mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f5a33c1de | ||
|
|
8e96c3cdae | ||
|
|
ba5c1c5d25 | ||
|
|
6a914faffb | ||
|
|
3b99590b3b | ||
|
|
5acbf6e0bf | ||
|
|
3ec45de4b4 | ||
|
|
3b7b2ac21e | ||
|
|
02a32e2524 | ||
|
|
eb1fab50e4 | ||
|
|
f85e258c29 | ||
|
|
72b8db8a31 | ||
|
|
fb31ff52bd |
@@ -12,6 +12,9 @@ require('./src/services/eventBus');
|
||||
require('./src/services/modeManager');
|
||||
require('./src/services/lockdownGuard');
|
||||
require('./src/services/roverManager');
|
||||
// Help monitoring subscribes to roverManager telemetry before assignment and
|
||||
// session services begin consuming the resulting roster state.
|
||||
require('./src/services/roverHelpService');
|
||||
require('./src/services/commandService');
|
||||
require('./src/services/roverConnectionService');
|
||||
require('./src/services/assignmentService');
|
||||
|
||||
+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
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,8 +12,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<!-- site-metadata:inject -->
|
||||
<!-- analytics:inject -->
|
||||
<script type="module" crossorigin src="/assets/index-CNVNbsvk.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B5TEaoXl.css">
|
||||
<script type="module" crossorigin src="/assets/index-BSetI9nD.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DJhimuQc.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -97,6 +97,12 @@ roverManager.managerEvents.on('private', ({ roverId, open }) => {
|
||||
}
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('help', ({ needsHelp }) => {
|
||||
// Entering HELP affects only future automatic placement. When HELP clears,
|
||||
// retry people who were waiting because every healthy rover was unavailable.
|
||||
if (!needsHelp) reassignWaiting();
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('rover', ({ roverId, action }) => {
|
||||
if (action === 'removed') {
|
||||
/*
|
||||
@@ -238,7 +244,10 @@ function pickRover(socket, options = {}) {
|
||||
return null;
|
||||
}
|
||||
const allCandidates = Array.from(roverManager.rovers.values()).filter((rover) => {
|
||||
if (!rover || rover.locked) return false;
|
||||
// HELP removes a rover only from automatic placement. Existing drivers are
|
||||
// not displaced, and explicit requestControl calls retain their normal
|
||||
// access policy so a person can deliberately take control to rescue it.
|
||||
if (!rover || rover.locked || rover.needsHelp) return false;
|
||||
const access = roverManager.canRequestControl(rover.id, socket, { allowUser: true });
|
||||
if (!access.ok) return false;
|
||||
return true;
|
||||
|
||||
@@ -6,7 +6,7 @@ const { buildBatteryStatusEmbed, buildBatteryCaption } = require('../batteryEmbe
|
||||
|
||||
function createBusEventHandler(deps) {
|
||||
const { logger, discordConfig, roverManager, rovers, schedulePresenceRotation, formatDuration, sendToChannel } = deps;
|
||||
const ADMIN_ALERT_EVENT_TYPES = new Set(['rover.online', 'rover.offline', 'rover.dockGuard', 'battery.warn', 'battery.urgent', 'battery.docked', 'battery.undocked', 'battery.charging.start', 'battery.charging.stop', 'battery.locked', 'battery.unlocked']);
|
||||
const ADMIN_ALERT_EVENT_TYPES = new Set(['rover.online', 'rover.offline', 'rover.dockGuard', 'rover.helpNeeded', 'rover.helpCleared', 'battery.warn', 'battery.urgent', 'battery.docked', 'battery.undocked', 'battery.charging.start', 'battery.charging.stop', 'battery.locked', 'battery.unlocked']);
|
||||
let skippedFirstModeAnnouncement = false;
|
||||
|
||||
function buildEmbed({ title, description, color, includeSiteUrl = true }) {
|
||||
@@ -85,6 +85,23 @@ function createBusEventHandler(deps) {
|
||||
case 'rover.dockGuard':
|
||||
announce({ channelId: channels.adminAlerts, color: 0xf0b651, title: 'Dock Guard Triggered', description: `${payload?.roverId} (${payload?.reasonText || 'undocked'}) for ${formatDuration(payload?.idleMs)}.` });
|
||||
break;
|
||||
case 'rover.helpNeeded':
|
||||
announce({
|
||||
channelId: channels.adminAlerts,
|
||||
pingRoleId: roles.adminPing || null,
|
||||
color: 0xef4444,
|
||||
title: 'Rover Needs Help',
|
||||
description: `${payload?.roverName || payload?.roverId || 'Unknown rover'}: ${payload?.reason || 'a sustained rover fault was detected'}.`,
|
||||
});
|
||||
break;
|
||||
case 'rover.helpCleared':
|
||||
announce({
|
||||
channelId: channels.adminAlerts,
|
||||
color: 0x4caf50,
|
||||
title: 'Rover Help Cleared',
|
||||
description: `${payload?.roverName || payload?.roverId || 'Unknown rover'} no longer needs help.`,
|
||||
});
|
||||
break;
|
||||
case 'battery.warn':
|
||||
announce({ channelId: channels.adminAlerts, pingRoleId: roles.adminPing || null, color: 0xf0b651, content: buildBatteryCaption(type, rovers.get(payload?.roverId || 'unknown')), embeds: [buildBatteryStatusEmbed({ color: 0xf0b651, records: Array.from(rovers.values()) })] });
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Rover Help Horn Notifier
|
||||
// Purpose: Repeats a short, disruptive locator chirp while a rover needs help.
|
||||
// Scope: Uses the existing server-to-roverd horn start/stop protocol without changing roverd.
|
||||
const HELP_HORN_FREQUENCY_HZ = 2000;
|
||||
const HELP_HORN_DURATION_MS = 250;
|
||||
const HELP_HORN_INTERVAL_MS = 5 * 1000;
|
||||
const HELP_ROOMBA_NOTE = 83;
|
||||
const HELP_ROOMBA_NOTE_DURATION = 16;
|
||||
|
||||
function createHelpHornNotifier({
|
||||
getRover,
|
||||
issueCommand,
|
||||
logger,
|
||||
setIntervalFn = setInterval,
|
||||
clearIntervalFn = clearInterval,
|
||||
setTimeoutFn = setTimeout,
|
||||
clearTimeoutFn = clearTimeout,
|
||||
} = {}) {
|
||||
const intervals = new Map();
|
||||
const stopTimers = new Map();
|
||||
|
||||
function stopPendingHorn(roverId) {
|
||||
const id = String(roverId);
|
||||
const stopTimer = stopTimers.get(id);
|
||||
if (stopTimer != null) clearTimeoutFn(stopTimer);
|
||||
stopTimers.delete(id);
|
||||
|
||||
// The Roomba song is self-terminating. With no pending external-horn stop,
|
||||
// there is no persistent sound owned by this notifier that needs cleanup.
|
||||
if (stopTimer == null) return;
|
||||
|
||||
// Always send a final stop during cleanup. This ensures HELP clearing in
|
||||
// the middle of a 250 ms chirp silences it immediately instead of waiting
|
||||
// for a timeout that was just cancelled.
|
||||
const record = getRover?.(id);
|
||||
if (!record?.ws) return;
|
||||
try {
|
||||
issueCommand(id, { type: 'horn', horn: { action: 'stop' } });
|
||||
} catch (err) {
|
||||
logger?.warn?.('Failed to stop rover help horn', { roverId: id, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
function chirp(roverId) {
|
||||
const id = String(roverId);
|
||||
const record = getRover?.(id);
|
||||
if (!record?.ws) return false;
|
||||
|
||||
let sounded = false;
|
||||
let externalHornStarted = false;
|
||||
if (record.meta?.horn?.enabled) {
|
||||
try {
|
||||
issueCommand(id, {
|
||||
type: 'horn',
|
||||
horn: {
|
||||
action: 'start',
|
||||
waveform: 'saw',
|
||||
freqs: [HELP_HORN_FREQUENCY_HZ],
|
||||
},
|
||||
});
|
||||
sounded = true;
|
||||
externalHornStarted = true;
|
||||
} catch (err) {
|
||||
logger?.warn?.('Failed to start rover help horn', { roverId: id, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Roomba 600-series songs use MIDI notes and 1/64-second durations.
|
||||
// Note 83 is approximately 987.8 Hz: one octave below the closest MIDI
|
||||
// pitch to the external 2000 Hz horn. Duration 16 matches its 250 ms pulse.
|
||||
issueCommand(id, {
|
||||
type: 'song',
|
||||
song: {
|
||||
notes: [{ note: HELP_ROOMBA_NOTE, duration: HELP_ROOMBA_NOTE_DURATION }],
|
||||
},
|
||||
});
|
||||
sounded = true;
|
||||
} catch (err) {
|
||||
logger?.warn?.('Failed to play rover help song', { roverId: id, error: err.message });
|
||||
}
|
||||
|
||||
if (externalHornStarted) {
|
||||
// There can be only one pending automatic stop for a rover. Replacing an
|
||||
// unexpected stale timer keeps the external pulse duration bounded; the
|
||||
// independently issued Roomba song always ends itself.
|
||||
const previousStop = stopTimers.get(id);
|
||||
if (previousStop != null) clearTimeoutFn(previousStop);
|
||||
stopTimers.set(
|
||||
id,
|
||||
setTimeoutFn(() => {
|
||||
stopTimers.delete(id);
|
||||
const current = getRover?.(id);
|
||||
if (!current?.ws) return;
|
||||
try {
|
||||
issueCommand(id, { type: 'horn', horn: { action: 'stop' } });
|
||||
} catch (err) {
|
||||
logger?.warn?.('Failed to finish rover help chirp', { roverId: id, error: err.message });
|
||||
}
|
||||
}, HELP_HORN_DURATION_MS),
|
||||
);
|
||||
}
|
||||
return sounded;
|
||||
}
|
||||
|
||||
function start(roverId) {
|
||||
const id = String(roverId);
|
||||
if (intervals.has(id)) return;
|
||||
const record = getRover?.(id);
|
||||
if (!record?.ws) return;
|
||||
|
||||
// Sound immediately so a newly detected rover can be located without
|
||||
// waiting through the first five-second interval.
|
||||
chirp(id);
|
||||
intervals.set(id, setIntervalFn(() => chirp(id), HELP_HORN_INTERVAL_MS));
|
||||
}
|
||||
|
||||
function stop(roverId) {
|
||||
const id = String(roverId);
|
||||
const interval = intervals.get(id);
|
||||
if (interval != null) clearIntervalFn(interval);
|
||||
intervals.delete(id);
|
||||
stopPendingHorn(id);
|
||||
}
|
||||
|
||||
return {
|
||||
chirp,
|
||||
start,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HELP_HORN_DURATION_MS,
|
||||
HELP_HORN_FREQUENCY_HZ,
|
||||
HELP_HORN_INTERVAL_MS,
|
||||
HELP_ROOMBA_NOTE,
|
||||
HELP_ROOMBA_NOTE_DURATION,
|
||||
createHelpHornNotifier,
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
// Rover Help Horn Notifier Tests
|
||||
// Purpose: Verifies the exact chirp payload, cadence, duration, and cleanup without real timers.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
HELP_HORN_DURATION_MS,
|
||||
HELP_HORN_FREQUENCY_HZ,
|
||||
HELP_HORN_INTERVAL_MS,
|
||||
HELP_ROOMBA_NOTE,
|
||||
HELP_ROOMBA_NOTE_DURATION,
|
||||
createHelpHornNotifier,
|
||||
} = require('./hornNotifier');
|
||||
|
||||
function createHarness({ enabled = true } = {}) {
|
||||
const commands = [];
|
||||
const intervals = new Map();
|
||||
const timeouts = new Map();
|
||||
const clearedIntervals = [];
|
||||
const clearedTimeouts = [];
|
||||
let nextTimerId = 1;
|
||||
const notifier = createHelpHornNotifier({
|
||||
getRover: () => ({ ws: {}, meta: { horn: { enabled } } }),
|
||||
issueCommand: (roverId, payload) => commands.push({ roverId, payload }),
|
||||
setIntervalFn: (callback, ms) => {
|
||||
const id = nextTimerId++;
|
||||
intervals.set(id, { callback, ms });
|
||||
return id;
|
||||
},
|
||||
clearIntervalFn: (id) => clearedIntervals.push(id),
|
||||
setTimeoutFn: (callback, ms) => {
|
||||
const id = nextTimerId++;
|
||||
timeouts.set(id, { callback, ms });
|
||||
return id;
|
||||
},
|
||||
clearTimeoutFn: (id) => clearedTimeouts.push(id),
|
||||
});
|
||||
return { clearedIntervals, clearedTimeouts, commands, intervals, notifier, timeouts };
|
||||
}
|
||||
|
||||
test('starts matching external and Roomba chirps and schedules the agreed cadence', () => {
|
||||
const harness = createHarness();
|
||||
harness.notifier.start('red');
|
||||
|
||||
assert.deepEqual(harness.commands, [
|
||||
{
|
||||
roverId: 'red',
|
||||
payload: {
|
||||
type: 'horn',
|
||||
horn: { action: 'start', waveform: 'saw', freqs: [HELP_HORN_FREQUENCY_HZ] },
|
||||
},
|
||||
},
|
||||
{
|
||||
roverId: 'red',
|
||||
payload: {
|
||||
type: 'song',
|
||||
song: {
|
||||
notes: [{ note: HELP_ROOMBA_NOTE, duration: HELP_ROOMBA_NOTE_DURATION }],
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.equal(Array.from(harness.intervals.values())[0].ms, HELP_HORN_INTERVAL_MS);
|
||||
assert.equal(Array.from(harness.timeouts.values())[0].ms, HELP_HORN_DURATION_MS);
|
||||
|
||||
Array.from(harness.timeouts.values())[0].callback();
|
||||
assert.equal(harness.commands.at(-1).payload.horn.action, 'stop');
|
||||
});
|
||||
|
||||
test('stop cancels cadence and pending pulse before issuing a final horn stop', () => {
|
||||
const harness = createHarness();
|
||||
harness.notifier.start('blue');
|
||||
const intervalId = Array.from(harness.intervals.keys())[0];
|
||||
const timeoutId = Array.from(harness.timeouts.keys())[0];
|
||||
|
||||
harness.notifier.stop('blue');
|
||||
assert.deepEqual(harness.clearedIntervals, [intervalId]);
|
||||
assert.deepEqual(harness.clearedTimeouts, [timeoutId]);
|
||||
assert.equal(harness.commands.at(-1).payload.horn.action, 'stop');
|
||||
});
|
||||
|
||||
test('still schedules the Roomba song when the external horn is disabled', () => {
|
||||
const harness = createHarness({ enabled: false });
|
||||
harness.notifier.start('green');
|
||||
assert.deepEqual(harness.commands, [{
|
||||
roverId: 'green',
|
||||
payload: {
|
||||
type: 'song',
|
||||
song: {
|
||||
notes: [{ note: HELP_ROOMBA_NOTE, duration: HELP_ROOMBA_NOTE_DURATION }],
|
||||
},
|
||||
},
|
||||
}]);
|
||||
assert.equal(harness.intervals.size, 1);
|
||||
assert.equal(harness.timeouts.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
// Rover Help Service
|
||||
// Purpose: Publishes sustained 600-series Roomba trouble as roster and alert state.
|
||||
// Scope: Integrates the pure monitor with roverManager, browser alerts, and the event bus.
|
||||
const roverManager = require('../roverManager');
|
||||
const { sendAlert } = require('../alertService');
|
||||
const { publishEvent } = require('../eventBus');
|
||||
const { REASON_LABELS, createRoverHelpMonitor } = require('./monitor');
|
||||
const { createHelpHornNotifier } = require('./hornNotifier');
|
||||
|
||||
const HELP_ALERT_COLOR = '#ef4444';
|
||||
|
||||
const hornNotifier = createHelpHornNotifier({
|
||||
getRover: (roverId) => roverManager.rovers.get(String(roverId)),
|
||||
// commandService imports roverManager, so resolving it only when a chirp is
|
||||
// actually issued avoids turning server startup order into a circular module
|
||||
// dependency while retaining the established command transport.
|
||||
issueCommand: (roverId, payload) => require('../commandService').issueCommand(roverId, payload),
|
||||
logger: require('../../globals/logger').child('roverHelpService'),
|
||||
});
|
||||
|
||||
const monitor = createRoverHelpMonitor({
|
||||
onChange({ roverId, needsHelp, addedReason, reasons }) {
|
||||
const record = roverManager.rovers.get(String(roverId));
|
||||
if (!record) return;
|
||||
const wasNeedingHelp = Boolean(record.needsHelp);
|
||||
roverManager.setNeedsHelp(roverId, needsHelp);
|
||||
|
||||
if (!wasNeedingHelp && needsHelp) {
|
||||
hornNotifier.start(roverId);
|
||||
const reason = REASON_LABELS[addedReason] || 'a sustained rover fault was detected';
|
||||
sendAlert({
|
||||
color: HELP_ALERT_COLOR,
|
||||
title: 'Rover Needs Help',
|
||||
message: `${record.meta?.name || roverId}: ${reason}.`,
|
||||
});
|
||||
// Discord owns presentation of this event. The UI intentionally receives
|
||||
// only the roster boolean, keeping every HELP overlay free of reason text.
|
||||
publishEvent({
|
||||
source: 'roverHelpService',
|
||||
type: 'rover.helpNeeded',
|
||||
payload: { roverId, roverName: record.meta?.name || roverId, reason, reasons },
|
||||
});
|
||||
} else if (wasNeedingHelp && !needsHelp) {
|
||||
hornNotifier.stop(roverId);
|
||||
publishEvent({
|
||||
source: 'roverHelpService',
|
||||
type: 'rover.helpCleared',
|
||||
payload: { roverId, roverName: record.meta?.name || roverId },
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('sensor', ({ roverId, sensors }) => {
|
||||
monitor.handleSensor(roverId, sensors);
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('dockGuard', (event) => {
|
||||
monitor.handleDockGuard(event);
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('rover', ({ roverId, action }) => {
|
||||
// A reconnect gets a fresh record and fresh persistence timers; stale sensor
|
||||
// history from a disconnected chassis must never immediately restore HELP.
|
||||
if (action === 'removed') {
|
||||
hornNotifier.stop(roverId);
|
||||
monitor.removeRover(roverId);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = { hornNotifier, monitor };
|
||||
@@ -0,0 +1,177 @@
|
||||
// Rover Help Monitor
|
||||
// Purpose: Converts sustained 600-series Roomba sensor conditions into one help state.
|
||||
// Scope: Owns timing and reason state without performing roster fanout, alerts, or Discord I/O.
|
||||
const WHEEL_DROP_HELP_MS = 15 * 60 * 1000;
|
||||
const CLIFF_HELP_MS = 10 * 60 * 1000;
|
||||
const DOCK_GUARD_HELP_MS = 15 * 60 * 1000;
|
||||
|
||||
const REASON_LABELS = Object.freeze({
|
||||
wheelDrop: 'a wheel-drop sensor remained active for 15 minutes',
|
||||
cliff: 'the same cliff-sensor pattern remained active for 10 minutes',
|
||||
docking: 'automatic docking remained active for 15 minutes',
|
||||
});
|
||||
|
||||
function cliffPattern(sensors) {
|
||||
// A four-bit pattern distinguishes one continuously held physical situation
|
||||
// from a rover encountering different edges. Zero means no active cliff and
|
||||
// therefore cannot begin or retain a cliff-help timer.
|
||||
return [
|
||||
sensors?.cliffLeft,
|
||||
sensors?.cliffFrontLeft,
|
||||
sensors?.cliffFrontRight,
|
||||
sensors?.cliffRight,
|
||||
].reduce((pattern, active, index) => pattern | (active ? 1 << index : 0), 0);
|
||||
}
|
||||
|
||||
function createRoverHelpMonitor({ now = () => Date.now(), onChange = () => {} } = {}) {
|
||||
const states = new Map();
|
||||
|
||||
function ensureState(roverId) {
|
||||
const id = String(roverId);
|
||||
if (!states.has(id)) {
|
||||
states.set(id, {
|
||||
wheelDropSince: null,
|
||||
cliffPattern: 0,
|
||||
cliffPatternSince: null,
|
||||
dockGuardSince: null,
|
||||
dockGuardSawPassive: false,
|
||||
reasons: new Set(),
|
||||
});
|
||||
}
|
||||
return states.get(id);
|
||||
}
|
||||
|
||||
function updateReason(roverId, state, reason, active) {
|
||||
const hadReason = state.reasons.has(reason);
|
||||
if (active === hadReason) return;
|
||||
if (active) state.reasons.add(reason);
|
||||
else state.reasons.delete(reason);
|
||||
|
||||
// Notify on every reason-set change so the integration can update the
|
||||
// aggregate flag correctly when one condition clears but another remains.
|
||||
onChange({
|
||||
roverId: String(roverId),
|
||||
needsHelp: state.reasons.size > 0,
|
||||
addedReason: active ? reason : null,
|
||||
removedReason: active ? null : reason,
|
||||
reasons: Array.from(state.reasons),
|
||||
});
|
||||
}
|
||||
|
||||
function handleSensor(roverId, sensors) {
|
||||
if (!roverId || !sensors) return;
|
||||
const state = ensureState(roverId);
|
||||
const timestamp = now();
|
||||
const docked = Boolean(sensors?.chargingSources?.homeBase);
|
||||
|
||||
if (docked) {
|
||||
// A 600-series Roomba can legitimately rest on the dock with wheel-drop
|
||||
// or cliff bits held by its physical position and the nearby surface.
|
||||
// Home-base contact is therefore a stronger signal than every monitored
|
||||
// fault here: reset all persistence history and do not let time spent
|
||||
// docked contribute toward a later HELP after it leaves the base.
|
||||
state.wheelDropSince = null;
|
||||
state.cliffPattern = 0;
|
||||
state.cliffPatternSince = null;
|
||||
state.dockGuardSince = null;
|
||||
state.dockGuardSawPassive = false;
|
||||
updateReason(roverId, state, 'wheelDrop', false);
|
||||
updateReason(roverId, state, 'cliff', false);
|
||||
updateReason(roverId, state, 'docking', false);
|
||||
return;
|
||||
}
|
||||
|
||||
const wheelDrop = Boolean(
|
||||
sensors?.bumpsAndWheelDrops?.wheelDropLeft || sensors?.bumpsAndWheelDrops?.wheelDropRight,
|
||||
);
|
||||
|
||||
if (wheelDrop) {
|
||||
if (state.wheelDropSince == null) state.wheelDropSince = timestamp;
|
||||
} else {
|
||||
state.wheelDropSince = null;
|
||||
}
|
||||
updateReason(
|
||||
roverId,
|
||||
state,
|
||||
'wheelDrop',
|
||||
state.wheelDropSince != null && timestamp - state.wheelDropSince >= WHEEL_DROP_HELP_MS,
|
||||
);
|
||||
|
||||
const nextCliffPattern = cliffPattern(sensors);
|
||||
if (!nextCliffPattern) {
|
||||
state.cliffPattern = 0;
|
||||
state.cliffPatternSince = null;
|
||||
} else if (nextCliffPattern !== state.cliffPattern) {
|
||||
// Any changed combination is new evidence on a non-mapping Roomba, not
|
||||
// proof that its chassis translated. Restart only the persistence timer;
|
||||
// encoder counts are deliberately not consulted anywhere in this monitor.
|
||||
state.cliffPattern = nextCliffPattern;
|
||||
state.cliffPatternSince = timestamp;
|
||||
}
|
||||
updateReason(
|
||||
roverId,
|
||||
state,
|
||||
'cliff',
|
||||
state.cliffPatternSince != null && timestamp - state.cliffPatternSince >= CLIFF_HELP_MS,
|
||||
);
|
||||
|
||||
if (state.dockGuardSince != null) {
|
||||
const oiMode = sensors?.oiMode?.label || null;
|
||||
if (oiMode === 'passive') state.dockGuardSawPassive = true;
|
||||
if (state.dockGuardSawPassive && oiMode && oiMode !== 'passive') {
|
||||
// Dock guard itself stops as soon as the 600-series wheels begin their
|
||||
// autonomous seek motion. Continue timing that seek after the guard
|
||||
// interval ends, and clear only on docking or a confirmed exit from the
|
||||
// passive OI mode used by opcode 143.
|
||||
state.dockGuardSince = null;
|
||||
state.dockGuardSawPassive = false;
|
||||
}
|
||||
}
|
||||
|
||||
updateReason(
|
||||
roverId,
|
||||
state,
|
||||
'docking',
|
||||
state.dockGuardSince != null && timestamp - state.dockGuardSince >= DOCK_GUARD_HELP_MS,
|
||||
);
|
||||
}
|
||||
|
||||
function handleDockGuard({ roverId, active, startedAt = null } = {}) {
|
||||
if (!roverId) return;
|
||||
const state = ensureState(roverId);
|
||||
if (active) {
|
||||
// Prefer roverManager's authoritative start time. The fallback keeps the
|
||||
// monitor deterministic if an event source omits it in a future caller.
|
||||
state.dockGuardSince = Number.isFinite(Number(startedAt)) ? Number(startedAt) : now();
|
||||
state.dockGuardSawPassive = false;
|
||||
return;
|
||||
}
|
||||
// Before passive mode is observed, a stopped guard means docking never
|
||||
// began. Once passive has been seen, wheel activity stops the guard even
|
||||
// though the Roomba is still autonomously seeking its dock, so sensor mode
|
||||
// and charging state become the authoritative completion signals instead.
|
||||
if (!state.dockGuardSawPassive) {
|
||||
state.dockGuardSince = null;
|
||||
updateReason(roverId, state, 'docking', false);
|
||||
}
|
||||
}
|
||||
|
||||
function removeRover(roverId) {
|
||||
states.delete(String(roverId));
|
||||
}
|
||||
|
||||
return {
|
||||
handleSensor,
|
||||
handleDockGuard,
|
||||
removeRover,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CLIFF_HELP_MS,
|
||||
DOCK_GUARD_HELP_MS,
|
||||
REASON_LABELS,
|
||||
WHEEL_DROP_HELP_MS,
|
||||
cliffPattern,
|
||||
createRoverHelpMonitor,
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
// Rover Help Monitor Tests
|
||||
// Purpose: Locks down sustained-condition timing without real timers or hardware.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
CLIFF_HELP_MS,
|
||||
DOCK_GUARD_HELP_MS,
|
||||
WHEEL_DROP_HELP_MS,
|
||||
createRoverHelpMonitor,
|
||||
} = require('./monitor');
|
||||
|
||||
function createHarness() {
|
||||
let timestamp = 1_000;
|
||||
const changes = [];
|
||||
const monitor = createRoverHelpMonitor({ now: () => timestamp, onChange: (change) => changes.push(change) });
|
||||
return {
|
||||
changes,
|
||||
monitor,
|
||||
advance(ms) {
|
||||
timestamp += ms;
|
||||
},
|
||||
now() {
|
||||
return timestamp;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('requires a continuous wheel drop and clears help when it releases', () => {
|
||||
const harness = createHarness();
|
||||
const dropped = { bumpsAndWheelDrops: { wheelDropLeft: true } };
|
||||
harness.monitor.handleSensor('red', dropped);
|
||||
harness.advance(WHEEL_DROP_HELP_MS - 1);
|
||||
harness.monitor.handleSensor('red', dropped);
|
||||
assert.equal(harness.changes.length, 0);
|
||||
|
||||
harness.advance(1);
|
||||
harness.monitor.handleSensor('red', dropped);
|
||||
assert.equal(harness.changes.at(-1).needsHelp, true);
|
||||
assert.equal(harness.changes.at(-1).addedReason, 'wheelDrop');
|
||||
|
||||
harness.monitor.handleSensor('red', { bumpsAndWheelDrops: {} });
|
||||
assert.equal(harness.changes.at(-1).needsHelp, false);
|
||||
});
|
||||
|
||||
test('a changed cliff combination restarts the ten minute timer', () => {
|
||||
const harness = createHarness();
|
||||
harness.monitor.handleSensor('blue', { cliffLeft: true });
|
||||
harness.advance(CLIFF_HELP_MS - 1);
|
||||
harness.monitor.handleSensor('blue', { cliffLeft: true, cliffFrontLeft: true });
|
||||
harness.advance(1);
|
||||
harness.monitor.handleSensor('blue', { cliffLeft: true, cliffFrontLeft: true });
|
||||
assert.equal(harness.changes.length, 0);
|
||||
|
||||
harness.advance(CLIFF_HELP_MS - 1);
|
||||
harness.monitor.handleSensor('blue', { cliffLeft: true, cliffFrontLeft: true });
|
||||
assert.equal(harness.changes.at(-1).addedReason, 'cliff');
|
||||
});
|
||||
|
||||
test('dock guard uses elapsed active time and clears when the guard stops', () => {
|
||||
const harness = createHarness();
|
||||
harness.monitor.handleDockGuard({ roverId: 'green', active: true, startedAt: harness.now() });
|
||||
harness.advance(DOCK_GUARD_HELP_MS);
|
||||
harness.monitor.handleSensor('green', {});
|
||||
assert.equal(harness.changes.at(-1).addedReason, 'docking');
|
||||
|
||||
harness.monitor.handleDockGuard({ roverId: 'green', active: false });
|
||||
assert.equal(harness.changes.at(-1).needsHelp, false);
|
||||
});
|
||||
|
||||
test('autonomous passive docking remains timed after wheel motion stops dock guard', () => {
|
||||
const harness = createHarness();
|
||||
harness.monitor.handleDockGuard({ roverId: 'yellow', active: true, startedAt: harness.now() });
|
||||
harness.monitor.handleSensor('yellow', { oiMode: { label: 'passive' }, chargingSources: {} });
|
||||
harness.monitor.handleDockGuard({ roverId: 'yellow', active: false });
|
||||
harness.advance(DOCK_GUARD_HELP_MS);
|
||||
harness.monitor.handleSensor('yellow', { oiMode: { label: 'passive' }, chargingSources: {} });
|
||||
assert.equal(harness.changes.at(-1).addedReason, 'docking');
|
||||
|
||||
harness.monitor.handleSensor('yellow', {
|
||||
oiMode: { label: 'passive' },
|
||||
chargingSources: { homeBase: true },
|
||||
});
|
||||
assert.equal(harness.changes.at(-1).needsHelp, false);
|
||||
});
|
||||
|
||||
test('clearing one reason retains help while another reason remains', () => {
|
||||
const harness = createHarness();
|
||||
const both = { bumpsAndWheelDrops: { wheelDropRight: true }, cliffRight: true };
|
||||
harness.monitor.handleSensor('orange', both);
|
||||
// Advance through the longer threshold so both independently sustained
|
||||
// conditions are active before exercising aggregate clearing behavior.
|
||||
harness.advance(WHEEL_DROP_HELP_MS);
|
||||
harness.monitor.handleSensor('orange', both);
|
||||
assert.deepEqual(harness.changes.at(-1).reasons.sort(), ['cliff', 'wheelDrop']);
|
||||
|
||||
harness.monitor.handleSensor('orange', { cliffRight: true });
|
||||
assert.equal(harness.changes.at(-1).needsHelp, true);
|
||||
assert.deepEqual(harness.changes.at(-1).reasons, ['cliff']);
|
||||
});
|
||||
|
||||
test('docked sensor conditions never accumulate help time', () => {
|
||||
const harness = createHarness();
|
||||
const dockedFaults = {
|
||||
chargingSources: { homeBase: true },
|
||||
bumpsAndWheelDrops: { wheelDropLeft: true },
|
||||
cliffLeft: true,
|
||||
};
|
||||
harness.monitor.handleSensor('purple', dockedFaults);
|
||||
harness.advance(WHEEL_DROP_HELP_MS + CLIFF_HELP_MS);
|
||||
harness.monitor.handleSensor('purple', dockedFaults);
|
||||
assert.equal(harness.changes.length, 0);
|
||||
|
||||
// Leaving the dock begins fresh timers instead of inheriting the long period
|
||||
// during which those same physical bits were harmlessly held at home base.
|
||||
harness.monitor.handleSensor('purple', {
|
||||
chargingSources: {},
|
||||
bumpsAndWheelDrops: { wheelDropLeft: true },
|
||||
cliffLeft: true,
|
||||
});
|
||||
assert.equal(harness.changes.length, 0);
|
||||
});
|
||||
|
||||
test('docking clears every active help reason', () => {
|
||||
const harness = createHarness();
|
||||
const faults = { bumpsAndWheelDrops: { wheelDropRight: true }, cliffRight: true };
|
||||
harness.monitor.handleSensor('silver', faults);
|
||||
harness.advance(WHEEL_DROP_HELP_MS);
|
||||
harness.monitor.handleSensor('silver', faults);
|
||||
assert.equal(harness.changes.at(-1).needsHelp, true);
|
||||
|
||||
harness.monitor.handleSensor('silver', {
|
||||
...faults,
|
||||
chargingSources: { homeBase: true },
|
||||
});
|
||||
assert.equal(harness.changes.at(-1).needsHelp, false);
|
||||
assert.deepEqual(harness.changes.at(-1).reasons, []);
|
||||
});
|
||||
@@ -133,6 +133,7 @@ const {
|
||||
getRoster,
|
||||
getRosterForSocket,
|
||||
broadcastRoster,
|
||||
setNeedsHelp,
|
||||
setToggleState,
|
||||
handleHostStats,
|
||||
canSeeRover,
|
||||
@@ -281,6 +282,7 @@ module.exports = {
|
||||
getRoster,
|
||||
getRosterForSocket,
|
||||
broadcastRoster,
|
||||
setNeedsHelp,
|
||||
setToggleState,
|
||||
handleHostStats,
|
||||
handleSensorFrame,
|
||||
|
||||
@@ -46,6 +46,10 @@ function createRosterLifecycle(deps) {
|
||||
room: `rover:${id}`,
|
||||
lastSeen: Date.now(),
|
||||
lastMovementAt: Date.now(),
|
||||
// Help state belongs to the live rover record so every roster consumer
|
||||
// sees one server-authoritative answer. The monitoring service owns why
|
||||
// the flag changes; roverManager only owns publishing the roster field.
|
||||
needsHelp: false,
|
||||
private: { enabled: false },
|
||||
privateOpen: true,
|
||||
privateSafety: { ...DEFAULT_PRIVATE_SAFETY },
|
||||
@@ -252,6 +256,7 @@ function createRosterLifecycle(deps) {
|
||||
: record.meta?.laser,
|
||||
locked: record.locked || (isPrivateRecord(record) && !isPrivateOpen(record)),
|
||||
lockReason: record.lockReason || (isPrivateRecord(record) && !isPrivateOpen(record) ? 'private' : null),
|
||||
needsHelp: Boolean(record.needsHelp),
|
||||
lastSeen: record.lastSeen,
|
||||
private: isPrivateRecord(record)
|
||||
? { enabled: true, open: isPrivateOpen(record), safety: getPrivateSafety(record) }
|
||||
@@ -299,6 +304,21 @@ function createRosterLifecycle(deps) {
|
||||
managerEvents.emit('rover', { roverId, action: device, record });
|
||||
}
|
||||
|
||||
function setNeedsHelp(roverId, needsHelp) {
|
||||
const record = rovers.get(String(roverId));
|
||||
if (!record) return false;
|
||||
const next = Boolean(needsHelp);
|
||||
if (record.needsHelp === next) return false;
|
||||
|
||||
// Emit both roster fanout forms used by the application. The lightweight
|
||||
// `rovers` event updates direct roster listeners immediately, while the
|
||||
// manager event asks sessionService to rebuild complete session snapshots.
|
||||
record.needsHelp = next;
|
||||
broadcastRoster();
|
||||
managerEvents.emit('help', { roverId: record.id, needsHelp: next });
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleHostStats(roverId, msg = {}) {
|
||||
const record = rovers.get(roverId);
|
||||
if (!record) return;
|
||||
@@ -345,6 +365,7 @@ function createRosterLifecycle(deps) {
|
||||
getRosterForSocket,
|
||||
syncSpectatorRooms,
|
||||
broadcastRoster,
|
||||
setNeedsHelp,
|
||||
setToggleState,
|
||||
handleHostStats,
|
||||
canSeeRover,
|
||||
|
||||
@@ -184,6 +184,11 @@ function createRoverLifecycle(deps) {
|
||||
const currentRecord = rovers.get(currentId);
|
||||
if (!currentRecord) return { ok: true, currentId };
|
||||
if (hasOtherDrivers(currentRecord, socket.id)) return { ok: true, currentId };
|
||||
// HELP means the normal dock-before-leaving requirement has failed to
|
||||
// resolve the rover's situation and a person may need to take a different
|
||||
// rover instead. This exception changes departure only; request eligibility
|
||||
// and automatic assignment ranking remain owned by their existing paths.
|
||||
if (currentRecord.needsHelp) return { ok: true, currentId };
|
||||
if (isDockedAndCharging(currentRecord)) return { ok: true, currentId };
|
||||
return { ok: false, currentId, message: 'Dock and charge your current rover before switching.' };
|
||||
}
|
||||
|
||||
@@ -57,3 +57,44 @@ test('removing a rover clears driver sets, reverse membership, rooms, and turns'
|
||||
{ socketId, roverId, action: 'remove' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('the last driver may leave an undocked HELP rover but not an ordinary undocked rover', () => {
|
||||
const roverId = 'rover-help';
|
||||
const socketId = 'driver-help';
|
||||
const socket = { id: socketId };
|
||||
const record = {
|
||||
id: roverId,
|
||||
drivers: new Set([socketId]),
|
||||
needsHelp: false,
|
||||
// A present but explicitly non-charging frame exercises the real policy
|
||||
// boundary instead of accidentally passing through missing rover state.
|
||||
lastSensor: {
|
||||
decoded: {
|
||||
chargingSources: { homeBase: false },
|
||||
chargingState: { code: 0 },
|
||||
},
|
||||
},
|
||||
};
|
||||
const lifecycle = createRoverLifecycle({
|
||||
io: { sockets: { sockets: new Map() } },
|
||||
rovers: new Map([[roverId, record]]),
|
||||
socketToRovers: new Map([[socketId, new Set([roverId])]]),
|
||||
managerEvents: new EventEmitter(),
|
||||
turnService: {},
|
||||
isAdmin: () => false,
|
||||
sendAlert: () => {},
|
||||
ALERT_COLOR: '#000000',
|
||||
getMode: () => 'public',
|
||||
getControlDenialReason: () => null,
|
||||
});
|
||||
|
||||
const ordinaryResult = lifecycle.canLeaveCurrentRover(socket);
|
||||
assert.equal(ordinaryResult.ok, false);
|
||||
assert.equal(ordinaryResult.message, 'Dock and charge your current rover before switching.');
|
||||
|
||||
// Mutating only the server-owned HELP flag proves that no docking, driver,
|
||||
// role, or assignment condition is being weakened as part of the exception.
|
||||
record.needsHelp = true;
|
||||
const helpResult = lifecycle.canLeaveCurrentRover(socket);
|
||||
assert.deepEqual(helpResult, { ok: true, currentId: roverId });
|
||||
});
|
||||
|
||||
@@ -329,6 +329,7 @@ function createSensorPipeline(deps) {
|
||||
function stopDockGuard(roverId) {
|
||||
const state = dockGuardStates.get(roverId);
|
||||
if (!state) return;
|
||||
const wasActive = state.active;
|
||||
if (state.timer) clearInterval(state.timer);
|
||||
state.active = false;
|
||||
state.reason = null;
|
||||
@@ -336,6 +337,11 @@ function createSensorPipeline(deps) {
|
||||
state.timer = null;
|
||||
state.idleUndockedSince = null;
|
||||
state.passiveUndockedSince = null;
|
||||
// The help monitor must clear its docking timer at the same ownership
|
||||
// boundary that stops dock guard. Inferring this from OI mode would be
|
||||
// incorrect because a 600-series rover can enter passive mode for reasons
|
||||
// other than the server's automatic docking workflow.
|
||||
if (wasActive) managerEvents.emit('dockGuard', { roverId, active: false });
|
||||
}
|
||||
|
||||
function attemptDockGuard(roverId) {
|
||||
@@ -360,6 +366,12 @@ function createSensorPipeline(deps) {
|
||||
state.active = true;
|
||||
state.reason = reason;
|
||||
state.startedAt = Date.now();
|
||||
managerEvents.emit('dockGuard', {
|
||||
roverId: record.id,
|
||||
active: true,
|
||||
reason,
|
||||
startedAt: state.startedAt,
|
||||
});
|
||||
const reasonText = reason === 'passive' ? 'passive mode' : 'idle and undocked';
|
||||
sendAlert({
|
||||
color: ALERT_COLOR,
|
||||
|
||||
@@ -355,6 +355,13 @@ managerEvents.on('privateSafety', ({ roverId }) => {
|
||||
syncAll();
|
||||
});
|
||||
|
||||
managerEvents.on('help', ({ roverId, needsHelp }) => {
|
||||
// HELP is roster state used by several passive UI routes, so every connected
|
||||
// socket must receive the transition rather than only the rover's drivers.
|
||||
logger.info('Rover help state changed', roverId, needsHelp);
|
||||
syncAll();
|
||||
});
|
||||
|
||||
privateRoverAccessRequestEvents.on('change', (event = {}) => {
|
||||
logger.info('Private rover access request state changed', event.reason || 'unknown');
|
||||
syncAll();
|
||||
|
||||
@@ -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]: ^
|
||||
|
||||
Generated
+290
-11
@@ -8,6 +8,7 @@
|
||||
"name": "webui",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@lizardbyte/gamepad-helper": "^2026.816.4539",
|
||||
"@thumbmarkjs/thumbmarkjs": "^1.10.0",
|
||||
"midi-file": "^1.2.4",
|
||||
"papaparse": "^5.5.4",
|
||||
@@ -34,7 +35,8 @@
|
||||
"postcss": "^8.5.6",
|
||||
"socket.io-client": "4.8.3",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"vite": "^7.2.2"
|
||||
"vite": "^7.2.2",
|
||||
"vitest": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
@@ -1034,9 +1036,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
||||
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1051,6 +1053,15 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@lizardbyte/gamepad-helper": {
|
||||
"version": "2026.816.4539",
|
||||
"resolved": "https://registry.npmjs.org/@lizardbyte/gamepad-helper/-/gamepad-helper-2026.816.4539.tgz",
|
||||
"integrity": "sha512-Tncd9+MEUOU9JpDnJIeFEDlUure8CGItLHbcr08i14uylUtx20oqWnTR+ZGmVITeGjdqW9+a43FSMJUiK0c4LA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://app.lizardbyte.dev"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -1483,6 +1494,24 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -1550,6 +1579,44 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz",
|
||||
"integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.31",
|
||||
"@vitest/spy": "5.0.0",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^1.2.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz",
|
||||
"integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
@@ -1667,6 +1734,16 @@
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.22",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz",
|
||||
@@ -1834,6 +1911,16 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -2061,6 +2148,13 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
|
||||
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
@@ -2300,6 +2394,16 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esutils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||
@@ -2310,6 +2414,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -2915,6 +3029,16 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.3.1.tgz",
|
||||
"integrity": "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@@ -3073,6 +3197,20 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
|
||||
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -3208,9 +3346,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3727,6 +3865,13 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
@@ -3780,6 +3925,20 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
|
||||
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
@@ -4013,15 +4172,35 @@
|
||||
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
|
||||
"integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
|
||||
"integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3"
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
@@ -4201,6 +4380,89 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz",
|
||||
"integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/mocker": "5.0.0",
|
||||
"chai": "^6.2.2",
|
||||
"es-module-lexer": "^2.3.2",
|
||||
"expect-type": "^1.4.0",
|
||||
"magic-string": "^1.2.3",
|
||||
"obug": "^2.1.4",
|
||||
"picomatch": "^4.0.7",
|
||||
"std-env": "^4.2.0",
|
||||
"tinybench": "6.1.4",
|
||||
"tinyexec": "1.3.0",
|
||||
"tinyglobby": "^0.2.17",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "5.0.0",
|
||||
"@vitest/browser-preview": "5.0.0",
|
||||
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
|
||||
"@vitest/coverage-istanbul": "5.0.0",
|
||||
"@vitest/coverage-v8": "5.0.0",
|
||||
"@vitest/ui": "5.0.0",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/web-haptics": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/web-haptics/-/web-haptics-0.0.6.tgz",
|
||||
@@ -4243,6 +4505,23 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
||||
+4
-1
@@ -7,9 +7,11 @@
|
||||
"dev": "VITE_ROVERD_URL=https://rover.otter.land vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"test": "node --test src/controls/inputs/*.test.js src/components/GamepadMappingSettings/*.test.js",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lizardbyte/gamepad-helper": "^2026.816.4539",
|
||||
"@thumbmarkjs/thumbmarkjs": "^1.10.0",
|
||||
"midi-file": "^1.2.4",
|
||||
"papaparse": "^5.5.4",
|
||||
@@ -36,6 +38,7 @@
|
||||
"postcss": "^8.5.6",
|
||||
"socket.io-client": "4.8.3",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"vite": "^7.2.2"
|
||||
"vite": "^7.2.2",
|
||||
"vitest": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Auto Fit Text
|
||||
// Purpose: Sizes one unwrapped label to the largest font that fits its container.
|
||||
// Scope: Supports the existing width-only labels and full-box overlays from one shared implementation.
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
export default function AutoFitText({
|
||||
children,
|
||||
className = '',
|
||||
containerClassName = '',
|
||||
maxSize = 1000,
|
||||
minSize = 14,
|
||||
fitHeight = false,
|
||||
style = undefined,
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const textRef = useRef(null);
|
||||
const [fontSize, setFontSize] = useState(maxSize);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const textEl = textRef.current;
|
||||
if (!container || !textEl) return undefined;
|
||||
|
||||
let animationFrame = null;
|
||||
const fit = () => {
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
if (!width || (fitHeight && !height)) {
|
||||
scheduleFit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Binary search avoids stepping through hundreds of possible font sizes.
|
||||
// Overlay labels test both axes; ordinary rover labels preserve their
|
||||
// previous width-only behavior so this shared move changes no layout.
|
||||
let low = minSize;
|
||||
let high = maxSize;
|
||||
let best = minSize;
|
||||
while (low <= high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
textEl.style.fontSize = `${middle}px`;
|
||||
const fitsWidth = textEl.scrollWidth <= width;
|
||||
const fitsHeight = !fitHeight || textEl.scrollHeight <= height;
|
||||
if (fitsWidth && fitsHeight) {
|
||||
best = middle;
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle - 1;
|
||||
}
|
||||
}
|
||||
setFontSize(best);
|
||||
};
|
||||
|
||||
const scheduleFit = () => {
|
||||
if (animationFrame) cancelAnimationFrame(animationFrame);
|
||||
animationFrame = requestAnimationFrame(fit);
|
||||
};
|
||||
|
||||
scheduleFit();
|
||||
const observer = new ResizeObserver(scheduleFit);
|
||||
observer.observe(container);
|
||||
return () => {
|
||||
if (animationFrame) cancelAnimationFrame(animationFrame);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [children, fitHeight, maxSize, minSize]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`${fitHeight ? 'flex h-full items-center justify-center' : ''} w-full min-w-0 ${containerClassName}`}
|
||||
>
|
||||
<div
|
||||
ref={textRef}
|
||||
className={`whitespace-nowrap ${className}`}
|
||||
style={{ fontSize: `${fontSize}px`, lineHeight: fitHeight ? 1 : 1.1, ...(style || {}) }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -68,8 +68,8 @@ export default function CardFrame({
|
||||
? { borderColor: '#008a35' }
|
||||
: accentRgb
|
||||
? {
|
||||
backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 0%, ${rgba(accentRgb, 0.1)} 100%)`,
|
||||
// backgroundImage: `linear-gradient(90deg, ${rgba(accentRgb, 0.1)} 100%)`,
|
||||
// backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 0%, ${rgba(accentRgb, 0.1)} 100%)`,
|
||||
backgroundImage: `linear-gradient(90deg, ${rgba(accentRgb, 0.2)} 100%)`,
|
||||
// backgroundImage: `background-color: ${rgba(accentRgb, 0.2)}`
|
||||
}
|
||||
: undefined;
|
||||
@@ -100,7 +100,7 @@ export default function CardFrame({
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-0.5">
|
||||
{title ? (
|
||||
<p className={cx('m-0 text-[0.78rem] font-semibold leading-none', greenMode ? 'text-lime-400' : 'text-neutral-50')}>
|
||||
<p className={cx('m-0 font-semibold leading-none', greenMode ? 'text-lime-400' : 'text-neutral-50')}>
|
||||
{title}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Adaptive Control Hint
|
||||
// Purpose: Renders the binding for a logical action using the user's most recently used input type.
|
||||
// Scope: Keeps the render component separate from its hook so React fast refresh can safely
|
||||
// replace this module without treating a non-component export as component state.
|
||||
import { useControlHintLabel } from './useControlHintLabel.js';
|
||||
|
||||
export default function ControlHint({ actionId }) {
|
||||
return <>{useControlHintLabel(actionId)}</>;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Adaptive Control Hint Label Hook
|
||||
// Purpose: Resolves a logical action to the keyboard or controller label appropriate for the
|
||||
// operator's most recently used input device.
|
||||
// Scope: Reads control/settings state only; it never captures input or dispatches rover commands.
|
||||
import { useControlSelector } from '../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import { useControllerRuntime } from '../../controls/inputs/controllerRuntime.js';
|
||||
import { formatControllerBinding } from '../../controls/inputs/controllerLabels.js';
|
||||
import { resolveGamepadProfile } from '../../controls/inputs/gamepadBindings.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { GAMEPAD_PROFILE_DEFAULT, GAMEPAD_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
|
||||
export function useControlHintLabel(actionId) {
|
||||
const keyValue = useControlSelector((control) => control.state.keymap?.[actionId]?.[0]);
|
||||
const runtime = useControllerRuntime();
|
||||
const { value: gamepadSettings } = useSettingsNamespace('gamepad', GAMEPAD_SETTINGS_DEFAULTS);
|
||||
|
||||
if (runtime.inputMethod !== 'controller' || !runtime.controller) {
|
||||
return formatKeyLabel(keyValue);
|
||||
}
|
||||
|
||||
/* Profiles remain keyed by reusable hardware signature, while runtime controller selection is
|
||||
instance-specific. This lets two identical connected pads share a mapping without losing the
|
||||
browser slot used to decide which one currently owns control. */
|
||||
const storedProfile =
|
||||
gamepadSettings?.profiles?.[runtime.controller.signature] ??
|
||||
gamepadSettings?.defaults?.profile ??
|
||||
GAMEPAD_PROFILE_DEFAULT;
|
||||
const profile = resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT);
|
||||
return formatControllerBinding(profile, actionId, runtime.controller);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import '../MobileControls/mobileControls.css';
|
||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import ControlHint from '../ControlHint/index.jsx';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import { deriveDriveDockStateFromTelemetry } from './driveDockState.js';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
@@ -90,7 +90,6 @@ export default function DriveDockAction({
|
||||
}) {
|
||||
const isMobile = layout === 'mobile';
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const actions = useControlActions();
|
||||
const dockAssist = useManualDockAssist();
|
||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||
@@ -104,8 +103,8 @@ export default function DriveDockAction({
|
||||
const driveDisabled = !roverId || pending !== null;
|
||||
const dockDisabled = !roverId || pending !== null;
|
||||
|
||||
const driveKeyLabel = formatKeyLabel(keymap?.driveMacro?.[0]);
|
||||
const dockKeyLabel = formatKeyLabel(keymap?.dockMacro?.[0]);
|
||||
const driveKeyLabel = <ControlHint actionId="driveMacro" />;
|
||||
const dockKeyLabel = <ControlHint actionId="dockMacro" />;
|
||||
|
||||
const dockInstructions = {
|
||||
summary: 'Use assist mode to manually line up with the dock.',
|
||||
|
||||
@@ -7,13 +7,15 @@ import { GAMEPAD_PROFILE_DEFAULT, GAMEPAD_SETTINGS_DEFAULTS } from '../../settin
|
||||
import {
|
||||
computeGamepadOutputs,
|
||||
createProfileForPad,
|
||||
resolveGamepadProfile,
|
||||
} from '../../controls/inputs/gamepadBindings.js';
|
||||
import { useGamepadHubState } from '../../controls/inputs/gamepadHub.js';
|
||||
import { acquireControllerControlLock } from '../../controls/inputs/controllerRuntime.js';
|
||||
import { describeController, formatControllerBinding } from '../../controls/inputs/controllerLabels.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import SliderField from './SliderField.jsx';
|
||||
import { ACTIONS, NUMBER_FORMAT } from './constants.js';
|
||||
import {
|
||||
formatSource,
|
||||
groupActions,
|
||||
pickActivePad,
|
||||
snapshotBaseline,
|
||||
@@ -26,21 +28,63 @@ 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">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 text-sm text-white">
|
||||
<span className="font-semibold">{label}</span>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
|
||||
>
|
||||
<option value="linear">Linear</option>
|
||||
<option value="expo">Fine center control</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function MappingRow({
|
||||
action,
|
||||
source,
|
||||
sourceLabel,
|
||||
liveValue,
|
||||
isCapturing,
|
||||
onClear,
|
||||
onCapture,
|
||||
onInvert,
|
||||
disabled,
|
||||
}) {
|
||||
// Mapping rows are constrained to a readable width so the source text and buttons remain
|
||||
// visually connected. Buttons wrap on very narrow panes instead of forcing tiny text.
|
||||
return (
|
||||
<div className="mx-auto grid w-full max-w-lg grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 rounded bg-neutral-800/80 px-1.5 py-1 text-sm max-[520px]:grid-cols-1">
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold leading-snug text-white">{action.label}</p>
|
||||
<p className="mt-0.5 text-xs leading-snug text-white">{formatSource(source)}</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="font-semibold leading-snug text-white">{action.label}</p>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${liveValue ? 'bg-emerald-400' : 'bg-neutral-600'}`} aria-hidden="true" />
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs leading-snug text-white">{sourceLabel}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1 max-[520px]:justify-start">
|
||||
{/* Axis-pair controls expose independent inversion because stick X/Y directions often
|
||||
@@ -50,7 +94,7 @@ function MappingRow({
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!source}
|
||||
disabled={disabled || !source}
|
||||
onClick={() => onInvert(action, 'invertX')}
|
||||
className="button-dark px-1 py-0.5 text-xs font-medium disabled:opacity-50"
|
||||
>
|
||||
@@ -58,7 +102,7 @@ function MappingRow({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!source}
|
||||
disabled={disabled || !source}
|
||||
onClick={() => onInvert(action, 'invertY')}
|
||||
className="button-dark px-1 py-0.5 text-xs font-medium disabled:opacity-50"
|
||||
>
|
||||
@@ -71,7 +115,7 @@ function MappingRow({
|
||||
{action.kind === 'axis' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!source}
|
||||
disabled={disabled || !source}
|
||||
onClick={() => onInvert(action)}
|
||||
className="button-dark px-1 py-0.5 text-xs font-medium disabled:opacity-50"
|
||||
>
|
||||
@@ -80,17 +124,18 @@ function MappingRow({
|
||||
)}
|
||||
{/* Clear and Capture are always present because they are the primary row actions. They
|
||||
wrap with the inversion controls on narrow panes instead of shrinking text. */}
|
||||
<button type="button" onClick={() => onClear(action)} className="button-dark px-1 py-0.5 text-xs">
|
||||
<button type="button" disabled={disabled} onClick={() => onClear(action)} className="button-dark px-1 py-0.5 text-xs disabled:opacity-50">
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onCapture(action)}
|
||||
className={`${
|
||||
isCapturing
|
||||
? 'rounded-md bg-emerald-500 px-1 py-0.5 text-emerald-950 hover:bg-emerald-400'
|
||||
: 'button-dark px-1 py-0.5'
|
||||
} text-xs font-medium`}
|
||||
} text-xs font-medium disabled:opacity-50`}
|
||||
>
|
||||
{isCapturing ? 'Waiting...' : 'Capture'}
|
||||
</button>
|
||||
@@ -106,25 +151,48 @@ export default function GamepadMappingSettings() {
|
||||
GAMEPAD_SETTINGS_DEFAULTS,
|
||||
);
|
||||
const [captureAction, setCaptureAction] = useState(null);
|
||||
const [actionFilter, setActionFilter] = useState('');
|
||||
const baselineRef = useRef(null);
|
||||
const grouped = useMemo(() => groupActions(ACTIONS), []);
|
||||
const captureCandidateRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
/* 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.activeSignature),
|
||||
[hubState.pads, gamepadSettings.activeSignature],
|
||||
() => pickActivePad(hubState.pads, gamepadSettings.activeInstanceKey),
|
||||
[hubState.pads, gamepadSettings.activeInstanceKey],
|
||||
);
|
||||
|
||||
const activeSignature = activePad?.signature ?? null;
|
||||
const activeProfile = useMemo(() => {
|
||||
if (!activeSignature) {
|
||||
return gamepadSettings?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT;
|
||||
}
|
||||
return (
|
||||
const storedProfile = !activeSignature
|
||||
? gamepadSettings?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT
|
||||
: (
|
||||
gamepadSettings?.profiles?.[activeSignature] ??
|
||||
gamepadSettings?.defaults?.profile ??
|
||||
GAMEPAD_PROFILE_DEFAULT
|
||||
);
|
||||
);
|
||||
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;
|
||||
@@ -132,7 +200,7 @@ export default function GamepadMappingSettings() {
|
||||
saveGamepadSettings((prev) => {
|
||||
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
|
||||
if (current.profiles?.[activeSignature]) return current;
|
||||
const base = current?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT;
|
||||
const base = resolveGamepadProfile(current?.defaults?.profile, GAMEPAD_PROFILE_DEFAULT);
|
||||
const nextProfile = createProfileForPad(activePad, base);
|
||||
return {
|
||||
...current,
|
||||
@@ -146,6 +214,7 @@ export default function GamepadMappingSettings() {
|
||||
|
||||
useEffect(() => {
|
||||
baselineRef.current = null;
|
||||
captureCandidateRef.current = null;
|
||||
}, [captureAction, activeSignature]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -154,16 +223,49 @@ export default function GamepadMappingSettings() {
|
||||
baselineRef.current = snapshotBaseline(activePad);
|
||||
return;
|
||||
}
|
||||
const descriptor = buildDescriptorFromCapture(activePad, baselineRef.current, captureAction);
|
||||
let descriptor = buildDescriptorFromCapture(activePad, baselineRef.current, captureAction);
|
||||
if (captureAction.kind === 'button' && descriptor?.kind !== 'chord') {
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
if (descriptor && !captureCandidateRef.current) {
|
||||
/* Give the user a short window to add a modifier after the first button. Immediate capture
|
||||
makes chords physically impossible because browser frames never report both presses at
|
||||
precisely the same instant. */
|
||||
captureCandidateRef.current = { descriptor, startedAt: now };
|
||||
return;
|
||||
}
|
||||
if (!captureCandidateRef.current) return;
|
||||
if (now - captureCandidateRef.current.startedAt < 220) return;
|
||||
/* A quick tap may already be released when the chord window expires. Preserve the original
|
||||
candidate so capture completes normally instead of waiting for an unrelated later press. */
|
||||
descriptor = captureCandidateRef.current.descriptor;
|
||||
}
|
||||
if (!descriptor) return;
|
||||
saveGamepadSettings((prev) => {
|
||||
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
|
||||
const baseProfile =
|
||||
current.profiles?.[activeSignature] ?? current?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT;
|
||||
const baseProfile = resolveGamepadProfile(
|
||||
current.profiles?.[activeSignature] ?? current?.defaults?.profile,
|
||||
GAMEPAD_PROFILE_DEFAULT,
|
||||
);
|
||||
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) => !sourcesUseSamePhysicalInput(source, descriptor),
|
||||
);
|
||||
return [actionId, { ...binding, sources }];
|
||||
}),
|
||||
);
|
||||
const nextProfile = {
|
||||
...baseProfile,
|
||||
bindings: {
|
||||
...(baseProfile.bindings ?? {}),
|
||||
/* A physical input has one owner by default. Removing an exact duplicate avoids two
|
||||
toggles firing from one press while still allowing deliberate multi-button chords. */
|
||||
...bindingsWithoutConflict,
|
||||
[captureAction.id]: {
|
||||
...(baseProfile.bindings?.[captureAction.id] ?? {}),
|
||||
kind: captureAction.kind,
|
||||
@@ -179,14 +281,20 @@ export default function GamepadMappingSettings() {
|
||||
},
|
||||
};
|
||||
});
|
||||
setCaptureAction(null);
|
||||
/* Hub snapshots drive this effect, but capture state is React-owned UI state. Deferring its
|
||||
reset to a microtask avoids a synchronous state cascade inside the effect while the id
|
||||
guard prevents an older completion from cancelling a newer capture request. */
|
||||
const completedActionId = captureAction.id;
|
||||
queueMicrotask(() => {
|
||||
setCaptureAction((current) => current?.id === completedActionId ? null : current);
|
||||
});
|
||||
}, [activePad, activeSignature, captureAction, saveGamepadSettings]);
|
||||
|
||||
const setActiveSignature = useCallback(
|
||||
(signature) => {
|
||||
const setActiveInstanceKey = useCallback(
|
||||
(instanceKey) => {
|
||||
saveGamepadSettings((prev) => ({
|
||||
...(prev ?? GAMEPAD_SETTINGS_DEFAULTS),
|
||||
activeSignature: signature || null,
|
||||
activeInstanceKey: instanceKey || null,
|
||||
}));
|
||||
},
|
||||
[saveGamepadSettings],
|
||||
@@ -196,10 +304,10 @@ export default function GamepadMappingSettings() {
|
||||
(patch) => {
|
||||
saveGamepadSettings((prev) => {
|
||||
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
|
||||
const baseProfile =
|
||||
const storedProfile =
|
||||
(activeSignature && current.profiles?.[activeSignature]) ??
|
||||
current?.defaults?.profile ??
|
||||
GAMEPAD_PROFILE_DEFAULT;
|
||||
current?.defaults?.profile;
|
||||
const baseProfile = resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT);
|
||||
const nextProfile = {
|
||||
...baseProfile,
|
||||
calibration: {
|
||||
@@ -228,14 +336,48 @@ export default function GamepadMappingSettings() {
|
||||
[activeSignature, saveGamepadSettings],
|
||||
);
|
||||
|
||||
const updateProfile = useCallback(
|
||||
(patch) => {
|
||||
saveGamepadSettings((prev) => {
|
||||
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
|
||||
const storedProfile =
|
||||
(activeSignature && current.profiles?.[activeSignature]) ??
|
||||
current?.defaults?.profile;
|
||||
const nextProfile = {
|
||||
...resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT),
|
||||
...patch,
|
||||
};
|
||||
if (!activeSignature) {
|
||||
return {
|
||||
...current,
|
||||
defaults: { ...(current.defaults ?? {}), profile: nextProfile },
|
||||
};
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
profiles: { ...(current.profiles ?? {}), [activeSignature]: nextProfile },
|
||||
};
|
||||
});
|
||||
},
|
||||
[activeSignature, saveGamepadSettings],
|
||||
);
|
||||
|
||||
const resetActiveProfile = useCallback(() => {
|
||||
/* Resetting only the selected hardware avoids erasing carefully tuned profiles for other
|
||||
controllers. Device metadata is rebuilt so the profile remains recognizable offline. */
|
||||
const nextProfile = createProfileForPad(activePad, GAMEPAD_PROFILE_DEFAULT);
|
||||
updateProfile(nextProfile);
|
||||
setCaptureAction(null);
|
||||
}, [activePad, updateProfile]);
|
||||
|
||||
const updateBinding = useCallback(
|
||||
(actionId, updater) => {
|
||||
saveGamepadSettings((prev) => {
|
||||
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
|
||||
const baseProfile =
|
||||
const storedProfile =
|
||||
(activeSignature && current.profiles?.[activeSignature]) ??
|
||||
current?.defaults?.profile ??
|
||||
GAMEPAD_PROFILE_DEFAULT;
|
||||
current?.defaults?.profile;
|
||||
const baseProfile = resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT);
|
||||
const nextBinding = updater(baseProfile.bindings?.[actionId] ?? {});
|
||||
const nextProfile = {
|
||||
...baseProfile,
|
||||
@@ -300,39 +442,74 @@ export default function GamepadMappingSettings() {
|
||||
const outputs = computeGamepadOutputs(activePad, activeProfile);
|
||||
return { outputs };
|
||||
}, [activePad, activeProfile]);
|
||||
const controllerDescription = useMemo(() => describeController(activePad), [activePad]);
|
||||
|
||||
const liveValueForAction = useCallback((actionId) => {
|
||||
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;
|
||||
return Boolean(outputs.buttons[actionId]);
|
||||
}, [diagnostics]);
|
||||
|
||||
return (
|
||||
<CardFrame
|
||||
title="Controller"
|
||||
meta={activePad ? 'Move sticks or press buttons to bind' : 'Connect a controller to configure.'}
|
||||
actions={
|
||||
<button type="button" disabled={!activePad} onClick={resetActiveProfile} className="button-dark px-1 py-0.5 text-xs disabled:opacity-50">
|
||||
Reset profile
|
||||
</button>
|
||||
}
|
||||
bodyClassName="space-y-2 p-1 text-sm"
|
||||
>
|
||||
{captureAction && (
|
||||
<p className="mx-auto w-full max-w-lg rounded bg-emerald-950/50 px-1.5 py-1 text-sm text-white">
|
||||
Capturing {captureAction.label}...
|
||||
</p>
|
||||
<div className="mx-auto flex w-full max-w-lg items-center justify-between gap-1 rounded bg-emerald-950/50 px-1.5 py-1 text-sm text-white">
|
||||
<span>Release controls, then move or press the input for {captureAction.label}.</span>
|
||||
<button type="button" onClick={() => setCaptureAction(null)} className="button-dark px-1 py-0.5 text-xs">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<SettingsGroupLabel>Connected controller</SettingsGroupLabel>
|
||||
{hubState.pads.length === 0 ? (
|
||||
<p className="mx-auto w-full max-w-lg text-sm text-white">No controller detected.</p>
|
||||
{hubState.error ? (
|
||||
<p className="mx-auto w-full max-w-lg rounded border border-red-500/60 bg-red-950/40 px-1.5 py-1 text-sm text-white">
|
||||
Controller access failed: {hubState.error}
|
||||
</p>
|
||||
) : hubState.pads.length === 0 ? (
|
||||
<p className="mx-auto w-full max-w-lg text-sm text-white">
|
||||
{hubState.supported === false
|
||||
? 'This browser does not support controllers.'
|
||||
: 'No controller detected. Connect it, focus this page, then press a button.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="mx-auto grid w-full max-w-lg grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 rounded bg-neutral-800/80 px-1.5 py-1 text-sm max-[420px]:grid-cols-1">
|
||||
<select
|
||||
value={activeSignature ?? ''}
|
||||
onChange={(event) => setActiveSignature(event.target.value)}
|
||||
value={activePad?.instanceKey ?? ''}
|
||||
onChange={(event) => setActiveInstanceKey(event.target.value)}
|
||||
className="min-w-0 rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
|
||||
>
|
||||
{hubState.pads.map((pad) => (
|
||||
<option key={pad.signature} value={pad.signature}>
|
||||
{pad.id || 'Unknown controller'}
|
||||
<option key={pad.instanceKey} value={pad.instanceKey}>
|
||||
{pad.id || 'Unknown controller'} (slot {pad.index + 1})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="rounded bg-neutral-900 px-1 py-0.5 text-xs text-white">
|
||||
{activePad?.mapping ?? 'unknown'}
|
||||
</span>
|
||||
<p className="col-span-full truncate text-xs text-slate-300" title={activePad?.id}>
|
||||
{controllerDescription.description ?? activePad?.id}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -342,6 +519,42 @@ export default function GamepadMappingSettings() {
|
||||
{/* Calibration controls stay in one stacked column because range inputs become harder to
|
||||
tune when squeezed into multiple narrow columns. */}
|
||||
<div className="grid gap-1">
|
||||
<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-center gap-1.5 text-sm text-white">
|
||||
<span className="font-semibold">Button prompts</span>
|
||||
<select
|
||||
value={activeProfile.promptStyle ?? 'auto'}
|
||||
onChange={(event) => updateProfile({ promptStyle: event.target.value })}
|
||||
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
|
||||
>
|
||||
<option value="auto">Automatic</option>
|
||||
<option value="xbox">Xbox</option>
|
||||
<option value="playstation">PlayStation</option>
|
||||
<option value="switch">Nintendo</option>
|
||||
<option value="standard">Generic</option>
|
||||
</select>
|
||||
</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"
|
||||
@@ -351,15 +564,50 @@ export default function GamepadMappingSettings() {
|
||||
value={activeProfile.calibration?.driveDeadzone ?? 0.18}
|
||||
onChange={(value) => updateCalibration({ driveDeadzone: 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="Drive response"
|
||||
value={activeProfile.calibration?.driveCurve ?? 'linear'}
|
||||
onChange={(value) => updateCalibration({ driveCurve: value })}
|
||||
/>
|
||||
<SliderField
|
||||
label="Full-stick speed"
|
||||
description="Maximum wheel output at full stick"
|
||||
min={50}
|
||||
max={500}
|
||||
step={10}
|
||||
value={activeProfile.calibration?.baseSpeed ?? 500}
|
||||
onChange={(value) => updateCalibration({ baseSpeed: value })}
|
||||
/>
|
||||
<SliderField
|
||||
label="Turbo drive speed"
|
||||
description="Maximum output while holding the turbo modifier"
|
||||
min={50}
|
||||
max={500}
|
||||
step={10}
|
||||
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="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"
|
||||
@@ -369,6 +617,11 @@ export default function GamepadMappingSettings() {
|
||||
value={activeProfile.calibration?.auxDeadzone ?? 0.05}
|
||||
onChange={(value) => updateCalibration({ auxDeadzone: value })}
|
||||
/>
|
||||
<CurveField
|
||||
label="Brush response"
|
||||
value={activeProfile.calibration?.auxCurve ?? 'linear'}
|
||||
onChange={(value) => updateCalibration({ auxCurve: value })}
|
||||
/>
|
||||
<SliderField
|
||||
label="Side brush scale"
|
||||
description="Scale side brush output"
|
||||
@@ -378,22 +631,33 @@ export default function GamepadMappingSettings() {
|
||||
value={activeProfile.calibration?.auxSideScale ?? 0.55}
|
||||
onChange={(value) => updateCalibration({ auxSideScale: 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 ?? 'absolute'}
|
||||
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="Precision speed"
|
||||
description="Maximum drive speed while holding the precision modifier"
|
||||
min={20}
|
||||
max={250}
|
||||
step={5}
|
||||
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. */}
|
||||
<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"
|
||||
@@ -407,6 +671,16 @@ export default function GamepadMappingSettings() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="mx-auto block w-full max-w-lg">
|
||||
<span className="sr-only">Filter controller actions</span>
|
||||
<input
|
||||
type="search"
|
||||
value={actionFilter}
|
||||
onChange={(event) => setActionFilter(event.target.value)}
|
||||
placeholder="Find a controller action"
|
||||
className="field-input w-full px-1.5 py-1 text-sm"
|
||||
/>
|
||||
</label>
|
||||
{Object.entries(grouped).map(([section, actions]) => (
|
||||
<div key={section} className="space-y-1">
|
||||
<SettingsGroupLabel>{section}</SettingsGroupLabel>
|
||||
@@ -421,10 +695,13 @@ export default function GamepadMappingSettings() {
|
||||
key={action.id}
|
||||
action={action}
|
||||
source={source}
|
||||
sourceLabel={formatControllerBinding(activeProfile, action.id, activePad)}
|
||||
liveValue={liveValueForAction(action.id)}
|
||||
isCapturing={captureAction?.id === action.id}
|
||||
onClear={handleClear}
|
||||
onCapture={setCaptureAction}
|
||||
onInvert={handleInvert}
|
||||
disabled={!activePad}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -434,11 +711,12 @@ export default function GamepadMappingSettings() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<SettingsGroupLabel>Diagnostics</SettingsGroupLabel>
|
||||
{!activePad ? (
|
||||
<p className="mx-auto w-full max-w-lg text-sm text-white">No controller detected.</p>
|
||||
null
|
||||
) : (
|
||||
<div className="mx-auto w-full max-w-lg space-y-1 rounded bg-neutral-900/70 px-1.5 py-1 text-xs text-white">
|
||||
<details className="mx-auto w-full max-w-lg rounded bg-neutral-900/70 px-1.5 py-1 text-xs text-white">
|
||||
<summary className="cursor-pointer text-sm font-semibold text-white">Advanced diagnostics</summary>
|
||||
<div className="mt-1 space-y-1">
|
||||
<p className="text-white">Raw axes</p>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{activePad.axes.map((value, index) => (
|
||||
@@ -475,7 +753,8 @@ export default function GamepadMappingSettings() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</CardFrame>
|
||||
|
||||
@@ -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,14 +52,33 @@ 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' },
|
||||
{ id: 'slowModifier', label: 'Precision modifier', kind: 'button', section: 'Driving' },
|
||||
{ id: 'hornHonk', label: 'Horn (hold)', kind: 'button', section: 'Audio and chat' },
|
||||
{ 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: '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' },
|
||||
{ id: 'auxMainReverse', label: 'Main brush reverse', kind: 'button', section: 'Aux buttons' },
|
||||
{ id: 'auxSideForward', label: 'Side brush forward', kind: 'button', section: 'Aux buttons' },
|
||||
{ id: 'auxSideReverse', label: 'Side brush reverse', kind: 'button', section: 'Aux buttons' },
|
||||
{ id: 'auxVacuumFast', label: 'Vacuum max', kind: 'button', section: 'Aux buttons' },
|
||||
{ id: 'auxVacuumSlow', label: 'Vacuum low', kind: 'button', section: 'Aux buttons' },
|
||||
{ id: 'auxAllForward', label: 'All motors forward', kind: 'button', section: 'Aux buttons' },
|
||||
];
|
||||
|
||||
export const CAPTURE_AXIS_THRESHOLD = 0.45;
|
||||
|
||||
@@ -33,10 +33,10 @@ export function groupActions(actions) {
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function pickActivePad(pads, activeSignature) {
|
||||
export function pickActivePad(pads, activeInstanceKey) {
|
||||
if (!pads || pads.length === 0) return null;
|
||||
if (activeSignature) {
|
||||
const match = pads.find((pad) => pad.signature === activeSignature);
|
||||
if (activeInstanceKey) {
|
||||
const match = pads.find((pad) => pad.instanceKey === activeInstanceKey);
|
||||
if (match) return match;
|
||||
}
|
||||
return pads[0];
|
||||
@@ -62,10 +62,13 @@ function detectAxisCapture(pad, baseline, action) {
|
||||
if (action.kind === 'axisPair') {
|
||||
const top = deltas.filter((entry) => entry.delta > CAPTURE_AXIS_THRESHOLD).slice(0, 2);
|
||||
if (top.length < 2) return null;
|
||||
const orderedIndices = top.map((entry) => entry.index).sort((a, b) => a - b);
|
||||
return {
|
||||
kind: 'axisPair',
|
||||
x: top[0].index,
|
||||
y: top[1].index,
|
||||
// Browsers expose two-dimensional controls as adjacent X/Y axes. Sorting the captured pair
|
||||
// prevents whichever direction moved first from randomly swapping steering and throttle.
|
||||
x: orderedIndices[0],
|
||||
y: orderedIndices[1],
|
||||
...(action.invertDefaults ?? {}),
|
||||
};
|
||||
}
|
||||
@@ -80,16 +83,25 @@ function detectAxisCapture(pad, baseline, action) {
|
||||
|
||||
function detectButtonCapture(pad, baseline, action) {
|
||||
const buttons = pad.buttons ?? [];
|
||||
const newlyPressed = [];
|
||||
for (let i = 0; i < buttons.length; i += 1) {
|
||||
const btn = buttons[i];
|
||||
const value = typeof btn?.value === 'number' ? btn.value : btn?.pressed ? 1 : 0;
|
||||
if (btn?.pressed || value > CAPTURE_BUTTON_THRESHOLD) {
|
||||
const baselineValue = baseline.buttons?.[i]?.value ?? 0;
|
||||
const baselinePressed = baseline.buttons?.[i]?.pressed ?? false;
|
||||
if (!baselinePressed && (btn?.pressed || value - baselineValue > CAPTURE_BUTTON_THRESHOLD)) {
|
||||
if (action.kind === 'axis') {
|
||||
return { kind: 'buttonAxis', index: i };
|
||||
}
|
||||
return { kind: 'button', index: i };
|
||||
newlyPressed.push({ kind: 'button', index: i });
|
||||
}
|
||||
}
|
||||
if (newlyPressed.length > 1) {
|
||||
// Capturing all buttons observed in the same frame makes intentional modifier chords possible
|
||||
// without a separate advanced editor, while a normal single press keeps the compact shape.
|
||||
return { kind: 'chord', inputs: newlyPressed };
|
||||
}
|
||||
if (newlyPressed.length === 1) return newlyPressed[0];
|
||||
const axes = pad.axes ?? [];
|
||||
for (let i = 0; i < axes.length; i += 1) {
|
||||
const value = axes[i] ?? 0;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Controller Capture Tests
|
||||
// Purpose: Verifies that binding capture cannot select held controls or swap stick axes randomly.
|
||||
// Scope: Covers the pure capture detector used by the Controller settings surface.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { buildDescriptorFromCapture, snapshotBaseline } from './helpers.js';
|
||||
|
||||
function button(pressed = false, value = pressed ? 1 : 0) {
|
||||
return { pressed, value };
|
||||
}
|
||||
|
||||
test('a button held before capture is ignored', () => {
|
||||
const baselinePad = { axes: [0, 0], buttons: [button(true), button(false)] };
|
||||
const currentPad = { axes: [0, 0], buttons: [button(true), button(false)] };
|
||||
const descriptor = buildDescriptorFromCapture(
|
||||
currentPad,
|
||||
snapshotBaseline(baselinePad),
|
||||
{ kind: 'button' },
|
||||
);
|
||||
assert.equal(descriptor, null);
|
||||
});
|
||||
|
||||
test('axis-pair capture assigns the lower adjacent axis to X regardless of movement order', () => {
|
||||
const baseline = snapshotBaseline({ axes: [0, 0, 0, 0], buttons: [] });
|
||||
const descriptor = buildDescriptorFromCapture(
|
||||
{ axes: [0, 0, -0.7, 0.9], buttons: [] },
|
||||
baseline,
|
||||
{ kind: 'axisPair', invertDefaults: { invertY: true } },
|
||||
);
|
||||
assert.deepEqual(descriptor, {
|
||||
kind: 'axisPair',
|
||||
x: 2,
|
||||
y: 3,
|
||||
invertY: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('simultaneous new buttons are represented as a chord', () => {
|
||||
const baseline = snapshotBaseline({ axes: [], buttons: [button(), button(), button()] });
|
||||
const descriptor = buildDescriptorFromCapture(
|
||||
{ axes: [], buttons: [button(true), button(), button(true)] },
|
||||
baseline,
|
||||
{ kind: 'button' },
|
||||
);
|
||||
assert.deepEqual(descriptor, {
|
||||
kind: 'chord',
|
||||
inputs: [{ kind: 'button', index: 0 }, { kind: 'button', index: 2 }],
|
||||
});
|
||||
});
|
||||
@@ -2,14 +2,14 @@
|
||||
// Purpose: Defines the Help Content View module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useMemo } from 'react';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import ControlHint from '../ControlHint/index.jsx';
|
||||
import { useControllerRuntime } from '../../controls/inputs/controllerRuntime.js';
|
||||
import { getHelpContent } from '../../help/content.js';
|
||||
|
||||
function KeyPill({ actionId, keymap }) {
|
||||
const value = keymap?.[actionId]?.[0] ?? '';
|
||||
function KeyPill({ actionId }) {
|
||||
return (
|
||||
<span className="rounded border border-slate-600 bg-slate-900/40 px-1 text-[0.7rem] text-slate-200">
|
||||
{formatKeyLabel(value)}
|
||||
<ControlHint actionId={actionId} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -130,14 +130,20 @@ function KeyboardGroup({ group, keymap }) {
|
||||
}
|
||||
|
||||
function KeyboardBlock({ block, keymap }) {
|
||||
const runtime = useControllerRuntime();
|
||||
if (!block) return null;
|
||||
const usingController = runtime.inputMethod === 'controller';
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{/* Heading and footnote share a row when possible and wrap independently
|
||||
when the Help card is mounted in a narrow desktop column. */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-0.5 text-xs text-slate-200">
|
||||
<span className="font-semibold">{block.title}</span>
|
||||
{block.footnote && <span className="text-[0.7rem] text-slate-400">{block.footnote}</span>}
|
||||
<span className="font-semibold">{usingController ? 'Controller controls' : block.title}</span>
|
||||
<span className="text-[0.7rem] text-slate-400">
|
||||
{usingController
|
||||
? 'Per-controller; adjust bindings in Settings → Controller.'
|
||||
: block.footnote}
|
||||
</span>
|
||||
</div>
|
||||
{/* Two keyboard groups fit comfortably once the Help surface reaches 32rem.
|
||||
Using the real content threshold restores the established old-page layout
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useMemo } from 'react';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useControlSelector } from '../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import ControlHint from '../ControlHint/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
@@ -184,7 +183,6 @@ export default function HomeAssistantControls() {
|
||||
}
|
||||
|
||||
function HomeAssistantControlsContent() {
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const ha = useSessionSelector((state) => state.session?.homeAssistant || null);
|
||||
const { homeAssistantToggle, homeAssistantSetLightColor, homeAssistantSetLightWhite } =
|
||||
useSessionActions();
|
||||
@@ -196,8 +194,8 @@ function HomeAssistantControlsContent() {
|
||||
const lightPolicyLocked = Boolean(lightPolicy?.locked || lightPolicy?.lockedOn);
|
||||
const controlsLocked = lightPolicyLocked && !adminCanControlLockedLights;
|
||||
const lockState = lightPolicy?.lockState || (lightPolicy?.lockedOn ? 'on' : null);
|
||||
const onKeyLabel = formatKeyLabel(keymap?.homeAssistantOn?.[0]);
|
||||
const offKeyLabel = formatKeyLabel(keymap?.homeAssistantOff?.[0]);
|
||||
const onKeyLabel = <ControlHint actionId="homeAssistantOn" />;
|
||||
const offKeyLabel = <ControlHint actionId="homeAssistantOff" />;
|
||||
|
||||
if (!ha?.enabled) {
|
||||
return (
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useRef } from 'react';
|
||||
import { FaBullhorn, FaCrosshairs, FaLightbulb } from 'react-icons/fa';
|
||||
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
|
||||
import ControlHint from '../../../ControlHint/index.jsx';
|
||||
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
|
||||
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
|
||||
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||
@@ -45,7 +45,6 @@ export default function BottomLeftPod({ roverId }) {
|
||||
const headlightOn = useControlSelector((control) => Boolean(control.pipeline?.headlightState?.headlightOn));
|
||||
const laserOn = useControlSelector((control) => Boolean(control.pipeline?.laserState?.laserOn));
|
||||
const hornActive = useControlSelector((control) => Boolean(control.state.horn?.active));
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const { setHeadlight, setLaser, startHorn, stopHorn } = useControlActions();
|
||||
const canControl = useCanControlRover(roverId);
|
||||
const hornPointerRef = useRef(null);
|
||||
@@ -80,13 +79,13 @@ export default function BottomLeftPod({ roverId }) {
|
||||
{/* Physical rover actions become visibly and behaviorally unavailable
|
||||
while another queued driver owns the turn. Pod/settings controls
|
||||
remain interactive because they do not mutate rover hardware. */}
|
||||
{hornDevice ? <RoundControl label="Horn" icon={FaBullhorn} keyLabel={formatKeyLabel(keymap?.hornHonk?.[0])} active={hornActive} tone="horn" disabled={!canControl} large onPointerDown={startHornPointer} onPointerUp={stopHornPointer} className="absolute bottom-1 left-1" /> : null}
|
||||
{headlight ? <RoundControl label="Headlight" icon={FaLightbulb} keyLabel={formatKeyLabel(keymap?.headlightToggle?.[0])} active={headlightOn} disabled={!canControl} onClick={() => setHeadlight(!headlightOn)} className="absolute left-[1.979rem] top-[0.662rem]" /> : null}
|
||||
{hornDevice ? <RoundControl label="Horn" icon={FaBullhorn} keyLabel={<ControlHint actionId="hornHonk" />} active={hornActive} tone="horn" disabled={!canControl} large onPointerDown={startHornPointer} onPointerUp={stopHornPointer} className="absolute bottom-1 left-1" /> : null}
|
||||
{headlight ? <RoundControl label="Headlight" icon={FaLightbulb} keyLabel={<ControlHint actionId="headlightToggle" />} active={headlightOn} disabled={!canControl} onClick={() => setHeadlight(!headlightOn)} className="absolute left-[1.979rem] top-[0.662rem]" /> : null}
|
||||
{/* The room-light lock deliberately blocks laser activation because
|
||||
the laser is only intended for use while the room is dark. This
|
||||
mirrors the old desktop control's visible disabled state; turn
|
||||
ownership remains the other independent control restriction. */}
|
||||
{laser ? <RoundControl label="Laser" icon={FaCrosshairs} keyLabel={formatKeyLabel(keymap?.laserToggle?.[0])} active={laserOn} disabled={!canControl || roomLightsLockedOn} onClick={() => setLaser(!laserOn)} className="absolute left-[5.338rem] top-[4.021rem]" /> : null}
|
||||
{laser ? <RoundControl label="Laser" icon={FaCrosshairs} keyLabel={<ControlHint actionId="laserToggle" />} active={laserOn} disabled={!canControl || roomLightsLockedOn} onClick={() => setLaser(!laserOn)} className="absolute left-[5.338rem] top-[4.021rem]" /> : null}
|
||||
<CornerPodToggle corner="bottom-left" expanded label="Hide rover controls" onClick={() => setOpen(false)} />
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { FaVideo } from 'react-icons/fa';
|
||||
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
|
||||
import ControlHint from '../../../ControlHint/index.jsx';
|
||||
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
|
||||
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
|
||||
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||
@@ -32,7 +32,6 @@ export default function BottomRightPod({ roverId }) {
|
||||
const [open, setOpen] = usePodVisibility('camera', true);
|
||||
const camera = useControlSelector((control) => control.state.camera);
|
||||
const dockAssistActive = useControlSelector((control) => Boolean(control.state.manualDockAssist?.active));
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const { setServoAngle } = useControlActions();
|
||||
const canControl = useCanControlRover(roverId);
|
||||
const config = camera?.config;
|
||||
@@ -86,8 +85,8 @@ export default function BottomRightPod({ roverId }) {
|
||||
</button>
|
||||
{/* These positions continue around the same circle just beyond the two slider endpoints.
|
||||
Together they occupy the open third facing the corner without enlarging the pod. */}
|
||||
<div className="absolute left-[61%] top-[90%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={formatKeyLabel(keymap?.cameraDown?.[0])} /></div>
|
||||
<div className="absolute left-[90%] top-[61%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={formatKeyLabel(keymap?.cameraUp?.[0])} /></div>
|
||||
<div className="absolute left-[61%] top-[90%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={<ControlHint actionId="cameraDown" />} /></div>
|
||||
<div className="absolute left-[90%] top-[61%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={<ControlHint actionId="cameraUp" />} /></div>
|
||||
<CornerPodToggle corner="bottom-right" expanded label="Hide camera tilt" onClick={() => setOpen(false)} />
|
||||
</div>
|
||||
) : showCameraControls && enabled ? (
|
||||
|
||||
@@ -4,14 +4,12 @@ import { useCallback, useState } from 'react';
|
||||
import { FaComment } from 'react-icons/fa';
|
||||
import { useChatActions } from '../../../../context/ChatContext.jsx';
|
||||
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
|
||||
import { useControlSelector } from '../../../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
|
||||
import ControlHint from '../../../ControlHint/index.jsx';
|
||||
import HudChatInput from '../../HudChatInput/index.jsx';
|
||||
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||
|
||||
export default function ChatExpansion({ podOpen }) {
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const chatKeyLabel = useControlSelector((control) => formatKeyLabel(control.state.keymap?.chatFocus?.[0]));
|
||||
const { blurChat, focusChat } = useChatActions();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -50,7 +48,7 @@ export default function ChatExpansion({ podOpen }) {
|
||||
<FaComment aria-hidden="true" />
|
||||
{/* The pill reflects the live keymap so remapping chat focus updates this
|
||||
compact HUD hint without duplicating or hardcoding the default key. */}
|
||||
{chatKeyLabel ? <KeyPill label={chatKeyLabel} /> : null}
|
||||
<KeyPill label={<ControlHint actionId="chatFocus" />} />
|
||||
</button>
|
||||
|
||||
<HudChatInput variant="newdrive" open={open} onOpenChange={setChatOpen} />
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// the archived desktop layout retains its previous DriveDockAction behavior.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { FaChargingStation } from 'react-icons/fa';
|
||||
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
|
||||
import { useControlActions } from '../../../../controls/index.js';
|
||||
import ControlHint from '../../../ControlHint/index.jsx';
|
||||
import { useTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
|
||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../../../context/telemetryViews.js';
|
||||
import { useManualDockAssist } from '../../../../features/manualDockAssist/useManualDockAssist.js';
|
||||
@@ -252,7 +252,6 @@ function UndockTransitionGhost({ onFinish }) {
|
||||
export default function DockingHud({ roverId }) {
|
||||
const layout = useDriverLayout();
|
||||
const actions = useControlActions();
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||
// This replaces ManualDockAssistOverlay as the current HUD's one lifecycle owner. It preserves the
|
||||
// success sounds, camera positioning, speed cap, and automatic exit after charging begins.
|
||||
@@ -282,12 +281,8 @@ export default function DockingHud({ roverId }) {
|
||||
/* Mobile already presents its own touch-oriented driving controls. The docked
|
||||
action therefore keeps its plain-language instruction without advertising a
|
||||
keyboard shortcut that is irrelevant on that layout. */
|
||||
const driveKeyLabel = layout === 'desktop'
|
||||
? formatKeyLabel(keymap?.driveMacro?.[0])
|
||||
: '';
|
||||
const dockKeyLabel = layout === 'desktop'
|
||||
? formatKeyLabel(keymap?.dockMacro?.[0])
|
||||
: '';
|
||||
const driveKeyLabel = layout === 'desktop' ? <ControlHint actionId="driveMacro" /> : '';
|
||||
const dockKeyLabel = layout === 'desktop' ? <ControlHint actionId="dockMacro" /> : '';
|
||||
const batteryPodOpen = podSettings?.battery !== false;
|
||||
// The camera arc is the shared circular-pod reference size. Keep the dock expansion flush
|
||||
// against the battery shell after enlarging that gauge to the same 8.5-rem footprint.
|
||||
|
||||
@@ -16,8 +16,8 @@ import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
|
||||
import QueueTargetRow, { QueueUserChips } from '../QueueTargetRow/index.jsx';
|
||||
import TurnsOverlay from '../HudOverlays/TurnsOverlay/index.jsx';
|
||||
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import { useControlActions } from '../../controls/index.js';
|
||||
import ControlHint from '../ControlHint/index.jsx';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
|
||||
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||
@@ -283,12 +283,7 @@ function PtzMobileControlsPanel({ ptz, disabled = false }) {
|
||||
);
|
||||
}
|
||||
|
||||
function keyLabelFor(keymap, actionId) {
|
||||
return formatKeyLabel(keymap?.[actionId]?.[0]);
|
||||
}
|
||||
|
||||
function PtzControlReference() {
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const rows = [
|
||||
['Tilt up', 'driveForward'],
|
||||
['Tilt down', 'driveBackward'],
|
||||
@@ -305,7 +300,7 @@ function PtzControlReference() {
|
||||
{rows.map(([label, actionId]) => (
|
||||
<div key={label} className="surface flex items-center justify-between gap-1">
|
||||
<span className="text-slate-400">{label}</span>
|
||||
<KeyPill label={keyLabelFor(keymap, actionId)} />
|
||||
<KeyPill label={<ControlHint actionId={actionId} />} />
|
||||
</div>
|
||||
))}
|
||||
</CardFrame>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// Scope: Owns row chrome, queue chips, timer labels, and row/button event plumbing;
|
||||
// callers still own target-specific permission checks and request actions.
|
||||
import RoverLabel from '../RoverLabel/index.jsx';
|
||||
import RoverHelpOverlay from '../RoverHelpOverlay/index.jsx';
|
||||
|
||||
function classNames(...values) {
|
||||
return values.filter(Boolean).join(' ');
|
||||
@@ -105,7 +106,7 @@ export default function QueueTargetRow({
|
||||
return (
|
||||
<li
|
||||
className={classNames(
|
||||
'surface flex flex-wrap items-start justify-between gap-0.5',
|
||||
'surface relative flex flex-wrap items-start justify-between gap-0.5 overflow-hidden',
|
||||
canClick && 'cursor-pointer',
|
||||
locked
|
||||
? 'bg-red-900/40'
|
||||
@@ -191,6 +192,9 @@ export default function QueueTargetRow({
|
||||
{buttonLabel}
|
||||
</button>
|
||||
) : null}
|
||||
{/* Queue rows use the exact overlay component as the large video and
|
||||
display surfaces; its measured font automatically adapts to this box. */}
|
||||
<RoverHelpOverlay active={Boolean(target?.needsHelp)} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,32 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useControlSelector } from '../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import ControlHint from '../ControlHint/index.jsx';
|
||||
import NicknameForm from '../NicknameForm/index.jsx';
|
||||
import SocialButton from '../SocialButton/index.jsx';
|
||||
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { getSocialById } from '../../lib/socials.js';
|
||||
|
||||
function ControlRow({ label, keyLabel }) {
|
||||
function ControlRow({ label, actionId }) {
|
||||
return (
|
||||
<div className="surface-muted flex items-center justify-between gap-0.5 px-0.5 py-0.35 text-[0.8rem] text-slate-200">
|
||||
<span>{label}</span>
|
||||
<KeyPill label={keyLabel} />
|
||||
<KeyPill label={<ControlHint actionId={actionId} />} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopQuickstart({ keymap }) {
|
||||
function DesktopQuickstart() {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm text-slate-200">1. Click "Your rover is docked" to undock.</p>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm text-slate-200">2. Drive with these keybindings:</p>
|
||||
<div className="space-y-0.5">
|
||||
<ControlRow label="Forward" keyLabel={formatKeyLabel(keymap?.driveForward?.[0])} />
|
||||
<ControlRow label="Backward" keyLabel={formatKeyLabel(keymap?.driveBackward?.[0])} />
|
||||
<ControlRow label="Turn Left" keyLabel={formatKeyLabel(keymap?.driveLeft?.[0])} />
|
||||
<ControlRow label="Turn Right" keyLabel={formatKeyLabel(keymap?.driveRight?.[0])} />
|
||||
<ControlRow label="Move faster" keyLabel={formatKeyLabel(keymap?.boostModifier?.[0])} />
|
||||
<ControlRow label="Move slower" keyLabel={formatKeyLabel(keymap?.slowModifier?.[0])} />
|
||||
<ControlRow label="Forward" actionId="driveForward" />
|
||||
<ControlRow label="Backward" actionId="driveBackward" />
|
||||
<ControlRow label="Turn Left" actionId="driveLeft" />
|
||||
<ControlRow label="Turn Right" actionId="driveRight" />
|
||||
<ControlRow label="Move faster" actionId="boostModifier" />
|
||||
<ControlRow label="Move slower" actionId="slowModifier" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-slate-200">3. Use the video HUD for rover controls and information.</p>
|
||||
@@ -75,9 +73,7 @@ export default function QuickstartOverlay({
|
||||
onToggleShowOnLoad,
|
||||
onClose,
|
||||
}) {
|
||||
const rawKeymap = useControlSelector((control) => control.state.keymap);
|
||||
const isDesktop = layout === 'desktop';
|
||||
const keymap = useMemo(() => rawKeymap || {}, [rawKeymap]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
@@ -97,7 +93,7 @@ export default function QuickstartOverlay({
|
||||
</div>
|
||||
<div className={`grid gap-0.5 p-0.5 ${isDesktop ? 'md:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]' : 'grid-cols-1'}`}>
|
||||
<section className="space-y-0.5 border-b border-slate-700">
|
||||
{isDesktop ? <DesktopQuickstart keymap={keymap} /> : <MobileQuickstart />}
|
||||
{isDesktop ? <DesktopQuickstart /> : <MobileQuickstart />}
|
||||
</section>
|
||||
{/* {!isDesktop? <div className='w-full h-1 bg-blue-500'></div> : null} */}
|
||||
<section className="space-y-0.5">
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Rover Help Overlay
|
||||
// Purpose: Gives every rover surface one unmistakable, responsive HELP treatment.
|
||||
// Scope: Renders only the shared visual; server roster state decides when it is active.
|
||||
import AutoFitText from '../AutoFitText/index.jsx';
|
||||
import './styles.css';
|
||||
|
||||
export default function RoverHelpOverlay({ active = false }) {
|
||||
if (!active) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rover-help-overlay pointer-events-none absolute inset-0 z-50 overflow-hidden border-[clamp(0.2rem,1.2cqw,1rem)] border-white bg-red-600 p-[clamp(0.2rem,2cqw,1.5rem)] text-white"
|
||||
role="status"
|
||||
aria-label="Rover needs help"
|
||||
>
|
||||
<AutoFitText
|
||||
fitHeight
|
||||
minSize={8}
|
||||
maxSize={1400}
|
||||
className="font-black tracking-tight text-white"
|
||||
>
|
||||
HELP
|
||||
</AutoFitText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
@keyframes rover-help-flash {
|
||||
0%, 49% { opacity: 0.96; }
|
||||
50%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.rover-help-overlay {
|
||||
container-type: size;
|
||||
animation: rover-help-flash 2s steps(1, end) infinite;
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import { useHudMapSetting } from '../../hooks/useHudMapSetting.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { useSocket } from '../../context/SocketContext.jsx';
|
||||
import { AUDIO_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import ControlHint from '../ControlHint/index.jsx';
|
||||
import {
|
||||
DEFAULT_PAGE_THEME_KEY,
|
||||
PAGE_THEME_OPTIONS,
|
||||
@@ -129,7 +129,6 @@ function reconnectSocketWithTransport(socket, transport) {
|
||||
}
|
||||
|
||||
export default function SettingsPanel() {
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
const { sendOiCommand, setSensorStream } = useControlActions();
|
||||
const canControl = Boolean(roverId);
|
||||
@@ -170,7 +169,7 @@ export default function SettingsPanel() {
|
||||
? Math.max(0, Math.min(1, audioSettings.mainBrushDuckAmount))
|
||||
: AUDIO_SETTINGS_DEFAULTS.mainBrushDuckAmount;
|
||||
const videoColorFilter = normalizeVideoFilter(videoSettings?.colorFilter);
|
||||
const videoFilterCycleKeyLabel = formatKeyLabel(keymap?.videoFilterCycle?.[0]);
|
||||
const videoFilterCycleKeyLabel = <ControlHint actionId="videoFilterCycle" />;
|
||||
|
||||
useEffect(() => {
|
||||
// Settings load after the provider mounts and can also be replaced by an incoming inter-instance
|
||||
|
||||
@@ -4,6 +4,7 @@ import RoverDescriptionOverlay from '../HudOverlays/RoverDescriptionOverlay/inde
|
||||
import OvercurrentOverlay from '../HudOverlays/OvercurrentOverlay/index.jsx';
|
||||
import LowBatteryOverlay from '../HudOverlays/LowBatteryOverlay/index.jsx';
|
||||
import VerticalBatteryOverlay from '../HudOverlays/VerticalBatteryOverlay/index.jsx';
|
||||
import RoverHelpOverlay from '../RoverHelpOverlay/index.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
|
||||
export default function SpectateVideo({
|
||||
@@ -11,6 +12,7 @@ export default function SpectateVideo({
|
||||
label,
|
||||
fitParent = false,
|
||||
layoutFormat = 'desktop',
|
||||
needsHelp = false,
|
||||
}) {
|
||||
const isExternalSpectatorSnapshotOnly = useSessionSelector((state) =>
|
||||
state.session?.role === 'spectator' &&
|
||||
@@ -46,6 +48,7 @@ export default function SpectateVideo({
|
||||
<OvercurrentOverlay roverId={roverId} compact={false} />
|
||||
<LowBatteryOverlay roverId={roverId} compact={false} />
|
||||
<VerticalBatteryOverlay show roverId={roverId} mobileHud={false} />
|
||||
<RoverHelpOverlay active={needsHelp} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { fieldClass } from '../constants.js';
|
||||
import { useControlSelector } from '../../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../../controls/keymapUtils.js';
|
||||
import ControlHint from '../../ControlHint/index.jsx';
|
||||
import { useSettingsNamespace } from '../../../settings/index.js';
|
||||
import { MAX_UPLOAD_BYTES, TARGET_SAMPLE_RATE, RTC_CONFIG } from './constants.js';
|
||||
import { bytesToBase64, buildAuthHeader } from './base64.js';
|
||||
@@ -28,7 +28,6 @@ export default function VipAudioUploadCard({
|
||||
readyMicWhip,
|
||||
stopMicWhip,
|
||||
}) {
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const pttActive = useControlSelector((control) => Boolean(control.state.mic?.pttActive));
|
||||
const { value: vipAudio, save: saveVipAudio } = useSettingsNamespace('vipAudio', {
|
||||
openMicEnabled: false,
|
||||
@@ -81,7 +80,7 @@ export default function VipAudioUploadCard({
|
||||
const whipLinkActive = !clipMode && (micState === 'live' || micState === 'starting');
|
||||
const clipRecording = clipMode && clipState === 'recording';
|
||||
const clipSending = clipMode && clipState === 'sending';
|
||||
const pttKeyLabel = formatKeyLabel(keymap?.micPtt?.[0]) || 'M';
|
||||
const pttKeyLabel = <ControlHint actionId="micPtt" />;
|
||||
|
||||
const setPttMode = useCallback(
|
||||
(nextMode) => {
|
||||
|
||||
@@ -12,8 +12,8 @@ import PtzLiveVideo from '../PtzLiveVideo/index.jsx';
|
||||
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
|
||||
import KeyPill from './VipAudioUploadCard/KeyPill.jsx';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import { useControlActions } from '../../controls/index.js';
|
||||
import ControlHint from '../ControlHint/index.jsx';
|
||||
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
@@ -307,12 +307,7 @@ function PtzMobileControlsPanel({ ptz, disabled = false }) {
|
||||
);
|
||||
}
|
||||
|
||||
function keyLabelFor(keymap, actionId) {
|
||||
return formatKeyLabel(keymap?.[actionId]?.[0]);
|
||||
}
|
||||
|
||||
function PtzControlReference() {
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const rows = [
|
||||
['Tilt up', 'driveForward'],
|
||||
['Tilt down', 'driveBackward'],
|
||||
@@ -331,7 +326,7 @@ function PtzControlReference() {
|
||||
<span className="text-slate-400">{label}</span>
|
||||
{/* Use the same key display component as the rest of the UI so PTZ
|
||||
controls read as normal mapped controls instead of custom labels. */}
|
||||
<KeyPill label={keyLabelFor(keymap, actionId)} />
|
||||
<KeyPill label={<ControlHint actionId={actionId} />} />
|
||||
</div>
|
||||
))}
|
||||
</CardFrame>
|
||||
|
||||
@@ -3,21 +3,38 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
||||
import { useControlActions, useControlSelector } from '../ControlContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { GAMEPAD_SETTINGS_DEFAULTS, GAMEPAD_PROFILE_DEFAULT } from '../../settings/namespaces.js';
|
||||
import {
|
||||
GAMEPAD_SETTINGS_DEFAULTS,
|
||||
GAMEPAD_PROFILE_DEFAULT,
|
||||
VIDEO_SETTINGS_DEFAULTS,
|
||||
} from '../../settings/namespaces.js';
|
||||
import {
|
||||
advanceCameraAngle,
|
||||
computeGamepadOutputs,
|
||||
createProfileForPad,
|
||||
getPadSignature,
|
||||
resolveGamepadProfile,
|
||||
} from './gamepadBindings.js';
|
||||
import { subscribeGamepadHub } from './gamepadHub.js';
|
||||
import { isTextEntryActive } from './inputFocusUtils.js';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import {
|
||||
isControllerControlLocked,
|
||||
markControllerDisconnected,
|
||||
markControllerInputActive,
|
||||
} from './controllerRuntime.js';
|
||||
import { useChatActions, useChatFocus } from '../../context/ChatContext.jsx';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { SONG_DEFAULT_DURATION, SONG_DEFAULT_NOTE, SONG_NOTE_RANGE } from '../constants.js';
|
||||
|
||||
const SOURCE = 'gamepad';
|
||||
const ZERO_VECTOR = { x: 0, y: 0, boost: false };
|
||||
const ZERO_AUX = { main: 0, side: 0, vacuum: 0 };
|
||||
const DRIVE_RATE_MS = 100;
|
||||
const AUX_RATE_MS = 100;
|
||||
const CONTROLLER_ACTIVITY_AXIS_MIN = 0.24;
|
||||
const CONTROLLER_ACTIVITY_AXIS_DELTA = 0.08;
|
||||
const VIDEO_FILTER_SEQUENCE = ['none', 'grayscale', 'greenscale'];
|
||||
|
||||
function areVectorsEqual(a, b) {
|
||||
return a && b && a.x === b.x && a.y === b.y && a.boost === b.boost;
|
||||
@@ -37,15 +54,63 @@ function isAuxIdle(aux) {
|
||||
return !aux.main && !aux.side && !aux.vacuum;
|
||||
}
|
||||
|
||||
function pickActivePad(pads, activeSignature) {
|
||||
function pickActivePad(pads, activeInstanceKey) {
|
||||
if (!pads || pads.length === 0) return null;
|
||||
if (activeSignature) {
|
||||
const match = pads.find((pad) => pad.signature === activeSignature);
|
||||
if (activeInstanceKey) {
|
||||
const match = pads.find((pad) => pad.instanceKey === activeInstanceKey);
|
||||
if (match) return match;
|
||||
}
|
||||
return pads[0];
|
||||
}
|
||||
|
||||
function hasMeaningfulControllerChange(pad, previous) {
|
||||
if (!previous) {
|
||||
return pad.buttons.some((button) => button.pressed) ||
|
||||
pad.axes.some((axis) => Math.abs(axis) >= CONTROLLER_ACTIVITY_AXIS_MIN);
|
||||
}
|
||||
const buttonPressed = pad.buttons.some(
|
||||
(button, index) => button.pressed && !previous.buttons?.[index]?.pressed,
|
||||
);
|
||||
if (buttonPressed) return true;
|
||||
return pad.axes.some((axis, index) => {
|
||||
const oldAxis = previous.axes?.[index] ?? 0;
|
||||
return Math.abs(axis) >= CONTROLLER_ACTIVITY_AXIS_MIN &&
|
||||
Math.abs(axis - oldAxis) >= CONTROLLER_ACTIVITY_AXIS_DELTA;
|
||||
});
|
||||
}
|
||||
|
||||
function isControllerNeutral(pad) {
|
||||
return !pad.buttons.some((button) => button.pressed || button.value > 0.1) &&
|
||||
!pad.axes.some((axis) => Math.abs(axis) > 0.2);
|
||||
}
|
||||
|
||||
function nextVideoFilter(value) {
|
||||
const index = VIDEO_FILTER_SEQUENCE.indexOf(value);
|
||||
return VIDEO_FILTER_SEQUENCE[(index < 0 ? 0 : index + 1) % VIDEO_FILTER_SEQUENCE.length];
|
||||
}
|
||||
|
||||
function cycleHomeAssistant(latest, targetState) {
|
||||
const homeAssistant = latest.homeAssistant;
|
||||
if (!homeAssistant?.enabled || !homeAssistant?.connected) return;
|
||||
if (
|
||||
(homeAssistant.lightPolicy?.locked || homeAssistant.lightPolicy?.lockedOn) &&
|
||||
!latest.adminCanControlLockedLights
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const entities = (homeAssistant.entities ?? []).filter(
|
||||
(entity) =>
|
||||
(entity.type === 'light' || entity.type === 'switch') &&
|
||||
entity.available !== false &&
|
||||
entity.state !== 'unavailable',
|
||||
);
|
||||
const ordered = targetState === 'on' ? entities : [...entities].reverse();
|
||||
const next = ordered.find((entity) =>
|
||||
targetState === 'on' ? entity.state !== 'on' : entity.state === 'on',
|
||||
);
|
||||
if (next) latest.homeAssistantSetState(next.id, targetState).catch(() => {});
|
||||
}
|
||||
|
||||
export default function GamepadInputManager() {
|
||||
const {
|
||||
setMode,
|
||||
@@ -57,11 +122,27 @@ export default function GamepadInputManager() {
|
||||
toggleHeadlight,
|
||||
toggleLaser,
|
||||
registerInputState,
|
||||
sendSong,
|
||||
setSongNote,
|
||||
startHorn,
|
||||
stopHorn,
|
||||
setMicPttActive,
|
||||
} = useControlActions();
|
||||
const cameraAngle = useControlSelector((control) => control.state.camera?.angle);
|
||||
const cameraConfig = useControlSelector((control) => control.state.camera?.config);
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
const dockAssist = useManualDockAssist();
|
||||
const { focusChat } = useChatActions();
|
||||
const { isChatFocused } = useChatFocus();
|
||||
const { homeAssistantSetState, pushAlert } = useSessionActions();
|
||||
const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null);
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const sessionMode = useSessionSelector((state) => state.session?.mode || null);
|
||||
const songNote = useControlSelector((control) => control.state.song?.note);
|
||||
const { value: videoSettings, save: saveVideoSettings } = useSettingsNamespace(
|
||||
'video',
|
||||
VIDEO_SETTINGS_DEFAULTS,
|
||||
);
|
||||
const { value: gamepadSettings, save: saveGamepadSettings } = useSettingsNamespace(
|
||||
'gamepad',
|
||||
GAMEPAD_SETTINGS_DEFAULTS,
|
||||
@@ -75,6 +156,12 @@ export default function GamepadInputManager() {
|
||||
const lastAuxSentAtRef = useRef(0);
|
||||
const lastServoAtRef = useRef(0);
|
||||
const lastServoAngleRef = useRef(null);
|
||||
const previousPadRef = useRef(null);
|
||||
const lastConnectedSignatureRef = useRef(null);
|
||||
const lastConnectedInstanceKeyRef = useRef(null);
|
||||
const lastRegisteredSignatureRef = useRef(null);
|
||||
const controllerLockedRef = useRef(false);
|
||||
const waitingForNeutralRef = useRef(false);
|
||||
// The hub subscription is intentionally stable, so this ref is the bridge back to the latest
|
||||
// React values. Rewriting it after each commit is cheaper than tearing down browser gamepad
|
||||
// listeners every time settings, camera state, or control callbacks change.
|
||||
@@ -91,7 +178,10 @@ export default function GamepadInputManager() {
|
||||
latest.saveGamepadSettings((prev) => {
|
||||
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
|
||||
if (current.profiles?.[signature]) return current;
|
||||
const base = current?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT;
|
||||
const base = resolveGamepadProfile(
|
||||
current?.defaults?.profile,
|
||||
GAMEPAD_PROFILE_DEFAULT,
|
||||
);
|
||||
const nextProfile = createProfileForPad(padState, base);
|
||||
return {
|
||||
...current,
|
||||
@@ -114,12 +204,19 @@ export default function GamepadInputManager() {
|
||||
const config = latest?.cameraConfig;
|
||||
if (!latest || !config) return;
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
const cameraMode = calibration?.cameraMode ?? 'absolute';
|
||||
const sensitivity = Math.max(1, Math.min(180, calibration?.cameraSensitivity ?? 60));
|
||||
const cameraMode = calibration?.cameraMode ?? 'velocity';
|
||||
const sensitivity = calibration?.cameraSensitivity ?? 60;
|
||||
const min = typeof config.minAngle === 'number' ? config.minAngle : -45;
|
||||
const max = typeof config.maxAngle === 'number' ? config.maxAngle : 45;
|
||||
if (cameraMode === 'velocity') {
|
||||
if (Math.abs(axisValue) <= 0.001) {
|
||||
/* Neutral is the safe synchronization point: no controller motion is being integrated, so
|
||||
an angle changed by another UI can replace our accumulator without causing jitter. */
|
||||
if (typeof latest.cameraAngle === 'number') lastServoAngleRef.current = latest.cameraAngle;
|
||||
lastServoAtRef.current = now;
|
||||
return;
|
||||
}
|
||||
const dt = Math.min(50, now - lastServoAtRef.current || 16);
|
||||
const delta = axisValue * sensitivity * (dt / 1000);
|
||||
if (Math.abs(delta) < 0.01) return;
|
||||
const baseline =
|
||||
typeof lastServoAngleRef.current === 'number'
|
||||
? lastServoAngleRef.current
|
||||
@@ -128,14 +225,12 @@ export default function GamepadInputManager() {
|
||||
: typeof config.homeAngle === 'number'
|
||||
? config.homeAngle
|
||||
: 0;
|
||||
const nextAngle = baseline + delta;
|
||||
const nextAngle = advanceCameraAngle(baseline, axisValue, sensitivity, dt, { min, max });
|
||||
latest.setServoAngle(nextAngle);
|
||||
lastServoAngleRef.current = nextAngle;
|
||||
lastServoAtRef.current = now;
|
||||
return;
|
||||
}
|
||||
const min = typeof config.minAngle === 'number' ? config.minAngle : -45;
|
||||
const max = typeof config.maxAngle === 'number' ? config.maxAngle : 45;
|
||||
const home = typeof config.homeAngle === 'number' ? config.homeAngle : (min + max) / 2;
|
||||
const angle =
|
||||
axisValue < 0
|
||||
@@ -153,9 +248,29 @@ export default function GamepadInputManager() {
|
||||
latest.setServoAngle(angle);
|
||||
}, []);
|
||||
|
||||
const activeSignature = useMemo(
|
||||
() => gamepadSettings?.activeSignature ?? null,
|
||||
[gamepadSettings?.activeSignature],
|
||||
const neutralizeController = useCallback((latest) => {
|
||||
/*
|
||||
Every path that makes controller commands unsafe converges here. In particular, held horn
|
||||
and microphone actions need releases just as much as drive and motor axes need zeroes.
|
||||
*/
|
||||
latest.setCameraAxisIntent(0);
|
||||
if (!areVectorsEqual(lastVectorRef.current, ZERO_VECTOR)) {
|
||||
lastVectorRef.current = ZERO_VECTOR;
|
||||
latest.setDriveVector(ZERO_VECTOR, { source: SOURCE });
|
||||
}
|
||||
if (!areAuxEqual(lastAuxRef.current, ZERO_AUX)) {
|
||||
lastAuxRef.current = ZERO_AUX;
|
||||
latest.setAuxMotors(ZERO_AUX);
|
||||
}
|
||||
if (buttonStateRef.current.get('hornHonk')) latest.stopHorn();
|
||||
if (buttonStateRef.current.get('micPtt')) latest.setMicPttActive(false);
|
||||
buttonStateRef.current = new Map();
|
||||
reverseStateRef.current = { main: false, side: false };
|
||||
}, []);
|
||||
|
||||
const activeInstanceKey = useMemo(
|
||||
() => gamepadSettings?.activeInstanceKey ?? null,
|
||||
[gamepadSettings?.activeInstanceKey],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -163,83 +278,132 @@ export default function GamepadInputManager() {
|
||||
// after React commits. Updating this ref before paint keeps the stable hub callback aligned
|
||||
// with the newest settings and control actions without resubscribing to the hub.
|
||||
latestRef.current = {
|
||||
activeSignature,
|
||||
activeInstanceKey,
|
||||
adminCanControlLockedLights:
|
||||
role === 'lockdown' || (role === 'admin' && sessionMode !== 'lockdown'),
|
||||
cameraAngle,
|
||||
cameraConfig,
|
||||
dockAssist,
|
||||
focusChat,
|
||||
gamepadSettings,
|
||||
homeAssistant,
|
||||
homeAssistantSetState,
|
||||
isChatFocused,
|
||||
pushAlert,
|
||||
registerInputState,
|
||||
roverId,
|
||||
runMacro,
|
||||
saveGamepadSettings,
|
||||
saveVideoSettings,
|
||||
sendSong,
|
||||
setAuxMotors,
|
||||
setCameraAxisIntent,
|
||||
setDriveVector,
|
||||
setMicPttActive,
|
||||
setMode,
|
||||
setSongNote,
|
||||
setServoAngle,
|
||||
songNote,
|
||||
startHorn,
|
||||
stopHorn,
|
||||
toggleHeadlight,
|
||||
toggleLaser,
|
||||
videoColorFilter: videoSettings?.colorFilter ?? VIDEO_SETTINGS_DEFAULTS.colorFilter,
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeGamepadHub((hubState) => {
|
||||
const unsubscribe = subscribeGamepadHub((hubState) => {
|
||||
const latest = latestRef.current;
|
||||
if (!latest) return;
|
||||
const activePad = pickActivePad(hubState.pads, latest.activeSignature);
|
||||
const activePad = pickActivePad(hubState.pads, latest.activeInstanceKey);
|
||||
if (!activePad) {
|
||||
// A disconnected controller cannot deliver a final neutral axis sample.
|
||||
// Publish it here so PTZ zoom never depends on the browser doing so.
|
||||
latest.setCameraAxisIntent(0);
|
||||
if (!areVectorsEqual(lastVectorRef.current, ZERO_VECTOR)) {
|
||||
lastVectorRef.current = ZERO_VECTOR;
|
||||
latest.setDriveVector(ZERO_VECTOR, { source: SOURCE });
|
||||
}
|
||||
if (!areAuxEqual(lastAuxRef.current, ZERO_AUX)) {
|
||||
lastAuxRef.current = ZERO_AUX;
|
||||
latest.setAuxMotors(ZERO_AUX);
|
||||
}
|
||||
buttonStateRef.current = new Map();
|
||||
reverseStateRef.current = { main: false, side: false };
|
||||
// A disconnect cannot provide release samples, so synthesize every required release once.
|
||||
neutralizeController(latest);
|
||||
markControllerDisconnected(lastConnectedSignatureRef.current);
|
||||
previousPadRef.current = null;
|
||||
lastConnectedSignatureRef.current = null;
|
||||
lastConnectedInstanceKeyRef.current = null;
|
||||
lastRegisteredSignatureRef.current = null;
|
||||
controllerLockedRef.current = false;
|
||||
waitingForNeutralRef.current = false;
|
||||
lastDriveSentAtRef.current = 0;
|
||||
lastAuxSentAtRef.current = 0;
|
||||
latest.registerInputState(SOURCE, { connected: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTextEntryActive()) {
|
||||
// Entering text blocks gamepad control immediately, including a held
|
||||
// camera axis that otherwise would keep its last PTZ zoom direction.
|
||||
latest.setCameraAxisIntent(0);
|
||||
if (!areVectorsEqual(lastVectorRef.current, ZERO_VECTOR)) {
|
||||
lastVectorRef.current = ZERO_VECTOR;
|
||||
latest.setDriveVector(ZERO_VECTOR, { source: SOURCE });
|
||||
if (
|
||||
lastConnectedInstanceKeyRef.current &&
|
||||
lastConnectedInstanceKeyRef.current !== activePad.instanceKey
|
||||
) {
|
||||
/* Browser slots distinguish two identical controllers. Neutralize the old owner before
|
||||
accepting the replacement and require any controls already held on the new pad to be
|
||||
released, preventing a selection change from inheriting drive, horn, or microphone. */
|
||||
neutralizeController(latest);
|
||||
previousPadRef.current = null;
|
||||
lastRegisteredSignatureRef.current = null;
|
||||
waitingForNeutralRef.current = true;
|
||||
}
|
||||
|
||||
if (hasMeaningfulControllerChange(activePad, previousPadRef.current)) {
|
||||
markControllerInputActive(activePad);
|
||||
}
|
||||
previousPadRef.current = activePad;
|
||||
lastConnectedSignatureRef.current = activePad.signature;
|
||||
lastConnectedInstanceKeyRef.current = activePad.instanceKey;
|
||||
|
||||
const controlsBlocked = isTextEntryActive() || isControllerControlLocked();
|
||||
if (controlsBlocked) {
|
||||
/* Configuration and text entry still receive hub snapshots, but they must never leak
|
||||
through to physical rover actions. Only publish/reset on the transition into the lock. */
|
||||
if (!controllerLockedRef.current) {
|
||||
neutralizeController(latest);
|
||||
latest.registerInputState(SOURCE, { connected: true, blocked: true });
|
||||
}
|
||||
if (!areAuxEqual(lastAuxRef.current, ZERO_AUX)) {
|
||||
lastAuxRef.current = ZERO_AUX;
|
||||
latest.setAuxMotors(ZERO_AUX);
|
||||
}
|
||||
buttonStateRef.current = new Map();
|
||||
reverseStateRef.current = { main: false, side: false };
|
||||
latest.registerInputState(SOURCE, { connected: true, blocked: true });
|
||||
controllerLockedRef.current = true;
|
||||
waitingForNeutralRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (controllerLockedRef.current) {
|
||||
controllerLockedRef.current = false;
|
||||
latest.registerInputState(SOURCE, { connected: true, blocked: false });
|
||||
}
|
||||
/* A control held while a dialog closes must not become a fresh command. Require a neutral
|
||||
sample before rearming the controller, just like releasing an emergency-stop switch. */
|
||||
if (waitingForNeutralRef.current) {
|
||||
if (!isControllerNeutral(activePad)) return;
|
||||
waitingForNeutralRef.current = false;
|
||||
}
|
||||
|
||||
ensureProfile(activePad);
|
||||
const signature = activePad.signature;
|
||||
const profile =
|
||||
const storedProfile =
|
||||
latest.gamepadSettings?.profiles?.[signature] ??
|
||||
latest.gamepadSettings?.defaults?.profile ??
|
||||
GAMEPAD_PROFILE_DEFAULT;
|
||||
const profile = resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT);
|
||||
const outputs = computeGamepadOutputs(activePad, profile);
|
||||
|
||||
if (!areVectorsEqual(outputs.driveVector, lastVectorRef.current)) {
|
||||
const driveVector = {
|
||||
...outputs.driveVector,
|
||||
boost: Boolean(outputs.buttons.boostModifier),
|
||||
};
|
||||
if (!areVectorsEqual(driveVector, lastVectorRef.current)) {
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
const idle = vectorMagnitude(outputs.driveVector) < 0.02;
|
||||
const idle = vectorMagnitude(driveVector) < 0.02;
|
||||
if (idle || now - lastDriveSentAtRef.current >= DRIVE_RATE_MS) {
|
||||
lastVectorRef.current = outputs.driveVector;
|
||||
lastVectorRef.current = driveVector;
|
||||
lastDriveSentAtRef.current = now;
|
||||
latest.setDriveVector(outputs.driveVector, { source: SOURCE });
|
||||
const precisionSpeed = profile.calibration?.precisionSpeed ?? 100;
|
||||
const baseSpeed = profile.calibration?.baseSpeed ?? 500;
|
||||
const turboSpeed = profile.calibration?.turboSpeed ?? 500;
|
||||
latest.setDriveVector(driveVector, {
|
||||
source: SOURCE,
|
||||
speedOptions: outputs.buttons.slowModifier
|
||||
? { baseSpeed: precisionSpeed, boostSpeed: precisionSpeed }
|
||||
: { baseSpeed, boostSpeed: turboSpeed },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,12 +414,30 @@ export default function GamepadInputManager() {
|
||||
const side = reverseStateRef.current.side
|
||||
? -Math.round(sideMagnitude * auxSideScale)
|
||||
: Math.round(sideMagnitude * auxSideScale);
|
||||
/* Digital bindings intentionally override proportional axes. This mirrors keyboard aux
|
||||
precedence exactly while preserving the controller-friendly analog defaults. */
|
||||
let aux = {
|
||||
main: outputs.auxAxis.main !== 0 ? main : 0,
|
||||
side: outputs.auxAxis.side !== 0 ? side : 0,
|
||||
vacuum: outputs.buttons.vacuum ? 127 : 0,
|
||||
main: outputs.buttons.auxMainForward
|
||||
? 127
|
||||
: outputs.buttons.auxMainReverse
|
||||
? -127
|
||||
: outputs.auxAxis.main !== 0
|
||||
? main
|
||||
: 0,
|
||||
side: outputs.buttons.auxSideForward
|
||||
? 127
|
||||
: outputs.buttons.auxSideReverse
|
||||
? -70
|
||||
: outputs.auxAxis.side !== 0
|
||||
? side
|
||||
: 0,
|
||||
vacuum: (outputs.buttons.vacuum || outputs.buttons.auxVacuumFast)
|
||||
? 127
|
||||
: outputs.buttons.auxVacuumSlow
|
||||
? 50
|
||||
: 0,
|
||||
};
|
||||
if (outputs.buttons.allAux) {
|
||||
if (outputs.buttons.allAux || outputs.buttons.auxAllForward) {
|
||||
aux = { main: 127, side: 127, vacuum: 127 };
|
||||
}
|
||||
if (!areAuxEqual(aux, lastAuxRef.current)) {
|
||||
@@ -305,6 +487,67 @@ export default function GamepadInputManager() {
|
||||
handleButtonEdge('laserToggle', false);
|
||||
}
|
||||
|
||||
const hornWasPressed = buttonStateRef.current.get('hornHonk') || false;
|
||||
if (outputs.buttons.hornHonk && handleButtonEdge('hornHonk', true)) {
|
||||
latest.startHorn();
|
||||
} else if (!outputs.buttons.hornHonk) {
|
||||
handleButtonEdge('hornHonk', false);
|
||||
if (hornWasPressed) latest.stopHorn();
|
||||
}
|
||||
|
||||
const micWasPressed = buttonStateRef.current.get('micPtt') || false;
|
||||
if (outputs.buttons.micPtt && handleButtonEdge('micPtt', true)) {
|
||||
latest.setMicPttActive(true);
|
||||
} else if (!outputs.buttons.micPtt) {
|
||||
handleButtonEdge('micPtt', false);
|
||||
if (micWasPressed) latest.setMicPttActive(false);
|
||||
}
|
||||
|
||||
if (outputs.buttons.videoFilterCycle && handleButtonEdge('videoFilterCycle', true)) {
|
||||
const nextFilter = nextVideoFilter(latest.videoColorFilter);
|
||||
latest.saveVideoSettings((current) => ({ ...(current ?? {}), colorFilter: nextFilter }));
|
||||
latest.pushAlert({
|
||||
id: 'video-filter-active',
|
||||
title: 'Video filter',
|
||||
message: `Rover video filter: ${nextFilter}`,
|
||||
color: '#38bdf8',
|
||||
lifetimeMs: 1600,
|
||||
});
|
||||
} else if (!outputs.buttons.videoFilterCycle) {
|
||||
handleButtonEdge('videoFilterCycle', false);
|
||||
}
|
||||
|
||||
if (outputs.buttons.chatFocus && handleButtonEdge('chatFocus', true)) {
|
||||
if (!latest.isChatFocused) latest.focusChat();
|
||||
} else if (!outputs.buttons.chatFocus) {
|
||||
handleButtonEdge('chatFocus', false);
|
||||
}
|
||||
|
||||
/* Song directions share identical edge and wrap behavior; the table keeps the two actions
|
||||
symmetric and prevents one direction from silently diverging during later changes. */
|
||||
for (const [actionId, direction] of [['songNoteUp', 1], ['songNoteDown', -1]]) {
|
||||
if (outputs.buttons[actionId] && handleButtonEdge(actionId, true)) {
|
||||
const [minNote, maxNote] = SONG_NOTE_RANGE;
|
||||
const currentNote = typeof latest.songNote === 'number' ? latest.songNote : SONG_DEFAULT_NOTE;
|
||||
const candidate = currentNote + direction;
|
||||
const nextNote = candidate > maxNote ? minNote : candidate < minNote ? maxNote : candidate;
|
||||
latest.setSongNote(nextNote);
|
||||
latest.sendSong([{ note: nextNote, duration: SONG_DEFAULT_DURATION }], { slot: 0 });
|
||||
} else if (!outputs.buttons[actionId]) {
|
||||
handleButtonEdge(actionId, false);
|
||||
}
|
||||
}
|
||||
|
||||
/* Room-control cycling differs only by target state, so both bindings use the same policy
|
||||
checks and ordered entity selection. */
|
||||
for (const [actionId, targetState] of [['homeAssistantOn', 'on'], ['homeAssistantOff', 'off']]) {
|
||||
if (outputs.buttons[actionId] && handleButtonEdge(actionId, true)) {
|
||||
cycleHomeAssistant(latest, targetState);
|
||||
} else if (!outputs.buttons[actionId]) {
|
||||
handleButtonEdge(actionId, false);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
PTZ zoom consumes the live signed gamepad axis, including its zero
|
||||
position, so releasing the stick is an explicit stop instead of merely
|
||||
@@ -312,24 +555,40 @@ export default function GamepadInputManager() {
|
||||
here and continue through their established absolute/velocity mapping.
|
||||
*/
|
||||
const handledAsPtzZoom = latest.setCameraAxisIntent(outputs.cameraAxis);
|
||||
if (!handledAsPtzZoom && Math.abs(outputs.cameraAxis) > 0.001) {
|
||||
handleCameraAxis(outputs.cameraAxis, profile.calibration);
|
||||
/* 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 &&
|
||||
(cameraCalibration?.cameraMode === 'velocity' || Math.abs(outputs.cameraAxis) > 0.001)
|
||||
) {
|
||||
handleCameraAxis(outputs.cameraAxis, cameraCalibration);
|
||||
}
|
||||
|
||||
latest.registerInputState(SOURCE, {
|
||||
connected: true,
|
||||
signature,
|
||||
id: activePad.id,
|
||||
index: activePad.index,
|
||||
axes: activePad.axes,
|
||||
buttons: activePad.buttons,
|
||||
drive: outputs.driveVector,
|
||||
aux,
|
||||
cameraAxis: outputs.cameraAxis,
|
||||
bindings: outputs.sources,
|
||||
});
|
||||
/* Raw values remain in the dedicated hub used by diagnostics. The shared reducer only
|
||||
needs connection identity, which avoids forcing the entire provider through 60 updates/s. */
|
||||
if (lastRegisteredSignatureRef.current !== signature) {
|
||||
lastRegisteredSignatureRef.current = signature;
|
||||
latest.registerInputState(SOURCE, {
|
||||
connected: true,
|
||||
blocked: false,
|
||||
signature,
|
||||
id: activePad.id,
|
||||
index: activePad.index,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [ensureProfile, handleButtonEdge, handleCameraAxis]);
|
||||
return () => {
|
||||
unsubscribe();
|
||||
const latest = latestRef.current;
|
||||
if (latest) neutralizeController(latest);
|
||||
markControllerDisconnected(lastConnectedSignatureRef.current);
|
||||
};
|
||||
}, [ensureProfile, handleButtonEdge, handleCameraAxis, neutralizeController]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useChatActions, useChatFocus } from '../../context/ChatContext.jsx';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { normalizeKeymapEntries, tokensForEvent } from '../keymapUtils.js';
|
||||
import { isKeyboardCaptureLocked } from './keyboardCaptureLock.js';
|
||||
import { markKeyboardInputActive } from './controllerRuntime.js';
|
||||
import { isTextInputElement } from './inputFocusUtils.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { INPUT_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
@@ -428,6 +429,13 @@ export default function KeyboardInputManager() {
|
||||
const tokens = tokensForEvent(event);
|
||||
if (tokens.length === 0) return;
|
||||
const tokenSet = new Set(tokens);
|
||||
/*
|
||||
Shortcut prompts follow the last meaningful control device, not arbitrary typing. Only a
|
||||
key that participates in the configured control map claims keyboard modality.
|
||||
*/
|
||||
if (tokens.some((token) => latest.actionTokens.has(token))) {
|
||||
markKeyboardInputActive();
|
||||
}
|
||||
if (bindingActive(latest.keymap.chatFocus, tokenSet)) {
|
||||
event.preventDefault();
|
||||
resetAll();
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Controller Prompt Labels
|
||||
// Purpose: Converts persisted controller bindings into compact prompts for the connected hardware.
|
||||
// Scope: Delegates hardware identification and standard button naming to gamepad-helper while
|
||||
// keeping rover action aliases and compact presentation local to the controller input layer.
|
||||
import GamepadHelper from '@lizardbyte/gamepad-helper/src/js/gamepad-helper.js';
|
||||
|
||||
const gamepadHelper = new GamepadHelper();
|
||||
|
||||
const ACTION_ALIASES = {
|
||||
driveForward: { bindingId: 'drive', direction: 'up' },
|
||||
driveBackward: { bindingId: 'drive', direction: 'down' },
|
||||
driveLeft: { bindingId: 'drive', direction: 'left' },
|
||||
driveRight: { bindingId: 'drive', direction: 'right' },
|
||||
cameraUp: { bindingId: 'cameraTilt', direction: 'up' },
|
||||
cameraDown: { bindingId: 'cameraTilt', direction: 'down' },
|
||||
auxMainForward: { bindingId: 'mainBrush', direction: 'forward' },
|
||||
auxMainReverse: { bindingId: 'mainBrush', direction: 'reverse' },
|
||||
auxSideForward: { bindingId: 'sideBrush', direction: 'forward' },
|
||||
auxSideReverse: { bindingId: 'sideBrush', direction: 'reverse' },
|
||||
auxVacuumFast: { bindingId: 'vacuum' },
|
||||
auxVacuumSlow: { bindingId: 'vacuum' },
|
||||
auxAllForward: { bindingId: 'allAux' },
|
||||
};
|
||||
|
||||
const DIRECTION_GLYPHS = {
|
||||
up: '↑',
|
||||
down: '↓',
|
||||
left: '←',
|
||||
right: '→',
|
||||
forward: '+',
|
||||
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↓',
|
||||
DLeft: 'D←',
|
||||
DRight: 'D→',
|
||||
TouchPad: 'Touchpad',
|
||||
};
|
||||
|
||||
function controllerType(controller, promptStyle) {
|
||||
/* Manual prompt selection is a direct library controller type, not a model-name imitation.
|
||||
Automatic mode gives the complete browser ID to gamepad-helper unchanged; in particular,
|
||||
its vendor/product lookup directly recognizes Linux's 054c-0ce6 DualSense identifier. */
|
||||
if (promptStyle && promptStyle !== 'auto') return promptStyle;
|
||||
return gamepadHelper.detectControllerType(controller?.id ?? '');
|
||||
}
|
||||
|
||||
export function describeController(controller) {
|
||||
const info = gamepadHelper.getGamepadInfo(controller?.id ?? '');
|
||||
return {
|
||||
model: info.type,
|
||||
brand: info.type === gamepadHelper.CONTROLLER_TYPES.PLAYSTATION ? 'Sony' : null,
|
||||
description: info.name,
|
||||
};
|
||||
}
|
||||
|
||||
function compactButtonName(source, type) {
|
||||
const name = gamepadHelper.getButtonName(type, source.index);
|
||||
return COMPACT_BUTTON_NAMES[name] ?? name;
|
||||
}
|
||||
|
||||
function compactAxisName(source) {
|
||||
/* Standard browser mappings place sticks in adjacent pairs. Showing the stick instead of its
|
||||
raw component keeps prompts short; the action and optional arrow already convey the axis. */
|
||||
if (source.index === 0 || source.index === 1) return 'LS';
|
||||
if (source.index === 2 || source.index === 3) return 'RS';
|
||||
return `A${source.index}`;
|
||||
}
|
||||
|
||||
function compactAxisPairName(source) {
|
||||
if (source.x === 0 && source.y === 1) return 'LS';
|
||||
if (source.x === 2 && source.y === 3) return 'RS';
|
||||
return `A${source.x}/${source.y}`;
|
||||
}
|
||||
|
||||
function compactSourceName(source, type) {
|
||||
if (!source) return '—';
|
||||
if (source.kind === 'chord') {
|
||||
return (source.inputs ?? []).map((input) => compactSourceName(input, type)).join('+');
|
||||
}
|
||||
if (source.kind === 'axisPair') return compactAxisPairName(source);
|
||||
if (source.kind === 'button' || source.kind === 'buttonAxis') {
|
||||
return compactButtonName(source, type);
|
||||
}
|
||||
if (source.kind === 'axis' || source.kind === 'axisButton') {
|
||||
return compactAxisName(source);
|
||||
}
|
||||
return '—';
|
||||
}
|
||||
|
||||
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 {
|
||||
binding: profile?.bindings?.[alias.bindingId] ?? null,
|
||||
direction: alias.direction ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
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 '—';
|
||||
const type = controllerType(controller, profile?.promptStyle);
|
||||
const label = compactSourceName(source, type);
|
||||
const directionLabel = direction ? DIRECTION_GLYPHS[direction] : null;
|
||||
return directionLabel ? `${label} ${directionLabel}` : label;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Controller Prompt Label Tests
|
||||
// Purpose: Protect the adapter between persisted rover actions and the third-party controller
|
||||
// model database, including manual prompt families and directional fallback aliases.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { GAMEPAD_PROFILE_DEFAULT } from '../../settings/namespaces.js';
|
||||
import { describeController, formatControllerBinding } from './controllerLabels.js';
|
||||
|
||||
test('uses the manually selected PlayStation button family', () => {
|
||||
const profile = { ...GAMEPAD_PROFILE_DEFAULT, promptStyle: 'playstation' };
|
||||
const label = formatControllerBinding(profile, 'vacuum', {
|
||||
id: 'Controller hidden by browser privacy mode',
|
||||
mapping: 'standard',
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const label = formatControllerBinding(GAMEPAD_PROFILE_DEFAULT, 'driveForward', {
|
||||
id: 'Xbox Wireless Controller',
|
||||
mapping: 'standard',
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const profile = {
|
||||
...GAMEPAD_PROFILE_DEFAULT,
|
||||
bindings: {
|
||||
...GAMEPAD_PROFILE_DEFAULT.bindings,
|
||||
auxMainReverse: { kind: 'button', sources: [{ kind: 'button', index: 15 }] },
|
||||
},
|
||||
};
|
||||
const label = formatControllerBinding(profile, 'auxMainReverse', {
|
||||
id: 'Xbox Wireless Controller',
|
||||
mapping: 'standard',
|
||||
});
|
||||
|
||||
assert.equal(label, 'D→');
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
// Controller Runtime Coordination
|
||||
// Purpose: Shares controller-only runtime facts without pushing animation-frame data through
|
||||
// React's application-wide control reducer.
|
||||
// Scope: Owns prompt modality, the last controller used, and temporary command suppression while
|
||||
// a controller is being configured. It does not send rover commands or interpret bindings.
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
const listeners = new Set();
|
||||
const controlLocks = new Set();
|
||||
|
||||
let snapshot = {
|
||||
inputMethod: 'keyboard',
|
||||
controller: null,
|
||||
};
|
||||
|
||||
function publish(nextSnapshot) {
|
||||
if (
|
||||
nextSnapshot.inputMethod === snapshot.inputMethod &&
|
||||
nextSnapshot.controller?.signature === snapshot.controller?.signature &&
|
||||
nextSnapshot.controller?.id === snapshot.controller?.id &&
|
||||
nextSnapshot.controller?.mapping === snapshot.controller?.mapping
|
||||
) {
|
||||
return;
|
||||
}
|
||||
snapshot = nextSnapshot;
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export function markKeyboardInputActive() {
|
||||
publish({ ...snapshot, inputMethod: 'keyboard' });
|
||||
}
|
||||
|
||||
export function markControllerInputActive(controller) {
|
||||
if (!controller) return;
|
||||
publish({
|
||||
inputMethod: 'controller',
|
||||
controller: {
|
||||
signature: controller.signature ?? null,
|
||||
id: controller.id ?? 'Unknown controller',
|
||||
mapping: controller.mapping ?? '',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function markControllerDisconnected(signature) {
|
||||
if (!snapshot.controller || snapshot.controller.signature !== signature) return;
|
||||
publish({ inputMethod: 'keyboard', controller: null });
|
||||
}
|
||||
|
||||
export function acquireControllerControlLock(reason = 'controller-configuration') {
|
||||
/*
|
||||
A tokenized lock is used instead of one boolean because a capture dialog and its parent
|
||||
settings surface can overlap during React cleanup. Releasing either owner must not briefly
|
||||
re-enable commands while the other owner still expects input to be diagnostic-only.
|
||||
*/
|
||||
const token = Symbol(reason);
|
||||
controlLocks.add(token);
|
||||
return () => controlLocks.delete(token);
|
||||
}
|
||||
|
||||
export function isControllerControlLocked() {
|
||||
return controlLocks.size > 0;
|
||||
}
|
||||
|
||||
export function subscribeControllerRuntime(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function getControllerRuntimeSnapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function useControllerRuntime() {
|
||||
return useSyncExternalStore(
|
||||
subscribeControllerRuntime,
|
||||
getControllerRuntimeSnapshot,
|
||||
getControllerRuntimeSnapshot,
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,42 @@
|
||||
// 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
|
||||
prompts all operate on the same complete action set. Adding an action here is intentionally
|
||||
controller-local and does not add controller concepts to the shared command pipeline.
|
||||
*/
|
||||
export const GAMEPAD_BUTTON_ACTION_IDS = [
|
||||
'tankCameraUp',
|
||||
'tankCameraDown',
|
||||
'vacuum',
|
||||
'allAux',
|
||||
'mainReverse',
|
||||
'sideReverse',
|
||||
'driveMacro',
|
||||
'dockMacro',
|
||||
'headlightToggle',
|
||||
'laserToggle',
|
||||
'boostModifier',
|
||||
'slowModifier',
|
||||
'hornHonk',
|
||||
'micPtt',
|
||||
'videoFilterCycle',
|
||||
'chatFocus',
|
||||
'songNoteUp',
|
||||
'songNoteDown',
|
||||
'homeAssistantOn',
|
||||
'homeAssistantOff',
|
||||
'auxMainForward',
|
||||
'auxMainReverse',
|
||||
'auxSideForward',
|
||||
'auxSideReverse',
|
||||
'auxVacuumFast',
|
||||
'auxVacuumSlow',
|
||||
'auxAllForward',
|
||||
];
|
||||
|
||||
export function getPadSignature(pad) {
|
||||
if (!pad) return 'unknown::none::0::0';
|
||||
@@ -26,6 +62,57 @@ export function createProfileForPad(pad, baseProfile) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function resolveGamepadProfile(profile, defaults) {
|
||||
/*
|
||||
Profiles are persisted independently per controller. Merge at the binding and calibration
|
||||
levels so adding a newly supported logical action immediately gives existing controllers a
|
||||
usable default without overwriting any binding the user deliberately customized.
|
||||
*/
|
||||
const base = defaults ?? {};
|
||||
const current = profile ?? {};
|
||||
const requiresBehaviorUpgrade = current.behaviorVersion !== base.behaviorVersion;
|
||||
return {
|
||||
...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 ?? {}),
|
||||
/* 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,
|
||||
baseSpeed: base.calibration?.baseSpeed,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
bindings: {
|
||||
/* 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
|
||||
? cloneProfile(base.bindings ?? {})
|
||||
: { ...(base.bindings ?? {}), ...(current.bindings ?? {}) }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function advanceCameraAngle(currentAngle, axisValue, sensitivity, elapsedMs, limits) {
|
||||
/* Velocity camera state must never accumulate beyond the physical servo limits. Otherwise a
|
||||
long hold at an endpoint creates an invisible overshoot that has to unwind before reversing. */
|
||||
const min = Number.isFinite(limits?.min) ? limits.min : -45;
|
||||
const max = Number.isFinite(limits?.max) ? limits.max : 45;
|
||||
const baseline = Number.isFinite(currentAngle) ? currentAngle : (min + max) / 2;
|
||||
const safeElapsedMs = Math.max(0, Math.min(50, Number(elapsedMs) || 0));
|
||||
const degreesPerSecond = Math.max(1, Math.min(180, Number(sensitivity) || 60));
|
||||
const candidate = baseline + axisValue * degreesPerSecond * (safeElapsedMs / 1000);
|
||||
return Math.max(min, Math.min(max, candidate));
|
||||
}
|
||||
|
||||
function clampUnit(value) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(-1, Math.min(1, value));
|
||||
@@ -104,47 +191,103 @@ function resolveAxisPairSource(padState, sources = []) {
|
||||
}
|
||||
|
||||
function resolveButtonSource(padState, sources = []) {
|
||||
let firstReadableSource = null;
|
||||
for (const source of sources) {
|
||||
if (!source) continue;
|
||||
if (source.kind === 'chord') {
|
||||
const inputs = Array.isArray(source.inputs) ? source.inputs : [];
|
||||
if (inputs.length === 0) continue;
|
||||
const pressed = inputs.every((input) => resolveButtonSource(padState, [input]).pressed);
|
||||
if (pressed) return { pressed: true, source };
|
||||
firstReadableSource ??= source;
|
||||
continue;
|
||||
}
|
||||
if (source.kind === 'button') {
|
||||
const btn = readButton(padState, source.index);
|
||||
if (!btn) continue;
|
||||
return { pressed: btn.pressed, source };
|
||||
if (btn.pressed) return { pressed: true, source };
|
||||
firstReadableSource ??= source;
|
||||
continue;
|
||||
}
|
||||
if (source.kind === 'axisButton') {
|
||||
const value = readAxis(padState, source.index);
|
||||
if (value === null) continue;
|
||||
const direction = source.direction || 1;
|
||||
const threshold = typeof source.threshold === 'number' ? source.threshold : 0.6;
|
||||
return { pressed: value * direction > threshold, source };
|
||||
if (value * direction > threshold) return { pressed: true, source };
|
||||
firstReadableSource ??= source;
|
||||
continue;
|
||||
}
|
||||
if (source.kind === 'buttonAxis') {
|
||||
const btn = readButton(padState, source.index);
|
||||
if (!btn) continue;
|
||||
return { pressed: btn.value > 0.5, source };
|
||||
if (btn.value > 0.5) return { pressed: true, source };
|
||||
firstReadableSource ??= source;
|
||||
}
|
||||
}
|
||||
return { pressed: false, source: null };
|
||||
return { pressed: false, source: firstReadableSource };
|
||||
}
|
||||
|
||||
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 ?? {};
|
||||
@@ -157,42 +300,50 @@ export function computeGamepadOutputs(padState, profile) {
|
||||
let sideAxis = applyAxisDeadzone(clampUnit(sideSource.value), auxDeadzone);
|
||||
sideAxis = applyCurve(sideAxis, calibration.auxCurve);
|
||||
|
||||
const vacuumSource = resolveButtonSource(padState, bindings.vacuum?.sources);
|
||||
const allAuxSource = resolveButtonSource(padState, bindings.allAux?.sources);
|
||||
const mainReverseSource = resolveButtonSource(padState, bindings.mainReverse?.sources);
|
||||
const sideReverseSource = resolveButtonSource(padState, bindings.sideReverse?.sources);
|
||||
const driveMacroSource = resolveButtonSource(padState, bindings.driveMacro?.sources);
|
||||
const dockMacroSource = resolveButtonSource(padState, bindings.dockMacro?.sources);
|
||||
const headlightSource = resolveButtonSource(padState, bindings.headlightToggle?.sources);
|
||||
const laserSource = resolveButtonSource(padState, bindings.laserToggle?.sources);
|
||||
const buttonOutputs = Object.fromEntries(
|
||||
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: {
|
||||
vacuum: vacuumSource.pressed,
|
||||
allAux: allAuxSource.pressed,
|
||||
mainReverse: mainReverseSource.pressed,
|
||||
sideReverse: sideReverseSource.pressed,
|
||||
driveMacro: driveMacroSource.pressed,
|
||||
dockMacro: dockMacroSource.pressed,
|
||||
headlightToggle: headlightSource.pressed,
|
||||
laserToggle: laserSource.pressed,
|
||||
},
|
||||
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,
|
||||
vacuum: vacuumSource.source,
|
||||
allAux: allAuxSource.source,
|
||||
mainReverse: mainReverseSource.source,
|
||||
sideReverse: sideReverseSource.source,
|
||||
driveMacro: driveMacroSource.source,
|
||||
dockMacro: dockMacroSource.source,
|
||||
headlightToggle: headlightSource.source,
|
||||
laserToggle: laserSource.source,
|
||||
...Object.fromEntries(
|
||||
Object.entries(buttonOutputs).map(([actionId, output]) => [actionId, output.source]),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
// Gamepad Binding Tests
|
||||
// Purpose: Locks down the safety-critical conversion from browser values to logical actions.
|
||||
// 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 {
|
||||
advanceCameraAngle,
|
||||
computeGamepadOutputs,
|
||||
resolveGamepadProfile,
|
||||
} from './gamepadBindings.js';
|
||||
import { GAMEPAD_PROFILE_DEFAULT } from '../../settings/namespaces.js';
|
||||
|
||||
function pad({ axes = [0, 0, 0, 0], pressed = [], values = {} } = {}) {
|
||||
return {
|
||||
axes,
|
||||
buttons: Array.from({ length: 18 }, (_, index) => ({
|
||||
pressed: pressed.includes(index),
|
||||
value: values[index] ?? (pressed.includes(index) ? 1 : 0),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
test('radial drive deadzone removes drift and rescales real movement', () => {
|
||||
const idle = computeGamepadOutputs(pad({ axes: [0.1, -0.1, 0, 0] }), GAMEPAD_PROFILE_DEFAULT);
|
||||
assert.deepEqual(idle.driveVector, { x: 0, y: 0, boost: false });
|
||||
|
||||
const moving = computeGamepadOutputs(pad({ axes: [0, -0.59, 0, 0] }), GAMEPAD_PROFILE_DEFAULT);
|
||||
assert.equal(moving.driveVector.x, 0);
|
||||
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',
|
||||
sources: [{
|
||||
kind: 'chord',
|
||||
inputs: [{ kind: 'button', index: 4 }, { kind: 'button', index: 0 }],
|
||||
}],
|
||||
},
|
||||
},
|
||||
}, GAMEPAD_PROFILE_DEFAULT);
|
||||
|
||||
assert.equal(computeGamepadOutputs(pad({ pressed: [4] }), profile).buttons.hornHonk, false);
|
||||
assert.equal(computeGamepadOutputs(pad({ pressed: [4, 0] }), profile).buttons.hornHonk, true);
|
||||
});
|
||||
|
||||
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',
|
||||
sources: [{ kind: 'button', index: 2 }, { kind: 'button', index: 7 }],
|
||||
},
|
||||
},
|
||||
}, GAMEPAD_PROFILE_DEFAULT);
|
||||
|
||||
assert.equal(computeGamepadOutputs(pad({ pressed: [7] }), profile).buttons.laserToggle, true);
|
||||
});
|
||||
|
||||
test('profile resolution adds new actions without overwriting customized bindings', () => {
|
||||
const customDrive = {
|
||||
kind: 'axisPair',
|
||||
sources: [{ kind: 'axisPair', x: 2, y: 3, invertX: true, invertY: false }],
|
||||
};
|
||||
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);
|
||||
});
|
||||
@@ -5,9 +5,10 @@ import { getPadSignature } from './gamepadBindings.js';
|
||||
|
||||
const listeners = new Set();
|
||||
let rafId = null;
|
||||
let lastState = { pads: [], timestamp: 0 };
|
||||
let lastState = { pads: [], timestamp: 0, supported: true, error: null };
|
||||
let hasDeviceListeners = false;
|
||||
let deviceChangeHandler = null;
|
||||
let lastReadError = null;
|
||||
|
||||
function hasConnectedPads() {
|
||||
return readGamepads().some((pad) => pad?.connected !== false);
|
||||
@@ -15,14 +16,24 @@ function hasConnectedPads() {
|
||||
|
||||
function readGamepads() {
|
||||
if (typeof navigator === 'undefined' || !navigator.getGamepads) {
|
||||
lastReadError = new Error('This browser does not support the Gamepad API.');
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const pads = navigator.getGamepads();
|
||||
lastReadError = null;
|
||||
if (!pads) return [];
|
||||
return Array.from(pads).filter(Boolean);
|
||||
} catch (error) {
|
||||
/* Permissions Policy can make getGamepads throw instead of returning an empty list. Preserve
|
||||
that distinction so the setup UI can explain why reconnecting hardware will not help. */
|
||||
lastReadError = error instanceof Error ? error : new Error(String(error));
|
||||
return [];
|
||||
}
|
||||
const pads = navigator.getGamepads();
|
||||
if (!pads) return [];
|
||||
return Array.from(pads).filter(Boolean);
|
||||
}
|
||||
|
||||
function buildPadState(pad) {
|
||||
const signature = getPadSignature(pad);
|
||||
return {
|
||||
index: pad.index,
|
||||
id: pad.id,
|
||||
@@ -34,7 +45,8 @@ function buildPadState(pad) {
|
||||
pressed: Boolean(btn?.pressed),
|
||||
value: typeof btn?.value === 'number' ? btn.value : btn?.pressed ? 1 : 0,
|
||||
})),
|
||||
signature: getPadSignature(pad),
|
||||
signature,
|
||||
instanceKey: `${signature}::slot-${pad.index}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,6 +55,8 @@ function updateState() {
|
||||
lastState = {
|
||||
pads,
|
||||
timestamp: typeof performance !== 'undefined' ? performance.now() : Date.now(),
|
||||
supported: typeof navigator !== 'undefined' && typeof navigator.getGamepads === 'function',
|
||||
error: lastReadError?.message ?? null,
|
||||
};
|
||||
listeners.forEach((listener) => listener(lastState));
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useVisualTelemetrySelector } from '../../../context/TelemetryContext.js
|
||||
import { batteryTelemetryEqual, selectBatteryTelemetry } from '../../../context/telemetryViews.js';
|
||||
import BatteryBar from '../../../components/BatteryBar/index.jsx';
|
||||
import RoverLabel from '../../../components/RoverLabel/index.jsx';
|
||||
import AutoFitText from '../../../mini/MiniSummaryApp/components/AutoFitText.jsx';
|
||||
import AutoFitText from '../../../components/AutoFitText/index.jsx';
|
||||
import RoverHelpOverlay from '../../../components/RoverHelpOverlay/index.jsx';
|
||||
import {
|
||||
buildRoverStateText,
|
||||
findDriverForRover,
|
||||
@@ -38,6 +39,7 @@ export default function DisplayRoverCell({ rover, session }) {
|
||||
!locked && urgent ? 'ring-4 ring-red-500/90' : !locked && warn ? 'ring-2 ring-amber-300/80' : '',
|
||||
)}
|
||||
>
|
||||
<RoverHelpOverlay active={Boolean(rover?.needsHelp)} />
|
||||
<BatteryBar visual={visual} variant="background" orientation="vertical" />
|
||||
<div className="relative z-10 grid h-full min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] gap-[0.55vh] p-[0.85vw] text-center">
|
||||
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-[0.8vw]">
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useDriveDockState } from '../../../components/DriveDockAction/driveDock
|
||||
import { useControlActions, useControlSelector } from '../../../controls/index.js';
|
||||
import RoverQueuesPanel from '../../../components/RoverQueuesPanel/index.jsx';
|
||||
import RawUserPilePanel from '../../../components/RawUserPilePanel/index.jsx';
|
||||
import { formatKeyLabel } from '../../../controls/keymapUtils.js';
|
||||
import ControlHint from '../../../components/ControlHint/index.jsx';
|
||||
import GPIOToggleControl from '../../../components/GPIOToggleControl/index.jsx';
|
||||
import HornControl from '../../../components/HornControl/index.jsx';
|
||||
import CameraTiltControl from '../../../components/CameraTiltControl/index.jsx';
|
||||
@@ -54,7 +54,6 @@ function TopDownMapPanel() {
|
||||
function DriveDockPanel() {
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
const roomLightsLockedOn = useSessionSelector((state) => Boolean(state.session?.homeAssistant?.lightPolicy?.lockedOn));
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const camera = useControlSelector((control) => control.state.camera);
|
||||
const horn = useControlSelector((control) => control.state.horn);
|
||||
const headlight = useControlSelector((control) => control.pipeline?.headlight);
|
||||
@@ -81,11 +80,11 @@ function DriveDockPanel() {
|
||||
: typeof config?.homeAngle === 'number'
|
||||
? config.homeAngle
|
||||
: (min + max) / 2;
|
||||
const headlightLabel = formatKeyLabel(keymap?.headlightToggle?.[0]);
|
||||
const laserLabel = formatKeyLabel(keymap?.laserToggle?.[0]);
|
||||
const hornLabel = formatKeyLabel(keymap?.hornHonk?.[0]);
|
||||
const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]);
|
||||
const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]);
|
||||
const headlightLabel = <ControlHint actionId="headlightToggle" />;
|
||||
const laserLabel = <ControlHint actionId="laserToggle" />;
|
||||
const hornLabel = <ControlHint actionId="hornHonk" />;
|
||||
const upLabel = <ControlHint actionId="cameraUp" />;
|
||||
const downLabel = <ControlHint actionId="cameraDown" />;
|
||||
const cameraDisabled = Boolean(!roverId || dockAssist.cameraLocked);
|
||||
/*
|
||||
Precision movement mode also tightens the servo slider step. The command
|
||||
|
||||
@@ -11,6 +11,7 @@ import useDefaultNickname from '../../hooks/useDefaultNickname.js';
|
||||
import useUserIdentitySync from '../../hooks/useUserIdentitySync.js';
|
||||
import PtzLiveVideo from '../../components/PtzLiveVideo/index.jsx';
|
||||
import RoverMediaPlayer from '../../components/RoverMediaPlayer/index.jsx';
|
||||
import RoverHelpOverlay from '../../components/RoverHelpOverlay/index.jsx';
|
||||
import FitViewportFrame from './components/FitViewportFrame.jsx';
|
||||
import InfoColumn from './components/InfoColumn.jsx';
|
||||
import { ROTATE_MS } from './constants.js';
|
||||
@@ -352,6 +353,7 @@ export default function MiniSummaryContent() {
|
||||
/>
|
||||
)}
|
||||
</FitViewportFrame>
|
||||
<RoverHelpOverlay active={Boolean(rover.needsHelp)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
// Auto Fit Text
|
||||
// Purpose: Defines the Auto Fit Text module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
export default function AutoFitText({ children, className = '', maxSize = 1000, minSize = 14, style = undefined }) {
|
||||
const containerRef = useRef(null);
|
||||
const textRef = useRef(null);
|
||||
const [fontSize, setFontSize] = useState(maxSize);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const textEl = textRef.current;
|
||||
if (!container || !textEl) return undefined;
|
||||
|
||||
let raf = null;
|
||||
const fit = () => {
|
||||
const width = container.clientWidth;
|
||||
if (!width) {
|
||||
scheduleFit();
|
||||
return;
|
||||
}
|
||||
let low = minSize;
|
||||
let high = maxSize;
|
||||
let best = minSize;
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
textEl.style.fontSize = `${mid}px`;
|
||||
const fits = textEl.scrollWidth <= width;
|
||||
if (fits) {
|
||||
best = mid;
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
setFontSize(best);
|
||||
};
|
||||
|
||||
const scheduleFit = () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
raf = requestAnimationFrame(fit);
|
||||
};
|
||||
|
||||
scheduleFit();
|
||||
const ro = new ResizeObserver(scheduleFit);
|
||||
ro.observe(container);
|
||||
return () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [children, maxSize, minSize]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full min-w-0">
|
||||
<div
|
||||
ref={textRef}
|
||||
className={`whitespace-nowrap ${className}`}
|
||||
style={{ fontSize: `${fontSize}px`, lineHeight: 1.1, ...(style || {}) }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,9 @@
|
||||
import RoverMediaPlayer from '../../../components/RoverMediaPlayer/index.jsx';
|
||||
import BatteryBar from '../../../components/BatteryBar/index.jsx';
|
||||
import RoverLabel from '../../../components/RoverLabel/index.jsx';
|
||||
import RoverHelpOverlay from '../../../components/RoverHelpOverlay/index.jsx';
|
||||
import AutoFitText from '../../../components/AutoFitText/index.jsx';
|
||||
import { getBatteryVisual } from '../utils.js';
|
||||
import AutoFitText from './AutoFitText.jsx';
|
||||
|
||||
export default function InfoColumn({
|
||||
rover,
|
||||
@@ -32,6 +33,10 @@ export default function InfoColumn({
|
||||
orientation={isActiveView ? 'vertical' : 'horizontal'}
|
||||
variant="background"
|
||||
/>
|
||||
{/* In the side-by-side roster this column is the rover's complete tile.
|
||||
The active carousel already overlays its video pane, so suppressing a
|
||||
second copy here avoids flashing HELP twice for the same rover. */}
|
||||
<RoverHelpOverlay active={!isActiveView && Boolean(rover?.needsHelp)} />
|
||||
{isActiveView ? (
|
||||
<div className="relative z-10 flex min-w-0 flex-1 flex-col justify-between text-center">
|
||||
<div className="min-w-0 bg-transparent px-0 py-0 leading-none">
|
||||
|
||||
@@ -11,63 +11,84 @@ export const INPUT_SETTINGS_DEFAULTS = {
|
||||
};
|
||||
|
||||
export const GAMEPAD_PROFILE_DEFAULT = {
|
||||
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,
|
||||
driveCurve: 'linear',
|
||||
cameraCurve: 'linear',
|
||||
auxCurve: 'linear',
|
||||
cameraMode: 'absolute',
|
||||
cameraMode: 'velocity',
|
||||
cameraSensitivity: 60,
|
||||
auxSideScale: 0.55,
|
||||
baseSpeed: 500,
|
||||
turboSpeed: 500,
|
||||
precisionSpeed: 100,
|
||||
},
|
||||
bindings: {
|
||||
drive: {
|
||||
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 },
|
||||
{ kind: 'axis', index: 1, invert: true },
|
||||
],
|
||||
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 },
|
||||
{ kind: 'axis', index: 2, invert: false },
|
||||
],
|
||||
sources: [{ kind: 'buttonAxis', index: 6 }],
|
||||
},
|
||||
sideBrush: {
|
||||
kind: 'axis',
|
||||
sources: [
|
||||
{ kind: 'buttonAxis', index: 7 },
|
||||
{ kind: 'axis', index: 5, invert: false },
|
||||
],
|
||||
sources: [{ kind: 'buttonAxis', index: 7 }],
|
||||
},
|
||||
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',
|
||||
@@ -75,17 +96,71 @@ export const GAMEPAD_PROFILE_DEFAULT = {
|
||||
},
|
||||
headlightToggle: {
|
||||
kind: 'button',
|
||||
sources: [{ kind: 'button', index: 9 }],
|
||||
sources: [{ kind: 'button', index: 4 }],
|
||||
},
|
||||
laserToggle: {
|
||||
kind: 'button',
|
||||
sources: [{ kind: 'button', index: 5 }],
|
||||
},
|
||||
boostModifier: {
|
||||
kind: 'button',
|
||||
// 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: 10 }],
|
||||
},
|
||||
hornHonk: {
|
||||
kind: 'button',
|
||||
sources: [{ kind: 'button', index: 2 }],
|
||||
},
|
||||
micPtt: {
|
||||
kind: 'button',
|
||||
sources: [],
|
||||
},
|
||||
videoFilterCycle: {
|
||||
kind: 'button',
|
||||
sources: [],
|
||||
},
|
||||
chatFocus: {
|
||||
kind: 'button',
|
||||
sources: [],
|
||||
},
|
||||
songNoteUp: {
|
||||
kind: 'button',
|
||||
sources: [{ kind: 'button', index: 12 }],
|
||||
},
|
||||
songNoteDown: {
|
||||
kind: 'button',
|
||||
sources: [{ kind: 'button', index: 13 }],
|
||||
},
|
||||
homeAssistantOn: {
|
||||
kind: 'button',
|
||||
sources: [{ kind: 'button', index: 15 }],
|
||||
},
|
||||
homeAssistantOff: {
|
||||
kind: 'button',
|
||||
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
|
||||
can bind either style without the shared control system knowing which device produced it. */
|
||||
auxMainForward: { kind: 'button', sources: [] },
|
||||
auxMainReverse: { kind: 'button', sources: [] },
|
||||
auxSideForward: { kind: 'button', sources: [] },
|
||||
auxSideReverse: { kind: 'button', sources: [] },
|
||||
auxVacuumFast: { kind: 'button', sources: [] },
|
||||
auxVacuumSlow: { kind: 'button', sources: [] },
|
||||
auxAllForward: { kind: 'button', sources: [] },
|
||||
},
|
||||
};
|
||||
|
||||
export const GAMEPAD_SETTINGS_DEFAULTS = {
|
||||
activeSignature: null,
|
||||
// Runtime instance selection includes the browser slot so two identical controllers remain
|
||||
// distinguishable, while profiles below stay keyed by reusable hardware signature.
|
||||
activeInstanceKey: null,
|
||||
profiles: {},
|
||||
defaults: {
|
||||
profile: GAMEPAD_PROFILE_DEFAULT,
|
||||
|
||||
@@ -10,6 +10,7 @@ export default function RoverSpectatorCard({ rover }) {
|
||||
<SpectateVideo
|
||||
roverId={rover.id}
|
||||
label={rover.name}
|
||||
needsHelp={Boolean(rover.needsHelp)}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
Reference in New Issue
Block a user