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
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title> <title>webui</title>
<script type="module" crossorigin src="/assets/index-BdPhVyWy.js"></script> <script type="module" crossorigin src="/assets/index-NmzNrOYX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C4V7xiyW.css"> <link rel="stylesheet" crossorigin href="/assets/index-C4V7xiyW.css">
</head> </head>
<body> <body>
+4 -1
View File
@@ -8,6 +8,7 @@ import MobileControls, {
MobileLandscapeControlColumn, MobileLandscapeControlColumn,
} from './components/MobileControls.jsx'; } from './components/MobileControls.jsx';
import { ControlSystemProvider, KeyboardInputManager, GamepadInputManager } from './controls/index.js'; import { ControlSystemProvider, KeyboardInputManager, GamepadInputManager } from './controls/index.js';
import { SettingsProvider } from './settings/index.js';
import RoomCameraPanel from './components/RoomCameraPanel.jsx'; import RoomCameraPanel from './components/RoomCameraPanel.jsx';
import LogPanel from './components/LogPanel.jsx'; import LogPanel from './components/LogPanel.jsx';
import AuthPanel from './components/AuthPanel.jsx'; import AuthPanel from './components/AuthPanel.jsx';
@@ -114,13 +115,15 @@ function App() {
return ( return (
<div className="min-h-screen bg-black text-slate-50"> <div className="min-h-screen bg-black text-slate-50">
<SettingsProvider>
<ControlSystemProvider> <ControlSystemProvider>
<KeyboardInputManager /> <KeyboardInputManager />
<GamepadInputManager /> <GamepadInputManager />
<main className="flex w-full flex-col gap-1 px-1 py-1 text-base">{renderedLayout}</main> <main className="flex w-full flex-col gap-1 text-base">{renderedLayout}</main>
<AlertFeed /> <AlertFeed />
<ModeGateOverlay /> <ModeGateOverlay />
</ControlSystemProvider> </ControlSystemProvider>
</SettingsProvider>
</div> </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 { controlReducer, initialControlState } from './controlReducer.js';
import { computeDifferentialSpeeds, clamp } from './controlMath.js'; import { computeDifferentialSpeeds, clamp } from './controlMath.js';
import { useCommandPipeline } from './commandPipeline.js'; import { useCommandPipeline } from './commandPipeline.js';
import { loadControlSettings, saveControlSettings } from './persistence.js'; import { DEFAULT_KEYMAP, DEFAULT_MACROS } from './constants.js';
import { DEFAULT_KEYMAP } from './constants.js';
import { canonicalizeKeyInput } from './keymapUtils.js'; import { canonicalizeKeyInput } from './keymapUtils.js';
import { useSettingsNamespace } from '../settings/index.js';
const ControlSystemContext = createContext(null); const ControlSystemContext = createContext(null);
@@ -25,16 +25,23 @@ export function ControlSystemProvider({ children }) {
const pipeline = useCommandPipeline(); const pipeline = useCommandPipeline();
const [state, dispatch] = useReducer(controlReducer, initialControlState); const [state, dispatch] = useReducer(controlReducer, initialControlState);
const servoAngleRef = useRef(initialControlState.camera.angle); const servoAngleRef = useRef(initialControlState.camera.angle);
const {
value: controlSettings,
save: saveControlSettings,
} = useSettingsNamespace('controls', { keymap: DEFAULT_KEYMAP, macros: DEFAULT_MACROS });
useEffect(() => { useEffect(() => {
dispatch({ type: 'control/set-rover', payload: pipeline.roverId }); dispatch({ type: 'control/set-rover', payload: pipeline.roverId });
}, [pipeline.roverId]); }, [pipeline.roverId]);
useEffect(() => { useEffect(() => {
dispatch({ type: 'control/settings/loading' }); if (controlSettings?.keymap) {
const data = loadControlSettings(); dispatch({ type: 'control/set-keymap', payload: controlSettings.keymap });
dispatch({ type: 'control/settings/loaded', payload: data }); }
}, []); if (controlSettings?.macros) {
dispatch({ type: 'control/set-macros', payload: controlSettings.macros });
}
}, [controlSettings?.keymap, controlSettings?.macros]);
useEffect(() => { useEffect(() => {
const config = pipeline.servoConfig; const config = pipeline.servoConfig;
@@ -98,23 +105,6 @@ export function ControlSystemProvider({ children }) {
[pipeline], [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( const updateKeyBinding = useCallback(
(bindingId, keyValue) => { (bindingId, keyValue) => {
if (!bindingId) return false; if (!bindingId) return false;
@@ -123,17 +113,23 @@ export function ControlSystemProvider({ children }) {
const next = cloneKeymap(state.keymap); const next = cloneKeymap(state.keymap);
next[bindingId] = [canonical]; next[bindingId] = [canonical];
dispatch({ type: 'control/set-keymap', payload: next }); dispatch({ type: 'control/set-keymap', payload: next });
persistSettings({ keymap: next }); saveControlSettings((current) => ({
...(current ?? {}),
keymap: next,
}));
return true; return true;
}, },
[state.keymap, persistSettings], [state.keymap, saveControlSettings],
); );
const resetKeyBindings = useCallback(() => { const resetKeyBindings = useCallback(() => {
const defaults = cloneKeymap(DEFAULT_KEYMAP); const defaults = cloneKeymap(DEFAULT_KEYMAP);
dispatch({ type: 'control/set-keymap', payload: defaults }); dispatch({ type: 'control/set-keymap', payload: defaults });
persistSettings({ keymap: defaults }); saveControlSettings((current) => ({
}, [persistSettings]); ...(current ?? {}),
keymap: defaults,
}));
}, [saveControlSettings]);
const setServoAngle = useCallback( const setServoAngle = useCallback(
(value) => { (value) => {
@@ -218,12 +214,6 @@ export function ControlSystemProvider({ children }) {
dispatch({ type: 'control/register-input-state', payload: { source, state: data } }); 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( const contextValue = useMemo(
() => ({ () => ({
state, state,
@@ -243,8 +233,6 @@ export function ControlSystemProvider({ children }) {
updateKeyBinding, updateKeyBinding,
resetKeyBindings, resetKeyBindings,
registerInputState, registerInputState,
reloadSettings,
persistSettings,
}, },
}), }),
[ [
@@ -263,8 +251,6 @@ export function ControlSystemProvider({ children }) {
updateKeyBinding, updateKeyBinding,
resetKeyBindings, resetKeyBindings,
registerInputState, 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, macros: DEFAULT_MACROS,
keymap: DEFAULT_KEYMAP, keymap: DEFAULT_KEYMAP,
inputs: {}, 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) { export function controlReducer(state, action) {
switch (action.type) { switch (action.type) {
case 'control/set-rover': 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': case 'control/set-keymap':
return { return {
...state, ...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;
}
}