mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
gumap
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>webui</title>
|
||||
<script type="module" crossorigin src="/assets/index-NGdTJyVK.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-ZYHUqx5G.css">
|
||||
<script type="module" crossorigin src="/assets/index-DFvdwweA.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DxO2_uri.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import AuthPanel from './AuthPanel.jsx';
|
||||
import AdminPanel from './AdminPanel.jsx';
|
||||
import KeymapSettings from './KeymapSettings.jsx';
|
||||
import InputSettings from './InputSettings.jsx';
|
||||
import GamepadMappingSettings from './GamepadMappingSettings.jsx';
|
||||
|
||||
const manualTabs = [
|
||||
{ key: 'start', label: 'Start OI' },
|
||||
@@ -35,7 +36,8 @@ export default function AdvancedSettings() {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<KeymapSettings />
|
||||
<InputSettings />
|
||||
{/* <InputSettings /> */}
|
||||
<GamepadMappingSettings />
|
||||
<section className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100">
|
||||
<p className="text-xs text-slate-400">Manual OI commands</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import { GAMEPAD_MAPPING_DEFAULT } from '../settings/namespaces.js';
|
||||
|
||||
function useGamepad() {
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function updateStatus() {
|
||||
const pads = navigator.getGamepads?.();
|
||||
setConnected(Boolean(pads && Array.from(pads).some(Boolean)));
|
||||
}
|
||||
updateStatus();
|
||||
window.addEventListener('gamepadconnected', updateStatus);
|
||||
window.addEventListener('gamepaddisconnected', updateStatus);
|
||||
const id = setInterval(updateStatus, 2000);
|
||||
return () => {
|
||||
window.removeEventListener('gamepadconnected', updateStatus);
|
||||
window.removeEventListener('gamepaddisconnected', updateStatus);
|
||||
clearInterval(id);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return connected;
|
||||
}
|
||||
|
||||
const ACTIONS = [
|
||||
{ id: 'driveHorizontal', label: 'Drive horizontal', type: 'axis', section: 'Drive stick', path: ['drive', 'horizontal'] },
|
||||
{ id: 'driveVertical', label: 'Drive vertical', type: 'axis', section: 'Drive stick', path: ['drive', 'vertical'] },
|
||||
{ id: 'cameraVertical', label: 'Camera vertical', type: 'axis', section: 'Camera stick', path: ['camera', 'vertical'] },
|
||||
{ id: 'mainTrigger', label: 'Main brush trigger', type: 'button', section: 'Brush triggers', path: ['triggers', 'main'] },
|
||||
{ id: 'mainReverse', label: 'Main reverse button', type: 'button', section: 'Brush triggers', path: ['buttons', 'mainReverse'] },
|
||||
{ id: 'sideTrigger', label: 'Side brush trigger', type: 'button', section: 'Brush triggers', path: ['triggers', 'side'] },
|
||||
{ id: 'sideReverse', label: 'Side reverse button', type: 'button', section: 'Brush triggers', path: ['buttons', 'sideReverse'] },
|
||||
{ id: 'vacuum', label: 'Vacuum button', type: 'button', section: 'Auxiliary buttons', path: ['buttons', 'vacuum'] },
|
||||
{ id: 'allAux', label: 'All aux forward button', type: 'button', section: 'Auxiliary buttons', path: ['buttons', 'allAux'] },
|
||||
{ id: 'driveMacro', label: 'Drive macro button', type: 'button', section: 'Mode buttons', path: ['buttons', 'driveMacro'] },
|
||||
{ id: 'dockMacro', label: 'Dock macro button', type: 'button', section: 'Mode buttons', path: ['buttons', 'dockMacro'] },
|
||||
];
|
||||
|
||||
function formatAxis(value) {
|
||||
if (!value) return 'Unassigned';
|
||||
return `Axis ${value.index}${value.invert ? ' (invert)' : ''}`;
|
||||
}
|
||||
|
||||
function formatButton(value) {
|
||||
if (!value) return 'Unassigned';
|
||||
return `Button ${value.index}`;
|
||||
}
|
||||
|
||||
function updatePath(mapping, path, updater) {
|
||||
const [group, key] = path;
|
||||
const next = { ...(mapping ?? GAMEPAD_MAPPING_DEFAULT) };
|
||||
next[group] = { ...(next[group] ?? GAMEPAD_MAPPING_DEFAULT[group]) };
|
||||
next[group][key] = updater(next[group][key]);
|
||||
return next;
|
||||
}
|
||||
|
||||
export default function GamepadMappingSettings() {
|
||||
const gamepadConnected = useGamepad();
|
||||
const { value: mapping, save, reset } = useSettingsNamespace('gamepadMapping', GAMEPAD_MAPPING_DEFAULT);
|
||||
const [capture, setCapture] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!capture) return undefined;
|
||||
let raf;
|
||||
const scan = () => {
|
||||
const pads = navigator.getGamepads?.();
|
||||
const pad = pads && Array.from(pads).find(Boolean);
|
||||
if (pad) {
|
||||
if (capture.type === 'axis') {
|
||||
for (let i = 0; i < pad.axes.length; i += 1) {
|
||||
const value = pad.axes[i];
|
||||
if (Math.abs(value) > 0.55) {
|
||||
save((prev) => updatePath(prev, capture.path, () => ({ index: i, invert: value < 0 })));
|
||||
setCapture(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (capture.type === 'button') {
|
||||
for (let i = 0; i < pad.buttons.length; i += 1) {
|
||||
const btn = pad.buttons[i];
|
||||
if (btn && (btn.pressed || btn.value > 0.6)) {
|
||||
save((prev) => updatePath(prev, capture.path, () => ({ index: i })));
|
||||
setCapture(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(scan);
|
||||
};
|
||||
raf = requestAnimationFrame(scan);
|
||||
return () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [capture, save]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
return ACTIONS.reduce((acc, action) => {
|
||||
const list = acc[action.section] || (acc[action.section] = []);
|
||||
list.push(action);
|
||||
return acc;
|
||||
}, {});
|
||||
}, []);
|
||||
|
||||
const getValueLabel = (action) => {
|
||||
const [group, key] = action.path;
|
||||
const stored = mapping?.[group]?.[key] ?? null;
|
||||
return action.type === 'axis' ? formatAxis(stored) : formatButton(stored);
|
||||
};
|
||||
|
||||
const handleClear = (action) => {
|
||||
save((prev) => updatePath(prev, action.path, () => null));
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-sm bg-[#1d232b] p-1 text-sm text-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">Gamepad mapping</p>
|
||||
<p className="text-[0.65rem] text-slate-500">
|
||||
{gamepadConnected ? 'Press buttons or move sticks when prompted.' : 'Connect a controller to configure.'}
|
||||
</p>
|
||||
{capture && (
|
||||
<p className="mt-1 text-[0.7rem] text-emerald-400">Capturing {capture.label}…</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reset()}
|
||||
className="rounded-sm bg-black/40 px-2 py-1 text-xs uppercase tracking-wide text-slate-200 hover:bg-black/60"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{Object.entries(grouped).map(([section, actions]) => (
|
||||
<div key={section} className="rounded border border-white/5 p-1">
|
||||
<p className="text-[0.7rem] uppercase tracking-wide text-slate-500">{section}</p>
|
||||
<div className="mt-1 space-y-1">
|
||||
{actions.map((action) => (
|
||||
<div key={action.id} className="flex items-center justify-between rounded bg-black/30 px-1 py-1 text-xs">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-100">{action.label}</p>
|
||||
<p className="text-[0.65rem] text-slate-400">{getValueLabel(action)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleClear(action)}
|
||||
className="rounded bg-slate-800 px-2 py-1 text-[0.65rem] uppercase tracking-wide"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCapture(action)}
|
||||
className={`rounded px-2 py-1 text-[0.65rem] uppercase tracking-wide ${capture?.id === action.id ? 'bg-emerald-500 text-emerald-950' : 'bg-slate-700 text-slate-100'}`}
|
||||
>
|
||||
{capture?.id === action.id ? 'Waiting…' : 'Capture'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useControlSystem } from '../ControlContext.jsx';
|
||||
import { clampUnit } from '../controlMath.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { INPUT_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
import { INPUT_SETTINGS_DEFAULTS, GAMEPAD_MAPPING_DEFAULT } from '../../settings/namespaces.js';
|
||||
|
||||
const SOURCE = 'gamepad';
|
||||
|
||||
@@ -44,25 +44,55 @@ export default function GamepadInputManager() {
|
||||
},
|
||||
} = useControlSystem();
|
||||
const { value: inputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS);
|
||||
const { value: mapping } = useSettingsNamespace('gamepadMapping', GAMEPAD_MAPPING_DEFAULT);
|
||||
const gamepadSettings = inputSettings.gamepad ?? INPUT_SETTINGS_DEFAULTS.gamepad;
|
||||
const driveDeadzone = Math.min(Math.max(gamepadSettings.driveDeadzone ?? 0.2, 0), 0.8);
|
||||
const cameraDeadzone = Math.min(Math.max(gamepadSettings.cameraDeadzone ?? 0.25, 0), 0.9);
|
||||
const servoStep = gamepadSettings.servoStep ?? INPUT_SETTINGS_DEFAULTS.gamepad.servoStep;
|
||||
const auxReverseScale = gamepadSettings.auxReverseScale ?? INPUT_SETTINGS_DEFAULTS.gamepad.auxReverseScale;
|
||||
const mappingReady =
|
||||
Boolean(mapping?.drive?.horizontal) &&
|
||||
Boolean(mapping?.drive?.vertical) &&
|
||||
Boolean(mapping?.camera?.vertical) &&
|
||||
Boolean(mapping?.triggers?.main) &&
|
||||
Boolean(mapping?.triggers?.side) &&
|
||||
Boolean(mapping?.buttons?.mainReverse) &&
|
||||
Boolean(mapping?.buttons?.sideReverse) &&
|
||||
Boolean(mapping?.buttons?.vacuum) &&
|
||||
Boolean(mapping?.buttons?.allAux) &&
|
||||
Boolean(mapping?.buttons?.driveMacro) &&
|
||||
Boolean(mapping?.buttons?.dockMacro);
|
||||
const rafRef = useRef(null);
|
||||
const lastVectorRef = useRef({ x: 0, y: 0, boost: false });
|
||||
const lastAuxRef = useRef({ main: 0, side: 0, vacuum: 0 });
|
||||
const buttonStateRef = useRef(new Map());
|
||||
const servoThrottleRef = useRef(0);
|
||||
|
||||
const handleButtonEdge = useCallback(
|
||||
(index, pressed) => {
|
||||
const prev = buttonStateRef.current.get(index) || false;
|
||||
buttonStateRef.current.set(index, pressed);
|
||||
return pressed && !prev;
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handleButtonEdge = useCallback((key, pressed) => {
|
||||
const prev = buttonStateRef.current.get(key) || false;
|
||||
buttonStateRef.current.set(key, pressed);
|
||||
return pressed && !prev;
|
||||
}, []);
|
||||
|
||||
const getAxisValue = useCallback((pad, descriptor) => {
|
||||
if (!descriptor) return 0;
|
||||
const raw = pad.axes?.[descriptor.index] ?? 0;
|
||||
return descriptor.invert ? -raw : raw;
|
||||
}, []);
|
||||
|
||||
const getButtonValue = useCallback((pad, descriptor) => {
|
||||
if (!descriptor) return 0;
|
||||
const btn = pad.buttons?.[descriptor.index];
|
||||
if (!btn) return 0;
|
||||
return typeof btn.value === 'number' ? btn.value : btn.pressed ? 1 : 0;
|
||||
}, []);
|
||||
|
||||
const isButtonPressed = useCallback((pad, descriptor) => {
|
||||
if (!descriptor) return false;
|
||||
const btn = pad.buttons?.[descriptor.index];
|
||||
if (!btn) return false;
|
||||
return btn.pressed || btn.value > 0.5;
|
||||
}, []);
|
||||
|
||||
const pollGamepads = useCallback(() => {
|
||||
if (typeof navigator === 'undefined' || !navigator.getGamepads) {
|
||||
@@ -79,23 +109,34 @@ export default function GamepadInputManager() {
|
||||
lastAuxRef.current = { main: 0, side: 0, vacuum: 0 };
|
||||
setAuxMotors({ main: 0, side: 0, vacuum: 0 });
|
||||
}
|
||||
registerInputState(SOURCE, { connected: false });
|
||||
registerInputState(SOURCE, { connected: false, mappingReady });
|
||||
return;
|
||||
}
|
||||
|
||||
const axisLX = clampUnit(applyDeadzone(pad.axes?.[0] ?? 0, driveDeadzone));
|
||||
const axisLY = clampUnit(applyDeadzone(-(pad.axes?.[1] ?? 0), driveDeadzone));
|
||||
if (!mappingReady) {
|
||||
registerInputState(SOURCE, { connected: true, mappingReady: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const axisLX = clampUnit(applyDeadzone(getAxisValue(pad, mapping.drive.horizontal), driveDeadzone));
|
||||
const axisLY = clampUnit(applyDeadzone(-getAxisValue(pad, mapping.drive.vertical), driveDeadzone));
|
||||
const vector = { x: axisLX, y: axisLY, boost: false };
|
||||
if (!vectorsEqual(vector, lastVectorRef.current)) {
|
||||
lastVectorRef.current = vector;
|
||||
setDriveVector(vector, { source: SOURCE });
|
||||
}
|
||||
|
||||
const main = computeTriggerSpeed(pad.buttons?.[7], pad.buttons?.[5]);
|
||||
const side = computeTriggerSpeed(pad.buttons?.[6], pad.buttons?.[4], 127, auxReverseScale);
|
||||
const vacuum = pad.buttons?.[1]?.pressed ? 127 : 0;
|
||||
const mainMagnitude = Math.round(getButtonValue(pad, mapping.triggers.main) * 127);
|
||||
const mainReverse = isButtonPressed(pad, mapping.buttons.mainReverse);
|
||||
const main = mainReverse ? -mainMagnitude : mainMagnitude;
|
||||
|
||||
const sideMagnitude = Math.round(getButtonValue(pad, mapping.triggers.side) * 127);
|
||||
const sideReverse = isButtonPressed(pad, mapping.buttons.sideReverse);
|
||||
const side = sideReverse ? -Math.round(sideMagnitude * auxReverseScale) : sideMagnitude;
|
||||
|
||||
const vacuum = isButtonPressed(pad, mapping.buttons.vacuum) ? 127 : 0;
|
||||
let aux = { main, side, vacuum };
|
||||
if (pad.buttons?.[0]?.pressed) {
|
||||
if (isButtonPressed(pad, mapping.buttons.allAux)) {
|
||||
aux = { main: 127, side: 127, vacuum: 127 };
|
||||
}
|
||||
if (!auxEqual(aux, lastAuxRef.current)) {
|
||||
@@ -103,18 +144,20 @@ export default function GamepadInputManager() {
|
||||
setAuxMotors(aux);
|
||||
}
|
||||
|
||||
const cameraAxis = clampUnit(applyDeadzone(-(pad.axes?.[3] ?? 0), cameraDeadzone));
|
||||
const cameraAxis = clampUnit(applyDeadzone(-getAxisValue(pad, mapping.camera.vertical), cameraDeadzone));
|
||||
const now = performance.now();
|
||||
if (Math.abs(cameraAxis) > 0.25 && now - servoThrottleRef.current > 120) {
|
||||
servoThrottleRef.current = now;
|
||||
nudgeServo(cameraAxis > 0 ? servoStep : -servoStep);
|
||||
}
|
||||
|
||||
if (handleButtonEdge(14, pad.buttons?.[14]?.pressed)) {
|
||||
const driveMacroPressed = isButtonPressed(pad, mapping.buttons.driveMacro);
|
||||
if (handleButtonEdge(`macro-${mapping.buttons.driveMacro.index}`, driveMacroPressed)) {
|
||||
setMode('drive');
|
||||
runMacro('drive-sequence');
|
||||
}
|
||||
if (handleButtonEdge(15, pad.buttons?.[15]?.pressed)) {
|
||||
const dockMacroPressed = isButtonPressed(pad, mapping.buttons.dockMacro);
|
||||
if (handleButtonEdge(`macro-${mapping.buttons.dockMacro.index}`, dockMacroPressed)) {
|
||||
setMode('dock');
|
||||
runMacro('seek-dock');
|
||||
}
|
||||
@@ -135,7 +178,22 @@ export default function GamepadInputManager() {
|
||||
auxReverseScale,
|
||||
cameraDeadzone,
|
||||
driveDeadzone,
|
||||
getAxisValue,
|
||||
getButtonValue,
|
||||
handleButtonEdge,
|
||||
isButtonPressed,
|
||||
mapping.buttons?.allAux,
|
||||
mapping.buttons?.dockMacro,
|
||||
mapping.buttons?.driveMacro,
|
||||
mapping.buttons?.mainReverse,
|
||||
mapping.buttons?.sideReverse,
|
||||
mapping.buttons?.vacuum,
|
||||
mapping.camera?.vertical,
|
||||
mapping.drive?.horizontal,
|
||||
mapping.drive?.vertical,
|
||||
mapping.triggers?.main,
|
||||
mapping.triggers?.side,
|
||||
mappingReady,
|
||||
nudgeServo,
|
||||
registerInputState,
|
||||
runMacro,
|
||||
|
||||
@@ -10,3 +10,25 @@ export const INPUT_SETTINGS_DEFAULTS = {
|
||||
joystickSmoothing: 0.15,
|
||||
},
|
||||
};
|
||||
|
||||
export const GAMEPAD_MAPPING_DEFAULT = {
|
||||
drive: {
|
||||
horizontal: null,
|
||||
vertical: null,
|
||||
},
|
||||
camera: {
|
||||
vertical: null,
|
||||
},
|
||||
triggers: {
|
||||
main: null,
|
||||
side: null,
|
||||
},
|
||||
buttons: {
|
||||
mainReverse: null,
|
||||
sideReverse: null,
|
||||
vacuum: null,
|
||||
allAux: null,
|
||||
driveMacro: null,
|
||||
dockMacro: null,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user