slop tank steering

This commit is contained in:
legop3
2026-09-13 01:41:32 -04:00
parent 8e96c3cdae
commit 0f5a33c1de
15 changed files with 503 additions and 132 deletions
@@ -28,6 +28,24 @@ function SettingsGroupLabel({ children }) {
return <p className="mx-auto w-full max-w-lg text-sm font-semibold text-white">{children}</p>;
}
function physicalInputKey(source) {
/* Inversion and activation thresholds describe how an input is interpreted, not which physical
control it is. Ignoring those fields ensures Axis 3 cannot silently own both a tank track and
camera tilt merely because one binding happens to be inverted. */
if (source?.kind === 'axis' || source?.kind === 'axisButton') return `axis:${source.index}`;
if (source?.kind === 'button' || source?.kind === 'buttonAxis') return `button:${source.index}`;
return JSON.stringify(source);
}
function sourcesUseSamePhysicalInput(left, right) {
if (!left || !right) return false;
return physicalInputKey(left) === physicalInputKey(right);
}
function actionDriveMode(actionId) {
return ACTIONS.find((action) => action.id === actionId)?.driveMode ?? null;
}
function CurveField({ label, value, onChange }) {
return (
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
@@ -136,19 +154,15 @@ export default function GamepadMappingSettings() {
const [actionFilter, setActionFilter] = useState('');
const baselineRef = useRef(null);
const captureCandidateRef = useRef(null);
const grouped = useMemo(() => {
const query = actionFilter.trim().toLowerCase();
const visibleActions = query
? ACTIONS.filter((action) => `${action.label} ${action.section}`.toLowerCase().includes(query))
: ACTIONS;
return groupActions(visibleActions);
}, [actionFilter]);
useEffect(() => {
/* The Controller tab is a diagnostic surface. Locking for its entire mounted lifetime makes
calibration and casual input testing safe, not only the brief moment a binding is captured. */
return acquireControllerControlLock('controller-settings');
}, []);
/* The settings panel remains a live control surface so operators can tune calibration while
driving and immediately feel the result. Only capture owns the controller lock: without
that narrow guard, pressing the input being assigned could also drive a wheel, start a
motor, or toggle rover hardware before the new binding is saved. */
if (!captureAction) return undefined;
return acquireControllerControlLock('controller-binding-capture');
}, [captureAction]);
const activePad = useMemo(
() => pickActivePad(hubState.pads, gamepadSettings.activeInstanceKey),
@@ -166,6 +180,19 @@ export default function GamepadMappingSettings() {
);
return resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT);
}, [activeSignature, gamepadSettings?.defaults?.profile, gamepadSettings?.profiles]);
const driveMode = activeProfile.calibration?.driveMode === 'tank' ? 'tank' : 'single';
const grouped = useMemo(() => {
const query = actionFilter.trim().toLowerCase();
/* Only the active steering scheme is shown. Keeping inactive track/stick bindings out of the
mapping list prevents operators from tuning controls that currently have no runtime effect. */
const modeActions = ACTIONS.filter(
(action) => !action.driveMode || action.driveMode === driveMode,
);
const visibleActions = query
? modeActions.filter((action) => `${action.label} ${action.section}`.toLowerCase().includes(query))
: modeActions;
return groupActions(visibleActions);
}, [actionFilter, driveMode]);
useEffect(() => {
if (!activePad || !activeSignature) return;
@@ -219,12 +246,16 @@ export default function GamepadMappingSettings() {
current.profiles?.[activeSignature] ?? current?.defaults?.profile,
GAMEPAD_PROFILE_DEFAULT,
);
const descriptorKey = JSON.stringify(descriptor);
const bindingsWithoutConflict = Object.fromEntries(
Object.entries(baseProfile.bindings ?? {}).map(([actionId, binding]) => {
if (actionId === captureAction.id) return [actionId, binding];
const otherMode = actionDriveMode(actionId);
const captureMode = actionDriveMode(captureAction.id);
/* Opposing mode-only actions may intentionally reuse a physical input because runtime
never activates them together. Common actions still conflict with both modes. */
if (captureMode && otherMode && captureMode !== otherMode) return [actionId, binding];
const sources = (binding?.sources ?? []).filter(
(source) => JSON.stringify(source) !== descriptorKey,
(source) => !sourcesUseSamePhysicalInput(source, descriptor),
);
return [actionId, { ...binding, sources }];
}),
@@ -417,6 +448,11 @@ export default function GamepadMappingSettings() {
const outputs = diagnostics?.outputs;
if (!outputs) return false;
if (actionId === 'drive') return Math.hypot(outputs.driveVector.x, outputs.driveVector.y) > 0.01;
if (actionId === 'tankLeft') return Math.abs(outputs.tankTracks?.left ?? 0) > 0.01;
if (actionId === 'tankRight') return Math.abs(outputs.tankTracks?.right ?? 0) > 0.01;
if (actionId === 'tankCameraUp' || actionId === 'tankCameraDown') {
return Boolean(outputs.buttons[actionId]);
}
if (actionId === 'cameraTilt') return Math.abs(outputs.cameraAxis) > 0.01;
if (actionId === 'mainBrush') return Math.abs(outputs.auxAxis.main) > 0.01;
if (actionId === 'sideBrush') return Math.abs(outputs.auxAxis.side) > 0.01;
@@ -500,6 +536,25 @@ export default function GamepadMappingSettings() {
</div>
<p className="mt-0.5 text-xs leading-snug text-white">Override this only when the browser reports the controller incorrectly.</p>
</label>
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-1.5 text-sm text-white">
<span className="min-w-0 font-semibold text-white">Steering mode</span>
<select
value={driveMode}
onChange={(event) => {
updateCalibration({ driveMode: event.target.value });
setCaptureAction(null);
}}
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
>
<option value="single">Single stick</option>
<option value="tank">Tank sticks</option>
</select>
</div>
<p className="mt-0.5 text-xs leading-snug text-white">
Tank mode controls the left and right wheels with separate stick axes.
</p>
</label>
<SliderField
label="Drive deadzone"
description="Ignore small drive stick drift"
@@ -532,20 +587,27 @@ export default function GamepadMappingSettings() {
value={activeProfile.calibration?.turboSpeed ?? 500}
onChange={(value) => updateCalibration({ turboSpeed: value })}
/>
<SliderField
label="Camera deadzone"
description="Ignore small camera tilt drift"
min={0}
max={0.4}
step={0.01}
value={activeProfile.calibration?.cameraDeadzone ?? 0.08}
onChange={(value) => updateCalibration({ cameraDeadzone: value })}
/>
<CurveField
label="Camera response"
value={activeProfile.calibration?.cameraCurve ?? 'linear'}
onChange={(value) => updateCalibration({ cameraCurve: value })}
/>
{driveMode === 'single' && (
<>
{/* Analog-only settings are hidden in tank mode because its two D-pad camera
directions are digital velocity inputs. The values remain saved for when the
operator returns to single-stick steering. */}
<SliderField
label="Velocity camera deadzone"
description="Absolute mode always uses a 0.01 deadzone"
min={0}
max={0.4}
step={0.01}
value={activeProfile.calibration?.cameraDeadzone ?? 0.08}
onChange={(value) => updateCalibration({ cameraDeadzone: value })}
/>
<CurveField
label="Camera response"
value={activeProfile.calibration?.cameraCurve ?? 'linear'}
onChange={(value) => updateCalibration({ cameraCurve: value })}
/>
</>
)}
<SliderField
label="Aux deadzone"
description="Ignore small trigger noise"
@@ -578,22 +640,24 @@ export default function GamepadMappingSettings() {
value={activeProfile.calibration?.precisionSpeed ?? 100}
onChange={(value) => updateCalibration({ precisionSpeed: value })}
/>
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
{/* Camera mode is styled like the sliders so calibration controls read as one group
even though this specific setting is a select instead of a range input. */}
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-1.5 text-sm text-white">
<span className="min-w-0 font-semibold text-white">Camera mode</span>
<select
value={activeProfile.calibration?.cameraMode ?? 'velocity'}
onChange={(event) => updateCalibration({ cameraMode: event.target.value })}
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
>
<option value="absolute">Absolute</option>
<option value="velocity">Velocity</option>
</select>
</div>
<p className="mt-0.5 text-xs leading-snug text-white">Absolute maps stick to angle; velocity moves over time.</p>
</label>
{driveMode === 'single' && (
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
{/* Camera mode is styled like the sliders so calibration controls read as one group
even though this specific setting is a select instead of a range input. */}
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-1.5 text-sm text-white">
<span className="min-w-0 font-semibold text-white">Camera mode</span>
<select
value={activeProfile.calibration?.cameraMode ?? 'velocity'}
onChange={(event) => updateCalibration({ cameraMode: event.target.value })}
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
>
<option value="absolute">Absolute</option>
<option value="velocity">Velocity</option>
</select>
</div>
<p className="mt-0.5 text-xs leading-snug text-white">Absolute maps stick to angle; velocity moves over time.</p>
</label>
)}
<SliderField
label="Camera sensitivity"
description="Velocity mode degrees per second"
@@ -10,6 +10,23 @@ export const ACTIONS = [
kind: 'axisPair',
section: 'Driving',
invertDefaults: { invertX: false, invertY: true },
driveMode: 'single',
},
{
id: 'tankLeft',
label: 'Left track',
kind: 'axis',
section: 'Driving',
invertDefaults: { invert: true },
driveMode: 'tank',
},
{
id: 'tankRight',
label: 'Right track',
kind: 'axis',
section: 'Driving',
invertDefaults: { invert: true },
driveMode: 'tank',
},
{
id: 'cameraTilt',
@@ -17,7 +34,10 @@ export const ACTIONS = [
kind: 'axis',
section: 'Camera',
invertDefaults: { invert: true },
driveMode: 'single',
},
{ id: 'tankCameraUp', label: 'Camera up', kind: 'button', section: 'Camera', driveMode: 'tank' },
{ id: 'tankCameraDown', label: 'Camera down', kind: 'button', section: 'Camera', driveMode: 'tank' },
{
id: 'mainBrush',
label: 'Main brush',
@@ -32,12 +52,12 @@ export const ACTIONS = [
section: 'Brushes',
invertDefaults: { invert: false },
},
{ id: 'vacuum', label: 'Vacuum', kind: 'button', section: 'Aux buttons' },
{ id: 'allAux', label: 'All aux', kind: 'button', section: 'Aux buttons' },
{ id: 'vacuum', label: 'Vacuum only', kind: 'button', section: 'Aux buttons' },
{ id: 'allAux', label: 'All cleaning motors', kind: 'button', section: 'Aux buttons' },
{ id: 'mainReverse', label: 'Main reverse toggle', kind: 'button', section: 'Brush toggles' },
{ id: 'sideReverse', label: 'Side reverse toggle', kind: 'button', section: 'Brush toggles' },
{ id: 'driveMacro', label: 'Drive macro', kind: 'button', section: 'Mode macros' },
{ id: 'dockMacro', label: 'Dock macro', kind: 'button', section: 'Mode macros' },
{ id: 'driveMacro', label: 'Drive / undock sequence', kind: 'button', section: 'Mode controls' },
{ id: 'dockMacro', label: 'Manual docking assist', kind: 'button', section: 'Mode controls' },
{ id: 'headlightToggle', label: 'Headlight toggle', kind: 'button', section: 'Camera' },
{ id: 'laserToggle', label: 'Laser toggle', kind: 'button', section: 'Camera' },
{ id: 'boostModifier', label: 'Turbo modifier', kind: 'button', section: 'Driving' },
@@ -46,10 +66,10 @@ export const ACTIONS = [
{ id: 'micPtt', label: 'Microphone push to talk', kind: 'button', section: 'Audio and chat' },
{ id: 'chatFocus', label: 'Focus chat', kind: 'button', section: 'Audio and chat' },
{ id: 'videoFilterCycle', label: 'Cycle video filter', kind: 'button', section: 'Camera' },
{ id: 'songNoteUp', label: 'Song note up', kind: 'button', section: 'Audio and chat' },
{ id: 'songNoteDown', label: 'Song note down', kind: 'button', section: 'Audio and chat' },
{ id: 'homeAssistantOn', label: 'Next room control on', kind: 'button', section: 'Room controls' },
{ id: 'homeAssistantOff', label: 'Next room control off', kind: 'button', section: 'Room controls' },
{ id: 'songNoteUp', label: 'Play higher note', kind: 'button', section: 'Audio and chat', driveMode: 'single' },
{ id: 'songNoteDown', label: 'Play lower note', kind: 'button', section: 'Audio and chat', driveMode: 'single' },
{ id: 'homeAssistantOn', label: 'Turn next room control on', kind: 'button', section: 'Room controls' },
{ id: 'homeAssistantOff', label: 'Turn next room control off', kind: 'button', section: 'Room controls' },
/* Digital aux actions provide exact parity with the keyboard help surface. They coexist with
analog brush controls so each operator can choose proportional triggers or discrete buttons. */
{ id: 'auxMainForward', label: 'Main brush forward', kind: 'button', section: 'Aux buttons' },
@@ -555,11 +555,18 @@ export default function GamepadInputManager() {
here and continue through their established absolute/velocity mapping.
*/
const handledAsPtzZoom = latest.setCameraAxisIntent(outputs.cameraAxis);
/* Tank mode's camera input is a pair of direction buttons rather than a position-bearing
analog axis. Always interpret those buttons as velocity commands; absolute mode would
incorrectly jump directly to a servo endpoint on every D-pad press. The saved analog
camera preference remains untouched and resumes when single-stick steering is selected. */
const cameraCalibration = profile.calibration?.driveMode === 'tank'
? { ...profile.calibration, cameraMode: 'velocity' }
: profile.calibration;
if (
!handledAsPtzZoom &&
(profile.calibration?.cameraMode === 'velocity' || Math.abs(outputs.cameraAxis) > 0.001)
(cameraCalibration?.cameraMode === 'velocity' || Math.abs(outputs.cameraAxis) > 0.001)
) {
handleCameraAxis(outputs.cameraAxis, profile.calibration);
handleCameraAxis(outputs.cameraAxis, cameraCalibration);
}
/* Raw values remain in the dedicated hub used by diagnostics. The shared reducer only
@@ -31,6 +31,13 @@ const DIRECTION_GLYPHS = {
reverse: '',
};
const TANK_DIRECTION_GLYPHS = {
driveForward: ['up', 'up'],
driveBackward: ['down', 'down'],
driveLeft: ['down', 'up'],
driveRight: ['up', 'down'],
};
const COMPACT_BUTTON_NAMES = {
DUp: 'D↑',
DDown: 'D↓',
@@ -93,6 +100,12 @@ function compactSourceName(source, type) {
export function bindingForControllerAction(profile, actionId) {
const direct = profile?.bindings?.[actionId];
if (direct?.sources?.length) return { binding: direct, direction: null };
if (profile?.calibration?.driveMode === 'tank' && actionId === 'cameraUp') {
return { binding: profile?.bindings?.tankCameraUp ?? null, direction: null };
}
if (profile?.calibration?.driveMode === 'tank' && actionId === 'cameraDown') {
return { binding: profile?.bindings?.tankCameraDown ?? null, direction: null };
}
const alias = ACTION_ALIASES[actionId];
if (!alias) return { binding: direct ?? null, direction: null };
return {
@@ -102,6 +115,16 @@ export function bindingForControllerAction(profile, actionId) {
}
export function formatControllerBinding(profile, actionId, controller) {
if (profile?.calibration?.driveMode === 'tank' && TANK_DIRECTION_GLYPHS[actionId]) {
const leftSource = profile?.bindings?.tankLeft?.sources?.[0];
const rightSource = profile?.bindings?.tankRight?.sources?.[0];
if (!leftSource || !rightSource) return '—';
const type = controllerType(controller, profile?.promptStyle);
const [leftDirection, rightDirection] = TANK_DIRECTION_GLYPHS[actionId];
/* A tank movement is inherently a two-input gesture. Showing both compact stick directions
makes help labels accurate without spelling out controller model names or raw axis numbers. */
return `${compactSourceName(leftSource, type)} ${DIRECTION_GLYPHS[leftDirection]} + ${compactSourceName(rightSource, type)} ${DIRECTION_GLYPHS[rightDirection]}`;
}
const { binding, direction } = bindingForControllerAction(profile, actionId);
const source = binding?.sources?.[0];
if (!source) return '—';
@@ -4,16 +4,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { GAMEPAD_PROFILE_DEFAULT } from '../../settings/namespaces.js';
import { formatControllerBinding } from './controllerLabels.js';
import { describeController, formatControllerBinding } from './controllerLabels.js';
test('uses the manually selected PlayStation button family', () => {
const profile = { ...GAMEPAD_PROFILE_DEFAULT, promptStyle: 'playstation-dual-sense' };
const profile = { ...GAMEPAD_PROFILE_DEFAULT, promptStyle: 'playstation' };
const label = formatControllerBinding(profile, 'vacuum', {
id: 'Controller hidden by browser privacy mode',
mapping: 'standard',
});
assert.equal(label, 'X');
assert.equal(label, '');
});
test('recognizes the exact Linux DualSense browser identifier', () => {
const controller = {
id: '054c-0ce6-Sony Interactive Entertainment DualSense Wireless Controller',
mapping: 'standard',
};
assert.equal(describeController(controller).description, 'Sony DualSense (PS5)');
assert.equal(formatControllerBinding(GAMEPAD_PROFILE_DEFAULT, 'vacuum', controller), '○');
assert.equal(formatControllerBinding(GAMEPAD_PROFILE_DEFAULT, 'allAux', controller), '×');
});
test('falls back from a keyboard direction action to its controller axis', () => {
@@ -22,7 +33,23 @@ test('falls back from a keyboard direction action to its controller axis', () =>
mapping: 'standard',
});
assert.equal(label, 'Left stick ↑');
assert.equal(label, 'LS ↑');
});
test('tank steering prompts show both track directions compactly', () => {
const profile = {
...GAMEPAD_PROFILE_DEFAULT,
calibration: {
...GAMEPAD_PROFILE_DEFAULT.calibration,
driveMode: 'tank',
},
};
const controller = { id: 'Xbox Wireless Controller', mapping: 'standard' };
assert.equal(formatControllerBinding(profile, 'driveForward', controller), 'LS ↑ + RS ↑');
assert.equal(formatControllerBinding(profile, 'driveLeft', controller), 'LS ↓ + RS ↑');
assert.equal(formatControllerBinding(profile, 'cameraUp', controller), 'D↑');
assert.equal(formatControllerBinding(profile, 'cameraDown', controller), 'D↓');
});
test('a direct digital aux binding takes priority over its analog fallback', () => {
@@ -38,5 +65,5 @@ test('a direct digital aux binding takes priority over its analog fallback', ()
mapping: 'standard',
});
assert.equal(label, 'D-pad right');
assert.equal(label, 'D');
});
+93 -29
View File
@@ -1,6 +1,7 @@
// Gamepad Bindings
// Purpose: Defines default gamepad axis/button-to-action mappings and lookup helpers. Scope: Supplies binding metadata for gamepad input manager and settings UI.
const CURVE_EXPO = 1.6;
const ABSOLUTE_CAMERA_DEADZONE = 0.01;
/*
Binary actions share one resolver so the runtime, settings UI, diagnostics, and adaptive
@@ -8,6 +9,8 @@ const CURVE_EXPO = 1.6;
controller-local and does not add controller concepts to the shared command pipeline.
*/
export const GAMEPAD_BUTTON_ACTION_IDS = [
'tankCameraUp',
'tankCameraDown',
'vacuum',
'allAux',
'mainReverse',
@@ -72,11 +75,14 @@ export function resolveGamepadProfile(profile, defaults) {
...base,
...current,
behaviorVersion: base.behaviorVersion,
/* Old detector-specific prompt values are invalid for the replacement library. Returning to
automatic detection ensures a previously selected workaround cannot mask the real device. */
promptStyle: requiresBehaviorUpgrade ? base.promptStyle : current.promptStyle ?? base.promptStyle,
calibration: {
...(base.calibration ?? {}),
...(current.calibration ?? {}),
/* Version one introduced an absolute camera default and a 250 wheel-speed ceiling. Apply
the corrected behavior to persisted profiles once while preserving every user binding. */
/* Profile upgrades retain personal response tuning except for defaults whose old values
caused broken camera behavior or imposed an unintended drive-speed ceiling. */
...(requiresBehaviorUpgrade
? {
cameraMode: base.calibration?.cameraMode,
@@ -85,18 +91,12 @@ export function resolveGamepadProfile(profile, defaults) {
: {}),
},
bindings: {
...(base.bindings ?? {}),
...(current.bindings ?? {}),
/* The old defaults listed unrelated live axes as fallbacks. Since those indices still exist
on a standard pad, they were not real fallbacks and could make one stick own two actions.
Reset only these three version-one defaults; every explicitly digital binding survives. */
/* Version four intentionally replaces the old arbitrary default layout as one coherent
migration. Bindings are controller-local preferences, and the project does not retain
backwards compatibility with obsolete layouts; calibration and hardware metadata remain. */
...(requiresBehaviorUpgrade
? {
cameraTilt: base.bindings?.cameraTilt,
mainBrush: base.bindings?.mainBrush,
sideBrush: base.bindings?.sideBrush,
}
: {}),
? cloneProfile(base.bindings ?? {})
: { ...(base.bindings ?? {}), ...(current.bindings ?? {}) }),
},
};
}
@@ -232,20 +232,62 @@ export function computeGamepadOutputs(padState, profile) {
const bindings = profile?.bindings ?? {};
const calibration = profile?.calibration ?? {};
const driveBinding = bindings.drive ?? {};
const driveSource = resolveAxisPairSource(padState, driveBinding.sources);
let driveX = clampUnit(driveSource.x);
let driveY = clampUnit(driveSource.y);
const driveDeadzone = Math.min(Math.max(calibration.driveDeadzone ?? 0.18, 0), 0.8);
const driveCurved = applyRadialDeadzone(driveX, driveY, driveDeadzone);
driveX = applyCurve(driveCurved.x, calibration.driveCurve);
driveY = applyCurve(driveCurved.y, calibration.driveCurve);
const driveMode = calibration.driveMode === 'tank' ? 'tank' : 'single';
let driveX = 0;
let driveY = 0;
let driveSources;
let tankTracks = null;
if (driveMode === 'tank') {
const leftSource = resolveAxisSource(padState, bindings.tankLeft?.sources);
const rightSource = resolveAxisSource(padState, bindings.tankRight?.sources);
/* Each track gets its own axial deadzone and response curve before mixing. Applying a radial
deadzone to two independent throttles would make one track's drift or movement change the
activation threshold of the other, which is especially unpleasant during slow pivots. */
const leftTrack = applyCurve(
applyAxisDeadzone(clampUnit(leftSource.value), driveDeadzone),
calibration.driveCurve,
);
const rightTrack = applyCurve(
applyAxisDeadzone(clampUnit(rightSource.value), driveDeadzone),
calibration.driveCurve,
);
/* The shared drive mixer later computes left = forward + turn and right = forward - turn.
This inverse transform therefore preserves the requested track values exactly while keeping
tank-controller knowledge out of ControlContext and the rover command transport. */
driveX = clampUnit((leftTrack - rightTrack) / 2);
driveY = clampUnit((leftTrack + rightTrack) / 2);
tankTracks = { left: leftTrack, right: rightTrack };
driveSources = { tankLeft: leftSource.source, tankRight: rightSource.source };
} else {
const driveBinding = bindings.drive ?? {};
const driveSource = resolveAxisPairSource(padState, driveBinding.sources);
const driveCurved = applyRadialDeadzone(
clampUnit(driveSource.x),
clampUnit(driveSource.y),
driveDeadzone,
);
driveX = applyCurve(driveCurved.x, calibration.driveCurve);
driveY = applyCurve(driveCurved.y, calibration.driveCurve);
driveSources = { drive: driveSource.source };
}
const cameraBinding = bindings.cameraTilt ?? {};
const cameraSource = resolveAxisSource(padState, cameraBinding.sources);
const cameraDeadzone = Math.min(Math.max(calibration.cameraDeadzone ?? 0.08, 0), 0.8);
const cameraSource = driveMode === 'tank'
? { value: 0, source: null }
: resolveAxisSource(padState, cameraBinding.sources);
/* Absolute mode maps the stick directly across the servo's physical range. Its center needs
only a tiny noise guard; applying the velocity deadzone there creates a visibly unresponsive
band around the home angle and makes small position corrections feel delayed. */
const configuredCameraDeadzone = Math.min(
Math.max(calibration.cameraDeadzone ?? 0.08, 0),
0.8,
);
const cameraDeadzone = calibration.cameraMode === 'absolute'
? ABSOLUTE_CAMERA_DEADZONE
: configuredCameraDeadzone;
let cameraAxis = applyAxisDeadzone(clampUnit(cameraSource.value), cameraDeadzone);
cameraAxis = applyCurve(cameraAxis, calibration.cameraCurve);
const auxDeadzone = Math.min(Math.max(calibration.auxDeadzone ?? 0.05, 0), 0.6);
const mainBinding = bindings.mainBrush ?? {};
@@ -259,22 +301,44 @@ export function computeGamepadOutputs(padState, profile) {
sideAxis = applyCurve(sideAxis, calibration.auxCurve);
const buttonOutputs = Object.fromEntries(
GAMEPAD_BUTTON_ACTION_IDS.map((actionId) => [
actionId,
resolveButtonSource(padState, bindings[actionId]?.sources),
]),
GAMEPAD_BUTTON_ACTION_IDS.map((actionId) => {
/* D-pad vertical has two deliberate owners, one per steering mode. Suppressing the inactive
owner here lets both recommended layouts coexist in one controller profile without a
camera press also playing a song note after switching to tank steering. */
const inactiveForMode =
(driveMode === 'tank' && (actionId === 'songNoteUp' || actionId === 'songNoteDown')) ||
(driveMode === 'single' && (actionId === 'tankCameraUp' || actionId === 'tankCameraDown'));
return [
actionId,
inactiveForMode
? { pressed: false, source: null }
: resolveButtonSource(padState, bindings[actionId]?.sources),
];
}),
);
if (driveMode === 'tank') {
/* Direction buttons form the signed equivalent of the single analog camera axis. Opposing
presses cancel to zero, providing an immediate and deterministic stop for velocity mode. */
cameraAxis = Number(buttonOutputs.tankCameraUp.pressed) -
Number(buttonOutputs.tankCameraDown.pressed);
}
cameraAxis = applyCurve(cameraAxis, calibration.cameraCurve);
return {
driveVector: { x: driveX, y: driveY, boost: false },
// Track values are diagnostic-only; the runtime continues consuming driveVector exclusively.
tankTracks,
cameraAxis,
auxAxis: { main: mainAxis, side: sideAxis },
buttons: Object.fromEntries(
Object.entries(buttonOutputs).map(([actionId, output]) => [actionId, output.pressed]),
),
sources: {
drive: driveSource.source,
cameraTilt: cameraSource.source,
...driveSources,
cameraTilt: driveMode === 'tank'
? buttonOutputs.tankCameraUp.source ?? buttonOutputs.tankCameraDown.source
: cameraSource.source,
mainBrush: mainSource.source,
sideBrush: sideSource.source,
...Object.fromEntries(
@@ -3,7 +3,11 @@
// Scope: Exercises pure binding behavior without mounting React or opening a real controller.
import assert from 'node:assert/strict';
import test from 'node:test';
import { computeGamepadOutputs, resolveGamepadProfile } from './gamepadBindings.js';
import {
advanceCameraAngle,
computeGamepadOutputs,
resolveGamepadProfile,
} from './gamepadBindings.js';
import { GAMEPAD_PROFILE_DEFAULT } from '../../settings/namespaces.js';
function pad({ axes = [0, 0, 0, 0], pressed = [], values = {} } = {}) {
@@ -25,8 +29,105 @@ test('radial drive deadzone removes drift and rescales real movement', () => {
assert.ok(moving.driveVector.y > 0.49 && moving.driveVector.y < 0.51);
});
test('tank steering preserves independent left and right wheel requests', () => {
const tankProfile = {
...GAMEPAD_PROFILE_DEFAULT,
calibration: {
...GAMEPAD_PROFILE_DEFAULT.calibration,
driveMode: 'tank',
},
};
const forward = computeGamepadOutputs(pad({ axes: [0, -1, 0, -1] }), tankProfile);
assert.deepEqual(forward.tankTracks, { left: 1, right: 1 });
assert.deepEqual(forward.driveVector, { x: 0, y: 1, boost: false });
const pivotRight = computeGamepadOutputs(pad({ axes: [0, -1, 0, 1] }), tankProfile);
assert.deepEqual(pivotRight.tankTracks, { left: 1, right: -1 });
assert.deepEqual(pivotRight.driveVector, { x: 1, y: 0, boost: false });
const leftOnly = computeGamepadOutputs(pad({ axes: [0, -1, 0, 0] }), tankProfile);
assert.deepEqual(leftOnly.driveVector, { x: 0.5, y: 0.5, boost: false });
});
test('tank steering applies deadzone and remapping to each track independently', () => {
const tankProfile = {
...GAMEPAD_PROFILE_DEFAULT,
calibration: {
...GAMEPAD_PROFILE_DEFAULT.calibration,
driveMode: 'tank',
driveDeadzone: 0.2,
},
bindings: {
...GAMEPAD_PROFILE_DEFAULT.bindings,
tankLeft: { kind: 'axis', sources: [{ kind: 'axis', index: 0, invert: false }] },
tankRight: { kind: 'axis', sources: [{ kind: 'axis', index: 2, invert: true }] },
},
};
/* The left track is inside its own deadzone while the remapped right track reaches full output;
movement on one side must not pull the other side through a shared radial threshold. */
const output = computeGamepadOutputs(pad({ axes: [0.1, 0, -1, 0] }), tankProfile);
assert.deepEqual(output.tankTracks, { left: 0, right: 1 });
assert.deepEqual(output.driveVector, { x: -0.5, y: 0.5, boost: false });
});
test('tank camera buttons form one signed camera axis without playing song notes', () => {
const tankProfile = {
...GAMEPAD_PROFILE_DEFAULT,
calibration: {
...GAMEPAD_PROFILE_DEFAULT.calibration,
driveMode: 'tank',
},
};
const up = computeGamepadOutputs(pad({ pressed: [12] }), tankProfile);
assert.equal(up.cameraAxis, 1);
assert.equal(up.buttons.tankCameraUp, true);
assert.equal(up.buttons.songNoteUp, false);
const down = computeGamepadOutputs(pad({ pressed: [13] }), tankProfile);
assert.equal(down.cameraAxis, -1);
assert.equal(down.buttons.tankCameraDown, true);
assert.equal(down.buttons.songNoteDown, false);
const cancelled = computeGamepadOutputs(pad({ pressed: [12, 13] }), tankProfile);
assert.equal(cancelled.cameraAxis, 0);
});
test('single-stick mode keeps analog camera and song buttons separate', () => {
const output = computeGamepadOutputs(
pad({ axes: [0, 0, 0, -1], pressed: [12] }),
GAMEPAD_PROFILE_DEFAULT,
);
assert.equal(output.cameraAxis, 1);
assert.equal(output.buttons.songNoteUp, true);
assert.equal(output.buttons.tankCameraUp, false);
});
test('recommended standard-layout buttons resolve to the intended rover actions', () => {
const output = computeGamepadOutputs(
pad({ pressed: [0, 2, 4, 5, 9, 10, 15] }),
GAMEPAD_PROFILE_DEFAULT,
);
assert.equal(output.buttons.allAux, true);
assert.equal(output.buttons.vacuum, false);
assert.equal(output.buttons.hornHonk, true);
assert.equal(output.buttons.headlightToggle, true);
assert.equal(output.buttons.laserToggle, true);
assert.equal(output.buttons.driveMacro, true);
assert.equal(output.buttons.slowModifier, true);
assert.equal(output.buttons.homeAssistantOn, true);
assert.equal(output.buttons.mainReverse, false);
assert.equal(output.buttons.sideReverse, false);
assert.equal(output.buttons.boostModifier, false);
});
test('button chords require every constituent input', () => {
const profile = resolveGamepadProfile({
behaviorVersion: GAMEPAD_PROFILE_DEFAULT.behaviorVersion,
bindings: {
hornHonk: {
kind: 'button',
@@ -44,6 +145,7 @@ test('button chords require every constituent input', () => {
test('multiple button sources behave as alternatives instead of first-source-only fallbacks', () => {
const profile = resolveGamepadProfile({
behaviorVersion: GAMEPAD_PROFILE_DEFAULT.behaviorVersion,
bindings: {
laserToggle: {
kind: 'button',
@@ -60,8 +162,47 @@ test('profile resolution adds new actions without overwriting customized binding
kind: 'axisPair',
sources: [{ kind: 'axisPair', x: 2, y: 3, invertX: true, invertY: false }],
};
const resolved = resolveGamepadProfile({ bindings: { drive: customDrive } }, GAMEPAD_PROFILE_DEFAULT);
const resolved = resolveGamepadProfile({
behaviorVersion: GAMEPAD_PROFILE_DEFAULT.behaviorVersion,
bindings: { drive: customDrive },
}, GAMEPAD_PROFILE_DEFAULT);
assert.deepEqual(resolved.bindings.drive, customDrive);
assert.ok(resolved.bindings.hornHonk);
});
test('profile upgrade discards detector-specific prompt values', () => {
const resolved = resolveGamepadProfile({
behaviorVersion: 2,
promptStyle: 'playstation-dual-sense',
}, GAMEPAD_PROFILE_DEFAULT);
assert.equal(resolved.behaviorVersion, GAMEPAD_PROFILE_DEFAULT.behaviorVersion);
assert.equal(resolved.promptStyle, 'auto');
assert.deepEqual(resolved.bindings.allAux, GAMEPAD_PROFILE_DEFAULT.bindings.allAux);
assert.deepEqual(resolved.bindings.headlightToggle, GAMEPAD_PROFILE_DEFAULT.bindings.headlightToggle);
});
test('absolute camera mode always uses its fixed 0.01 deadzone', () => {
const profile = {
...GAMEPAD_PROFILE_DEFAULT,
calibration: {
...GAMEPAD_PROFILE_DEFAULT.calibration,
cameraMode: 'absolute',
cameraDeadzone: 0.4,
},
};
const inside = computeGamepadOutputs(pad({ axes: [0, 0, 0, -0.005] }), profile);
const outside = computeGamepadOutputs(pad({ axes: [0, 0, 0, -0.02] }), profile);
assert.equal(inside.cameraAxis, 0);
assert.ok(outside.cameraAxis > 0.01);
});
test('velocity camera accumulation clamps at the servo limit and reverses immediately', () => {
const atUpperLimit = advanceCameraAngle(45, 1, 180, 50, { min: -45, max: 45 });
const reversing = advanceCameraAngle(atUpperLimit, -1, 180, 50, { min: -45, max: 45 });
assert.equal(atUpperLimit, 45);
assert.equal(reversing, 36);
});
+45 -18
View File
@@ -11,10 +11,13 @@ export const INPUT_SETTINGS_DEFAULTS = {
};
export const GAMEPAD_PROFILE_DEFAULT = {
behaviorVersion: 2,
behaviorVersion: 4,
label: 'Default',
promptStyle: 'auto',
calibration: {
// Steering mode changes only how controller axes are interpreted. Both modes still emit the
// same normalized drive vector consumed by the shared rover control pipeline.
driveMode: 'single',
driveDeadzone: 0.18,
cameraDeadzone: 0.08,
auxDeadzone: 0.05,
@@ -33,10 +36,32 @@ export const GAMEPAD_PROFILE_DEFAULT = {
kind: 'axisPair',
sources: [{ kind: 'axisPair', x: 0, y: 1, invertX: false, invertY: true }],
},
// Tank steering treats the two vertical stick axes as independent wheel throttles. These
// remain separate bindings so controllers with unusual layouts can capture and invert each
// track without affecting the conventional single-stick mapping above.
tankLeft: {
kind: 'axis',
sources: [{ kind: 'axis', index: 1, invert: true }],
},
tankRight: {
kind: 'axis',
sources: [{ kind: 'axis', index: 3, invert: true }],
},
cameraTilt: {
kind: 'axis',
sources: [{ kind: 'axis', index: 3, invert: true }],
},
// Tank mode consumes both stick Y axes for driving, so its existing camera axis is exposed as
// two independently remappable buttons. Runtime combines them into the same signed camera
// value used by the analog single-stick binding; no camera-specific command path is added.
tankCameraUp: {
kind: 'button',
sources: [{ kind: 'button', index: 12 }],
},
tankCameraDown: {
kind: 'button',
sources: [{ kind: 'button', index: 13 }],
},
mainBrush: {
kind: 'axis',
sources: [{ kind: 'buttonAxis', index: 6 }],
@@ -47,23 +72,23 @@ export const GAMEPAD_PROFILE_DEFAULT = {
},
vacuum: {
kind: 'button',
sources: [{ kind: 'button', index: 0 }],
sources: [{ kind: 'button', index: 1 }],
},
allAux: {
kind: 'button',
sources: [{ kind: 'button', index: 1 }],
sources: [{ kind: 'button', index: 0 }],
},
mainReverse: {
kind: 'button',
sources: [{ kind: 'button', index: 4 }],
sources: [],
},
sideReverse: {
kind: 'button',
sources: [{ kind: 'button', index: 5 }],
sources: [],
},
driveMacro: {
kind: 'button',
sources: [{ kind: 'button', index: 2 }],
sources: [{ kind: 'button', index: 9 }],
},
dockMacro: {
kind: 'button',
@@ -71,51 +96,53 @@ export const GAMEPAD_PROFILE_DEFAULT = {
},
headlightToggle: {
kind: 'button',
sources: [{ kind: 'button', index: 9 }],
sources: [{ kind: 'button', index: 4 }],
},
laserToggle: {
kind: 'button',
sources: [],
sources: [{ kind: 'button', index: 5 }],
},
boostModifier: {
kind: 'button',
sources: [{ kind: 'button', index: 10 }],
// Full-stick driving already reaches the rover's 500-unit limit, so a default turbo button
// would claim a useful physical control without changing output.
sources: [],
},
slowModifier: {
kind: 'button',
sources: [{ kind: 'button', index: 11 }],
sources: [{ kind: 'button', index: 10 }],
},
hornHonk: {
kind: 'button',
sources: [{ kind: 'button', index: 12 }],
sources: [{ kind: 'button', index: 2 }],
},
micPtt: {
kind: 'button',
sources: [{ kind: 'button', index: 13 }],
sources: [],
},
videoFilterCycle: {
kind: 'button',
sources: [{ kind: 'button', index: 14 }],
sources: [],
},
chatFocus: {
kind: 'button',
sources: [{ kind: 'button', index: 15 }],
sources: [],
},
songNoteUp: {
kind: 'button',
sources: [],
sources: [{ kind: 'button', index: 12 }],
},
songNoteDown: {
kind: 'button',
sources: [],
sources: [{ kind: 'button', index: 13 }],
},
homeAssistantOn: {
kind: 'button',
sources: [],
sources: [{ kind: 'button', index: 15 }],
},
homeAssistantOff: {
kind: 'button',
sources: [],
sources: [{ kind: 'button', index: 14 }],
},
/* These direct digital aux actions mirror the keyboard contract exactly. They start empty
because the analog trigger/stick defaults above are friendlier on a controller, but users