mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
slop tank steering
This commit is contained in:
@@ -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→');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user