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:
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
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
File diff suppressed because one or more lines are too long
@@ -12,7 +12,7 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<!-- site-metadata:inject -->
|
||||
<!-- analytics:inject -->
|
||||
<script type="module" crossorigin src="/assets/index-DZVbaoV6.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BSetI9nD.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DJhimuQc.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -4,11 +4,9 @@ research how midis can be played easier with drag and drop, auto selection based
|
||||
and make sure each toggle and counter actually has a purpose besides debugging or something more useful to the user, note skipped is just for debugging
|
||||
```
|
||||
1. setting to disable replay popups in spectator settings menu
|
||||
2. improve controller support for bignuts700
|
||||
3. add admin ui for VIP and private requests instead of only through discord
|
||||
4. automated rover needs help system
|
||||
5. make roverd self update checkout to main branch
|
||||
6. fix this:
|
||||
2. add admin ui for VIP and private requests instead of only through discord
|
||||
3. make roverd self update checkout to main branch
|
||||
4. fix this:
|
||||
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
|
||||
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
|
||||
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
||||
|
||||
@@ -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,9 +587,14 @@ export default function GamepadMappingSettings() {
|
||||
value={activeProfile.calibration?.turboSpeed ?? 500}
|
||||
onChange={(value) => updateCalibration({ turboSpeed: 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="Camera deadzone"
|
||||
description="Ignore small camera tilt drift"
|
||||
label="Velocity camera deadzone"
|
||||
description="Absolute mode always uses a 0.01 deadzone"
|
||||
min={0}
|
||||
max={0.4}
|
||||
step={0.01}
|
||||
@@ -546,6 +606,8 @@ export default function GamepadMappingSettings() {
|
||||
value={activeProfile.calibration?.cameraCurve ?? 'linear'}
|
||||
onChange={(value) => updateCalibration({ cameraCurve: value })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<SliderField
|
||||
label="Aux deadzone"
|
||||
description="Ignore small trigger noise"
|
||||
@@ -578,6 +640,7 @@ export default function GamepadMappingSettings() {
|
||||
value={activeProfile.calibration?.precisionSpeed ?? 100}
|
||||
onChange={(value) => updateCalibration({ precisionSpeed: value })}
|
||||
/>
|
||||
{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. */}
|
||||
@@ -594,6 +657,7 @@ export default function GamepadMappingSettings() {
|
||||
</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 driveDeadzone = Math.min(Math.max(calibration.driveDeadzone ?? 0.18, 0), 0.8);
|
||||
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);
|
||||
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);
|
||||
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) => [
|
||||
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,
|
||||
resolveButtonSource(padState, bindings[actionId]?.sources),
|
||||
]),
|
||||
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