This commit is contained in:
legop3
2025-11-18 03:15:30 -05:00
parent 5c66bbd809
commit 98b61a7845
13 changed files with 294 additions and 136 deletions
+10 -7
View File
@@ -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>
);
}
+108
View File
@@ -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>
);
}
+23 -37
View File
@@ -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,
],
);
-3
View File
@@ -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
-45
View File
@@ -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,
-36
View File
@@ -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;
}
}
+94
View File
@@ -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 };
}
+2
View File
@@ -0,0 +1,2 @@
export const SETTINGS_COOKIE = 'roverSettings';
export const SETTINGS_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
+1
View File
@@ -0,0 +1 @@
export { SettingsProvider, useSettings, useSettingsNamespace } from './SettingsProvider.jsx';
+12
View File
@@ -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,
},
};
+36
View File
@@ -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;
}
}