mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
guh
This commit is contained in:
+10
-7
@@ -8,6 +8,7 @@ import MobileControls, {
|
||||
MobileLandscapeControlColumn,
|
||||
} from './components/MobileControls.jsx';
|
||||
import { ControlSystemProvider, KeyboardInputManager, GamepadInputManager } from './controls/index.js';
|
||||
import { SettingsProvider } from './settings/index.js';
|
||||
import RoomCameraPanel from './components/RoomCameraPanel.jsx';
|
||||
import LogPanel from './components/LogPanel.jsx';
|
||||
import AuthPanel from './components/AuthPanel.jsx';
|
||||
@@ -114,13 +115,15 @@ function App() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-slate-50">
|
||||
<ControlSystemProvider>
|
||||
<KeyboardInputManager />
|
||||
<GamepadInputManager />
|
||||
<main className="flex w-full flex-col gap-1 px-1 py-1 text-base">{renderedLayout}</main>
|
||||
<AlertFeed />
|
||||
<ModeGateOverlay />
|
||||
</ControlSystemProvider>
|
||||
<SettingsProvider>
|
||||
<ControlSystemProvider>
|
||||
<KeyboardInputManager />
|
||||
<GamepadInputManager />
|
||||
<main className="flex w-full flex-col gap-1 text-base">{renderedLayout}</main>
|
||||
<AlertFeed />
|
||||
<ModeGateOverlay />
|
||||
</ControlSystemProvider>
|
||||
</SettingsProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import { INPUT_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
|
||||
|
||||
const NUMBER_FORMAT = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 });
|
||||
|
||||
function SliderField({ label, description, min, max, step, value, onChange }) {
|
||||
return (
|
||||
<label className="block rounded border border-white/5 p-1">
|
||||
<div className="flex items-center justify-between text-xs text-slate-300">
|
||||
<span className="font-semibold text-slate-100">{label}</span>
|
||||
<span className="font-mono text-slate-400">{NUMBER_FORMAT.format(value)}</span>
|
||||
</div>
|
||||
{description && <p className="text-[0.65rem] text-slate-500">{description}</p>}
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
className="mt-1 w-full accent-emerald-400"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InputSettings() {
|
||||
const { value, save } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS);
|
||||
const gamepad = value.gamepad ?? INPUT_SETTINGS_DEFAULTS.gamepad;
|
||||
const mobile = value.mobile ?? INPUT_SETTINGS_DEFAULTS.mobile;
|
||||
|
||||
const updateGamepad = (patch) => {
|
||||
save((prev) => ({
|
||||
...prev,
|
||||
gamepad: { ...(prev.gamepad ?? INPUT_SETTINGS_DEFAULTS.gamepad), ...patch },
|
||||
}));
|
||||
};
|
||||
|
||||
const updateMobile = (patch) => {
|
||||
save((prev) => ({
|
||||
...prev,
|
||||
mobile: { ...(prev.mobile ?? INPUT_SETTINGS_DEFAULTS.mobile), ...patch },
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-sm bg-[#1d232b] p-1 text-sm text-slate-100">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">Input tuning</p>
|
||||
<div className="mt-2 space-y-2">
|
||||
<div>
|
||||
<p className="text-[0.7rem] uppercase tracking-wide text-slate-500">Gamepad</p>
|
||||
<div className="mt-1 space-y-1">
|
||||
<SliderField
|
||||
label="Drive deadzone"
|
||||
description="Ignore small stick movements"
|
||||
min={0}
|
||||
max={0.5}
|
||||
step={0.01}
|
||||
value={gamepad.driveDeadzone}
|
||||
onChange={(driveDeadzone) => updateGamepad({ driveDeadzone })}
|
||||
/>
|
||||
<SliderField
|
||||
label="Camera deadzone"
|
||||
description="Tilt stick sensitivity"
|
||||
min={0}
|
||||
max={0.5}
|
||||
step={0.01}
|
||||
value={gamepad.cameraDeadzone}
|
||||
onChange={(cameraDeadzone) => updateGamepad({ cameraDeadzone })}
|
||||
/>
|
||||
<SliderField
|
||||
label="Servo step"
|
||||
description="Degrees per camera tick"
|
||||
min={0.5}
|
||||
max={6}
|
||||
step={0.25}
|
||||
value={gamepad.servoStep}
|
||||
onChange={(servoStep) => updateGamepad({ servoStep })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[0.7rem] uppercase tracking-wide text-slate-500">Mobile joystick</p>
|
||||
<div className="mt-1 space-y-1">
|
||||
<SliderField
|
||||
label="Joystick radius"
|
||||
description="Drag distance needed for full speed"
|
||||
min={50}
|
||||
max={140}
|
||||
step={5}
|
||||
value={mobile.joystickRadius}
|
||||
onChange={(joystickRadius) => updateMobile({ joystickRadius })}
|
||||
/>
|
||||
<SliderField
|
||||
label="Smoothing"
|
||||
description="Lower values feel more direct"
|
||||
min={0}
|
||||
max={0.6}
|
||||
step={0.02}
|
||||
value={mobile.joystickSmoothing}
|
||||
onChange={(joystickSmoothing) => updateMobile({ joystickSmoothing })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useReducer,
|
||||
import { controlReducer, initialControlState } from './controlReducer.js';
|
||||
import { computeDifferentialSpeeds, clamp } from './controlMath.js';
|
||||
import { useCommandPipeline } from './commandPipeline.js';
|
||||
import { loadControlSettings, saveControlSettings } from './persistence.js';
|
||||
import { DEFAULT_KEYMAP } from './constants.js';
|
||||
import { DEFAULT_KEYMAP, DEFAULT_MACROS } from './constants.js';
|
||||
import { canonicalizeKeyInput } from './keymapUtils.js';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
|
||||
const ControlSystemContext = createContext(null);
|
||||
|
||||
@@ -25,16 +25,23 @@ export function ControlSystemProvider({ children }) {
|
||||
const pipeline = useCommandPipeline();
|
||||
const [state, dispatch] = useReducer(controlReducer, initialControlState);
|
||||
const servoAngleRef = useRef(initialControlState.camera.angle);
|
||||
const {
|
||||
value: controlSettings,
|
||||
save: saveControlSettings,
|
||||
} = useSettingsNamespace('controls', { keymap: DEFAULT_KEYMAP, macros: DEFAULT_MACROS });
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'control/set-rover', payload: pipeline.roverId });
|
||||
}, [pipeline.roverId]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'control/settings/loading' });
|
||||
const data = loadControlSettings();
|
||||
dispatch({ type: 'control/settings/loaded', payload: data });
|
||||
}, []);
|
||||
if (controlSettings?.keymap) {
|
||||
dispatch({ type: 'control/set-keymap', payload: controlSettings.keymap });
|
||||
}
|
||||
if (controlSettings?.macros) {
|
||||
dispatch({ type: 'control/set-macros', payload: controlSettings.macros });
|
||||
}
|
||||
}, [controlSettings?.keymap, controlSettings?.macros]);
|
||||
|
||||
useEffect(() => {
|
||||
const config = pipeline.servoConfig;
|
||||
@@ -98,23 +105,6 @@ export function ControlSystemProvider({ children }) {
|
||||
[pipeline],
|
||||
);
|
||||
|
||||
const persistSettings = useCallback(
|
||||
(partial) => {
|
||||
const merged = { ...(state.settings.data ?? {}), ...(partial ?? {}) };
|
||||
const success = saveControlSettings(merged);
|
||||
if (success) {
|
||||
dispatch({ type: 'control/settings/loaded', payload: merged });
|
||||
} else {
|
||||
dispatch({
|
||||
type: 'control/settings/error',
|
||||
payload: new Error('Failed to save control settings'),
|
||||
});
|
||||
}
|
||||
return success;
|
||||
},
|
||||
[state.settings.data],
|
||||
);
|
||||
|
||||
const updateKeyBinding = useCallback(
|
||||
(bindingId, keyValue) => {
|
||||
if (!bindingId) return false;
|
||||
@@ -123,17 +113,23 @@ export function ControlSystemProvider({ children }) {
|
||||
const next = cloneKeymap(state.keymap);
|
||||
next[bindingId] = [canonical];
|
||||
dispatch({ type: 'control/set-keymap', payload: next });
|
||||
persistSettings({ keymap: next });
|
||||
saveControlSettings((current) => ({
|
||||
...(current ?? {}),
|
||||
keymap: next,
|
||||
}));
|
||||
return true;
|
||||
},
|
||||
[state.keymap, persistSettings],
|
||||
[state.keymap, saveControlSettings],
|
||||
);
|
||||
|
||||
const resetKeyBindings = useCallback(() => {
|
||||
const defaults = cloneKeymap(DEFAULT_KEYMAP);
|
||||
dispatch({ type: 'control/set-keymap', payload: defaults });
|
||||
persistSettings({ keymap: defaults });
|
||||
}, [persistSettings]);
|
||||
saveControlSettings((current) => ({
|
||||
...(current ?? {}),
|
||||
keymap: defaults,
|
||||
}));
|
||||
}, [saveControlSettings]);
|
||||
|
||||
const setServoAngle = useCallback(
|
||||
(value) => {
|
||||
@@ -218,12 +214,6 @@ export function ControlSystemProvider({ children }) {
|
||||
dispatch({ type: 'control/register-input-state', payload: { source, state: data } });
|
||||
}, []);
|
||||
|
||||
const reloadSettings = useCallback(() => {
|
||||
dispatch({ type: 'control/settings/loading' });
|
||||
const next = loadControlSettings();
|
||||
dispatch({ type: 'control/settings/loaded', payload: next });
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
state,
|
||||
@@ -243,8 +233,6 @@ export function ControlSystemProvider({ children }) {
|
||||
updateKeyBinding,
|
||||
resetKeyBindings,
|
||||
registerInputState,
|
||||
reloadSettings,
|
||||
persistSettings,
|
||||
},
|
||||
}),
|
||||
[
|
||||
@@ -263,8 +251,6 @@ export function ControlSystemProvider({ children }) {
|
||||
updateKeyBinding,
|
||||
resetKeyBindings,
|
||||
registerInputState,
|
||||
reloadSettings,
|
||||
persistSettings,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -62,6 +62,3 @@ export const DEFAULT_MACROS = [
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const CONTROL_SETTINGS_COOKIE = 'roverControlSettings';
|
||||
export const CONTROL_SETTINGS_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
||||
|
||||
@@ -30,33 +30,8 @@ export const initialControlState = {
|
||||
macros: DEFAULT_MACROS,
|
||||
keymap: DEFAULT_KEYMAP,
|
||||
inputs: {},
|
||||
settings: {
|
||||
status: 'idle',
|
||||
error: null,
|
||||
lastLoadedAt: null,
|
||||
lastSavedAt: null,
|
||||
data: null,
|
||||
},
|
||||
};
|
||||
|
||||
function mergeSettings(state, payload) {
|
||||
const nextSettings = {
|
||||
status: 'ready',
|
||||
error: null,
|
||||
lastLoadedAt: Date.now(),
|
||||
lastSavedAt: state.settings.lastSavedAt,
|
||||
data: payload ?? state.settings.data,
|
||||
};
|
||||
const nextState = { ...state, settings: nextSettings };
|
||||
if (payload?.keymap) {
|
||||
nextState.keymap = { ...state.keymap, ...payload.keymap };
|
||||
}
|
||||
if (payload?.macros) {
|
||||
nextState.macros = payload.macros;
|
||||
}
|
||||
return nextState;
|
||||
}
|
||||
|
||||
export function controlReducer(state, action) {
|
||||
switch (action.type) {
|
||||
case 'control/set-rover':
|
||||
@@ -124,26 +99,6 @@ export function controlReducer(state, action) {
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'control/settings/loading':
|
||||
return {
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
status: 'loading',
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
case 'control/settings/loaded':
|
||||
return mergeSettings(state, action.payload);
|
||||
case 'control/settings/error':
|
||||
return {
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
status: 'error',
|
||||
error: action.payload,
|
||||
},
|
||||
};
|
||||
case 'control/set-keymap':
|
||||
return {
|
||||
...state,
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { CONTROL_SETTINGS_COOKIE, CONTROL_SETTINGS_MAX_AGE } from './constants.js';
|
||||
|
||||
function parseCookieValue(raw) {
|
||||
try {
|
||||
const decoded = decodeURIComponent(raw ?? '');
|
||||
return JSON.parse(decoded);
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse control settings cookie', error); // eslint-disable-line no-console
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadControlSettings() {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const cookiePrefix = `${CONTROL_SETTINGS_COOKIE}=`;
|
||||
const entry = document.cookie
|
||||
.split(';')
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith(cookiePrefix));
|
||||
if (!entry) return null;
|
||||
const raw = entry.substring(cookiePrefix.length);
|
||||
return parseCookieValue(raw);
|
||||
}
|
||||
|
||||
export function saveControlSettings(settings) {
|
||||
if (typeof document === 'undefined') return false;
|
||||
try {
|
||||
const serialized = encodeURIComponent(JSON.stringify(settings ?? {}));
|
||||
const cookie = `${CONTROL_SETTINGS_COOKIE}=${serialized}; path=/; max-age=${CONTROL_SETTINGS_MAX_AGE}; samesite=strict`;
|
||||
document.cookie = cookie;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Failed to write control settings cookie', error); // eslint-disable-line no-console
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { loadSettings, saveSettings } from './persistence.js';
|
||||
|
||||
const SettingsContext = createContext(null);
|
||||
|
||||
export function SettingsProvider({ children }) {
|
||||
const [state, setState] = useState(() => ({ status: 'loading', data: {} }));
|
||||
|
||||
useEffect(() => {
|
||||
const loaded = loadSettings();
|
||||
setState({ status: 'ready', data: loaded ?? {} });
|
||||
}, []);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
const loaded = loadSettings();
|
||||
setState({ status: 'ready', data: loaded ?? {} });
|
||||
}, []);
|
||||
|
||||
const saveAll = useCallback((nextData) => {
|
||||
const success = saveSettings(nextData ?? {});
|
||||
if (success) {
|
||||
setState({ status: 'ready', data: nextData ?? {} });
|
||||
} else {
|
||||
setState((prev) => ({ ...prev, status: 'error' }));
|
||||
}
|
||||
return success;
|
||||
}, []);
|
||||
|
||||
const setNamespace = useCallback((namespace, updater) => {
|
||||
setState((prev) => {
|
||||
const current = prev.data[namespace] || {};
|
||||
const nextValue = typeof updater === 'function' ? updater(current) : { ...current, ...(updater ?? {}) };
|
||||
const nextData = { ...prev.data, [namespace]: nextValue };
|
||||
const success = saveSettings(nextData);
|
||||
if (!success) {
|
||||
return { ...prev, status: 'error' };
|
||||
}
|
||||
return { status: 'ready', data: nextData };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
data: state.data,
|
||||
status: state.status,
|
||||
reload,
|
||||
saveAll,
|
||||
setNamespace,
|
||||
}),
|
||||
[state, reload, saveAll, setNamespace],
|
||||
);
|
||||
|
||||
return <SettingsContext.Provider value={contextValue}>{children}</SettingsContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSettings() {
|
||||
const ctx = useContext(SettingsContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useSettings must be used within SettingsProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useSettingsNamespace(namespace, defaults = {}) {
|
||||
const { data, status, setNamespace } = useSettings();
|
||||
const value = data[namespace] ?? defaults ?? {};
|
||||
|
||||
const save = useCallback(
|
||||
(update) => {
|
||||
setNamespace(namespace, (current) => {
|
||||
const base = current ?? {};
|
||||
if (typeof update === 'function') {
|
||||
return update(base);
|
||||
}
|
||||
return { ...base, ...(update ?? {}) };
|
||||
});
|
||||
},
|
||||
[namespace, setNamespace],
|
||||
);
|
||||
|
||||
const replace = useCallback(
|
||||
(nextValue) => {
|
||||
setNamespace(namespace, () => nextValue ?? {});
|
||||
},
|
||||
[namespace, setNamespace],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
const base = typeof defaults === 'object' ? { ...defaults } : defaults;
|
||||
setNamespace(namespace, () => base ?? {});
|
||||
}, [defaults, namespace, setNamespace]);
|
||||
|
||||
return { value, status, save, replace, reset };
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const SETTINGS_COOKIE = 'roverSettings';
|
||||
export const SETTINGS_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
||||
@@ -0,0 +1 @@
|
||||
export { SettingsProvider, useSettings, useSettingsNamespace } from './SettingsProvider.jsx';
|
||||
@@ -0,0 +1,12 @@
|
||||
export const INPUT_SETTINGS_DEFAULTS = {
|
||||
gamepad: {
|
||||
driveDeadzone: 0.2,
|
||||
cameraDeadzone: 0.25,
|
||||
servoStep: 2,
|
||||
auxReverseScale: 0.55,
|
||||
},
|
||||
mobile: {
|
||||
joystickRadius: 80,
|
||||
joystickSmoothing: 0.15,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { SETTINGS_COOKIE, SETTINGS_MAX_AGE } from './constants.js';
|
||||
|
||||
function parseCookieValue(raw) {
|
||||
try {
|
||||
const decoded = decodeURIComponent(raw ?? '');
|
||||
return JSON.parse(decoded);
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse settings cookie', error); // eslint-disable-line no-console
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadSettings() {
|
||||
if (typeof document === 'undefined') return {};
|
||||
const cookiePrefix = `${SETTINGS_COOKIE}=`;
|
||||
const entry = document.cookie
|
||||
.split(';')
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith(cookiePrefix));
|
||||
if (!entry) return {};
|
||||
const raw = entry.substring(cookiePrefix.length);
|
||||
return parseCookieValue(raw) ?? {};
|
||||
}
|
||||
|
||||
export function saveSettings(settings) {
|
||||
if (typeof document === 'undefined') return false;
|
||||
try {
|
||||
const serialized = encodeURIComponent(JSON.stringify(settings ?? {}));
|
||||
const cookie = `${SETTINGS_COOKIE}=${serialized}; path=/; max-age=${SETTINGS_MAX_AGE}; samesite=strict`;
|
||||
document.cookie = cookie;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Failed to write settings cookie', error); // eslint-disable-line no-console
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user