mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
Compare commits
14
Commits
9ca039229a
..
HELP
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b99590b3b | ||
|
|
5acbf6e0bf | ||
|
|
3ec45de4b4 | ||
|
|
3b7b2ac21e | ||
|
|
02a32e2524 | ||
|
|
eb1fab50e4 | ||
|
|
f85e258c29 | ||
|
|
72b8db8a31 | ||
|
|
fb31ff52bd | ||
|
|
99d2a7689f | ||
|
|
124369dfd7 | ||
|
|
4e24b5437d | ||
|
|
a3c13f3dd3 | ||
|
|
b0389b5ddc |
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -12,6 +12,9 @@ require('./src/services/eventBus');
|
|||||||
require('./src/services/modeManager');
|
require('./src/services/modeManager');
|
||||||
require('./src/services/lockdownGuard');
|
require('./src/services/lockdownGuard');
|
||||||
require('./src/services/roverManager');
|
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/commandService');
|
||||||
require('./src/services/roverConnectionService');
|
require('./src/services/roverConnectionService');
|
||||||
require('./src/services/assignmentService');
|
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
@@ -12,8 +12,8 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<!-- site-metadata:inject -->
|
<!-- site-metadata:inject -->
|
||||||
<!-- analytics:inject -->
|
<!-- analytics:inject -->
|
||||||
<script type="module" crossorigin src="/assets/index-DsdqxXyd.js"></script>
|
<script type="module" crossorigin src="/assets/index-C2LCvBX6.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-0J6fxLEg.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CqaMtmWx.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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 }) => {
|
roverManager.managerEvents.on('rover', ({ roverId, action }) => {
|
||||||
if (action === 'removed') {
|
if (action === 'removed') {
|
||||||
/*
|
/*
|
||||||
@@ -238,7 +244,10 @@ function pickRover(socket, options = {}) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const allCandidates = Array.from(roverManager.rovers.values()).filter((rover) => {
|
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 });
|
const access = roverManager.canRequestControl(rover.id, socket, { allowUser: true });
|
||||||
if (!access.ok) return false;
|
if (!access.ok) return false;
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const { buildBatteryStatusEmbed, buildBatteryCaption } = require('../batteryEmbe
|
|||||||
|
|
||||||
function createBusEventHandler(deps) {
|
function createBusEventHandler(deps) {
|
||||||
const { logger, discordConfig, roverManager, rovers, schedulePresenceRotation, formatDuration, sendToChannel } = 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;
|
let skippedFirstModeAnnouncement = false;
|
||||||
|
|
||||||
function buildEmbed({ title, description, color, includeSiteUrl = true }) {
|
function buildEmbed({ title, description, color, includeSiteUrl = true }) {
|
||||||
@@ -85,6 +85,23 @@ function createBusEventHandler(deps) {
|
|||||||
case 'rover.dockGuard':
|
case 'rover.dockGuard':
|
||||||
announce({ channelId: channels.adminAlerts, color: 0xf0b651, title: 'Dock Guard Triggered', description: `${payload?.roverId} (${payload?.reasonText || 'undocked'}) for ${formatDuration(payload?.idleMs)}.` });
|
announce({ channelId: channels.adminAlerts, color: 0xf0b651, title: 'Dock Guard Triggered', description: `${payload?.roverId} (${payload?.reasonText || 'undocked'}) for ${formatDuration(payload?.idleMs)}.` });
|
||||||
break;
|
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':
|
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()) })] });
|
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;
|
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,
|
getRoster,
|
||||||
getRosterForSocket,
|
getRosterForSocket,
|
||||||
broadcastRoster,
|
broadcastRoster,
|
||||||
|
setNeedsHelp,
|
||||||
setToggleState,
|
setToggleState,
|
||||||
handleHostStats,
|
handleHostStats,
|
||||||
canSeeRover,
|
canSeeRover,
|
||||||
@@ -281,6 +282,7 @@ module.exports = {
|
|||||||
getRoster,
|
getRoster,
|
||||||
getRosterForSocket,
|
getRosterForSocket,
|
||||||
broadcastRoster,
|
broadcastRoster,
|
||||||
|
setNeedsHelp,
|
||||||
setToggleState,
|
setToggleState,
|
||||||
handleHostStats,
|
handleHostStats,
|
||||||
handleSensorFrame,
|
handleSensorFrame,
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ function createRosterLifecycle(deps) {
|
|||||||
room: `rover:${id}`,
|
room: `rover:${id}`,
|
||||||
lastSeen: Date.now(),
|
lastSeen: Date.now(),
|
||||||
lastMovementAt: 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 },
|
private: { enabled: false },
|
||||||
privateOpen: true,
|
privateOpen: true,
|
||||||
privateSafety: { ...DEFAULT_PRIVATE_SAFETY },
|
privateSafety: { ...DEFAULT_PRIVATE_SAFETY },
|
||||||
@@ -252,6 +256,7 @@ function createRosterLifecycle(deps) {
|
|||||||
: record.meta?.laser,
|
: record.meta?.laser,
|
||||||
locked: record.locked || (isPrivateRecord(record) && !isPrivateOpen(record)),
|
locked: record.locked || (isPrivateRecord(record) && !isPrivateOpen(record)),
|
||||||
lockReason: record.lockReason || (isPrivateRecord(record) && !isPrivateOpen(record) ? 'private' : null),
|
lockReason: record.lockReason || (isPrivateRecord(record) && !isPrivateOpen(record) ? 'private' : null),
|
||||||
|
needsHelp: Boolean(record.needsHelp),
|
||||||
lastSeen: record.lastSeen,
|
lastSeen: record.lastSeen,
|
||||||
private: isPrivateRecord(record)
|
private: isPrivateRecord(record)
|
||||||
? { enabled: true, open: isPrivateOpen(record), safety: getPrivateSafety(record) }
|
? { enabled: true, open: isPrivateOpen(record), safety: getPrivateSafety(record) }
|
||||||
@@ -299,6 +304,21 @@ function createRosterLifecycle(deps) {
|
|||||||
managerEvents.emit('rover', { roverId, action: device, record });
|
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 = {}) {
|
function handleHostStats(roverId, msg = {}) {
|
||||||
const record = rovers.get(roverId);
|
const record = rovers.get(roverId);
|
||||||
if (!record) return;
|
if (!record) return;
|
||||||
@@ -345,6 +365,7 @@ function createRosterLifecycle(deps) {
|
|||||||
getRosterForSocket,
|
getRosterForSocket,
|
||||||
syncSpectatorRooms,
|
syncSpectatorRooms,
|
||||||
broadcastRoster,
|
broadcastRoster,
|
||||||
|
setNeedsHelp,
|
||||||
setToggleState,
|
setToggleState,
|
||||||
handleHostStats,
|
handleHostStats,
|
||||||
canSeeRover,
|
canSeeRover,
|
||||||
|
|||||||
@@ -329,6 +329,7 @@ function createSensorPipeline(deps) {
|
|||||||
function stopDockGuard(roverId) {
|
function stopDockGuard(roverId) {
|
||||||
const state = dockGuardStates.get(roverId);
|
const state = dockGuardStates.get(roverId);
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
|
const wasActive = state.active;
|
||||||
if (state.timer) clearInterval(state.timer);
|
if (state.timer) clearInterval(state.timer);
|
||||||
state.active = false;
|
state.active = false;
|
||||||
state.reason = null;
|
state.reason = null;
|
||||||
@@ -336,6 +337,11 @@ function createSensorPipeline(deps) {
|
|||||||
state.timer = null;
|
state.timer = null;
|
||||||
state.idleUndockedSince = null;
|
state.idleUndockedSince = null;
|
||||||
state.passiveUndockedSince = 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) {
|
function attemptDockGuard(roverId) {
|
||||||
@@ -360,6 +366,12 @@ function createSensorPipeline(deps) {
|
|||||||
state.active = true;
|
state.active = true;
|
||||||
state.reason = reason;
|
state.reason = reason;
|
||||||
state.startedAt = Date.now();
|
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';
|
const reasonText = reason === 'passive' ? 'passive mode' : 'idle and undocked';
|
||||||
sendAlert({
|
sendAlert({
|
||||||
color: ALERT_COLOR,
|
color: ALERT_COLOR,
|
||||||
|
|||||||
@@ -355,6 +355,13 @@ managerEvents.on('privateSafety', ({ roverId }) => {
|
|||||||
syncAll();
|
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 = {}) => {
|
privateRoverAccessRequestEvents.on('change', (event = {}) => {
|
||||||
logger.info('Private rover access request state changed', event.reason || 'unknown');
|
logger.info('Private rover access request state changed', event.reason || 'unknown');
|
||||||
syncAll();
|
syncAll();
|
||||||
|
|||||||
@@ -3,19 +3,12 @@
|
|||||||
research how midis can be played easier with drag and drop, auto selection based on which playback mode would fit, more useful and clear toggles, sample midi files to analyze how they would play on a single tone system, better live playback to see what notes are actually playing from each source and what simply wont play on the speaker
|
research how midis can be played easier with drag and drop, auto selection based on which playback mode would fit, more useful and clear toggles, sample midi files to analyze how they would play on a single tone system, better live playback to see what notes are actually playing from each source and what simply wont play on the speaker
|
||||||
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
|
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. assign rovers based on battery percentage, give people highest one
|
1. setting to disable replay popups in spectator settings menu
|
||||||
2. 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
|
3. add admin ui for VIP and private requests instead of only through discord
|
||||||
4. add flag in roverd for video aspect ratio
|
4. automated rover needs help system
|
||||||
1. maybe dont? whats the point anyway? why do we exist at all? is there purpose to life?
|
5. make roverd self update checkout to main branch
|
||||||
1. just removing the black bars, doesnt do anything practical for the driver page
|
6. fix this:
|
||||||
2. would only actually help for keeping spectate page compact
|
|
||||||
1. maybe just make the spectate videos be fixed width and match the height of the media
|
|
||||||
2. either 4:3 or 16:9
|
|
||||||
3. default is 4:3
|
|
||||||
4. all it does is tell the web UI to make the rover video 16:9 or 4:3 shaped
|
|
||||||
1. web UI should default to 4:3 if that rover doesnt yet have that config yet
|
|
||||||
5. 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]: /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]: cb({ error: err.message });
|
||||||
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,22 +16,27 @@ export default function AccessoriesExpansion({ roverId }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none absolute inset-y-0 left-0 z-20 flex items-center">
|
<div className="pointer-events-none absolute inset-y-0 left-0 z-20 flex items-center">
|
||||||
<AccessoriesToggle
|
{/* The tab remains attached to the video's left wall. When open, this
|
||||||
label="Accessories"
|
single shell grows around both the unchanged tab position and the
|
||||||
ariaLabel={open ? 'Hide accessory controls' : 'Show accessory controls'}
|
controls to its right, so the controls are not rendered as a second
|
||||||
onClick={() => setOpen(!open)}
|
disconnected panel. Its height follows content until the shared
|
||||||
hud
|
renderer reaches the scrolling boundary. */}
|
||||||
className="pointer-events-auto !h-28"
|
<div className="pointer-events-auto flex max-h-[70vh] items-center overflow-hidden rounded-r-xl bg-black/60">
|
||||||
/>
|
<AccessoriesToggle
|
||||||
{open ? (
|
label="Accessories"
|
||||||
<div className="pointer-events-auto w-64 overflow-hidden rounded-r-xl bg-black/60 p-0.5">
|
ariaLabel={open ? 'Hide accessory controls' : 'Show accessory controls'}
|
||||||
{/* This is exactly the renderer mounted by AuxColumn. The desktop
|
onClick={() => setOpen(!open)}
|
||||||
wrapper changes available dimensions, never control behavior.
|
hud
|
||||||
Content determines the normal panel height; max-height becomes a
|
className="!h-28"
|
||||||
scrolling boundary only for genuinely long accessory lists. */}
|
/>
|
||||||
<RoverAccessoryControls roverId={roverId} className="max-h-[70vh]" />
|
{open ? (
|
||||||
</div>
|
<RoverAccessoryControls
|
||||||
) : null}
|
roverId={roverId}
|
||||||
|
plainCenteredHeadings
|
||||||
|
className="w-64 max-h-[70vh]"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ function RoverAuxColumn({ roverId, layout, className }) {
|
|||||||
className="h-full"
|
className="h-full"
|
||||||
headerAction={(
|
headerAction={(
|
||||||
<AccessoriesToggle
|
<AccessoriesToggle
|
||||||
label="Aux"
|
label="Back"
|
||||||
ariaLabel="Return to auxiliary controls"
|
ariaLabel="Return to auxiliary controls"
|
||||||
compact
|
compact
|
||||||
onClick={() => setShowAccessories(false)}
|
onClick={() => setShowAccessories(false)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// Scope: Owns row chrome, queue chips, timer labels, and row/button event plumbing;
|
// Scope: Owns row chrome, queue chips, timer labels, and row/button event plumbing;
|
||||||
// callers still own target-specific permission checks and request actions.
|
// callers still own target-specific permission checks and request actions.
|
||||||
import RoverLabel from '../RoverLabel/index.jsx';
|
import RoverLabel from '../RoverLabel/index.jsx';
|
||||||
|
import RoverHelpOverlay from '../RoverHelpOverlay/index.jsx';
|
||||||
|
|
||||||
function classNames(...values) {
|
function classNames(...values) {
|
||||||
return values.filter(Boolean).join(' ');
|
return values.filter(Boolean).join(' ');
|
||||||
@@ -105,7 +106,7 @@ export default function QueueTargetRow({
|
|||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
className={classNames(
|
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',
|
canClick && 'cursor-pointer',
|
||||||
locked
|
locked
|
||||||
? 'bg-red-900/40'
|
? 'bg-red-900/40'
|
||||||
@@ -191,6 +192,9 @@ export default function QueueTargetRow({
|
|||||||
{buttonLabel}
|
{buttonLabel}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : 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>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,17 @@ export default function AccessoriesToggle({
|
|||||||
hud = false,
|
hud = false,
|
||||||
className = '',
|
className = '',
|
||||||
}) {
|
}) {
|
||||||
const sizeClass = compact ? 'h-6 w-10' : 'h-full w-8';
|
// The Back action is still shorter than the vertical launcher, but it needs
|
||||||
|
// a normal touch target and a readable word instead of the previous tiny
|
||||||
|
// abbreviated control. The full launcher continues to fill the height its
|
||||||
|
// desktop or mobile parent assigns to it.
|
||||||
|
const sizeClass = compact ? 'h-8 w-14' : 'h-full w-8';
|
||||||
const toneClass = hud
|
const toneClass = hud
|
||||||
? 'rounded-none border-0 bg-black/60 text-white/75 shadow-none hover:bg-black hover:text-white'
|
// On desktop the tab sits inside the shared popout shell. Rounding only
|
||||||
: 'rounded-xl border-2 border-cyan-300/70 bg-cyan-900 text-cyan-50 shadow-md hover:brightness-110 active:brightness-125';
|
// its exposed right edge preserves its left-wall attachment without
|
||||||
|
// introducing a separate accessory-specific panel treatment.
|
||||||
|
? 'rounded-r-xl border-0 bg-black/60 text-white shadow-none'
|
||||||
|
: 'rounded-xl border-2 border-cyan-300/70 bg-cyan-900 text-cyan-50 shadow-md';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -25,10 +32,10 @@ export default function AccessoriesToggle({
|
|||||||
triggerTouchHaptic('button');
|
triggerTouchHaptic('button');
|
||||||
onClick();
|
onClick();
|
||||||
}}
|
}}
|
||||||
className={`mobile-touch-control flex shrink-0 items-center justify-center text-sm font-semibold transition active:scale-[0.98] ${sizeClass} ${toneClass} ${className}`.trim()}
|
className={`mobile-touch-control flex shrink-0 items-center justify-center text-sm font-semibold ${sizeClass} ${toneClass} ${className}`.trim()}
|
||||||
>
|
>
|
||||||
{/* Full launchers use vertical writing in the narrow wall space. The
|
{/* Full launchers use vertical writing in the narrow wall space. The
|
||||||
compact Aux return stays horizontal so it consumes only one heading. */}
|
compact Back action stays horizontal so it fits in the heading. */}
|
||||||
<span className={compact ? 'flex items-center' : 'flex items-center gap-1 [writing-mode:vertical-rl] rotate-180'}>
|
<span className={compact ? 'flex items-center' : 'flex items-center gap-1 [writing-mode:vertical-rl] rotate-180'}>
|
||||||
{!compact ? <FaPuzzlePiece className="shrink-0 text-sm" aria-hidden="true" /> : null}
|
{!compact ? <FaPuzzlePiece className="shrink-0 text-sm" aria-hidden="true" /> : null}
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ function trimUnicode(value, maximumLength) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CARD_CLASS = 'mobile-touch-control rounded-xl border-2 px-2 py-1 text-slate-50';
|
const CARD_CLASS = 'mobile-touch-control rounded-xl border-2 px-2 py-1 text-slate-50';
|
||||||
|
// Sliders already contain a label row and a separate track. Giving that
|
||||||
|
// two-row control the same vertical padding as a one-row button made it much
|
||||||
|
// taller than its neighbors without improving its touch target.
|
||||||
|
const SLIDER_CARD_CLASS = 'mobile-touch-control rounded-xl border-2 px-1.5 py-0.5 text-slate-50';
|
||||||
const DISABLED_CLASS = 'disabled:cursor-not-allowed disabled:opacity-40';
|
const DISABLED_CLASS = 'disabled:cursor-not-allowed disabled:opacity-40';
|
||||||
|
|
||||||
function SliderControl({ peripheralId, control, disabled, send, value: storedValue }) {
|
function SliderControl({ peripheralId, control, disabled, send, value: storedValue }) {
|
||||||
@@ -97,7 +101,7 @@ function SliderControl({ peripheralId, control, disabled, send, value: storedVal
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${CARD_CLASS} border-emerald-300/70 bg-emerald-900 ${disabled ? 'cursor-not-allowed opacity-40' : ''}`}>
|
<div className={`${SLIDER_CARD_CLASS} border-emerald-300/70 bg-emerald-900 ${disabled ? 'cursor-not-allowed opacity-40' : ''}`}>
|
||||||
<div className="flex items-center justify-between gap-1 text-sm font-semibold">
|
<div className="flex items-center justify-between gap-1 text-sm font-semibold">
|
||||||
<span className="min-w-0 truncate">{control.name}</span>
|
<span className="min-w-0 truncate">{control.name}</span>
|
||||||
<span className="shrink-0 font-mono text-emerald-100">{value}</span>
|
<span className="shrink-0 font-mono text-emerald-100">{value}</span>
|
||||||
@@ -121,7 +125,7 @@ function SliderControl({ peripheralId, control, disabled, send, value: storedVal
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
style={{ touchAction: 'none' }}
|
style={{ touchAction: 'none' }}
|
||||||
className="mobile-touch-control mobile-drag-control relative mt-1 h-7 w-full rounded-full border border-emerald-100/80 bg-emerald-950 shadow-inner focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-200"
|
className="mobile-touch-control mobile-drag-control relative mt-0.5 h-6 w-full rounded-full border border-emerald-100/80 bg-emerald-950 shadow-inner focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-200"
|
||||||
>
|
>
|
||||||
{/* An inset track gives the thumb room to remain entirely inside the
|
{/* An inset track gives the thumb room to remain entirely inside the
|
||||||
card at both endpoints without browser-specific range styling. */}
|
card at both endpoints without browser-specific range styling. */}
|
||||||
@@ -155,7 +159,7 @@ function ToggleControl({ peripheralId, control, disabled, send, value }) {
|
|||||||
aria-pressed={enabled}
|
aria-pressed={enabled}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
className={`${CARD_CLASS} ${DISABLED_CLASS} flex min-h-12 w-full items-center justify-between gap-1 font-semibold transition active:scale-[0.99] ${enabled ? 'border-emerald-300/70 bg-emerald-800 text-emerald-50' : 'border-amber-300/70 bg-amber-900 text-amber-50'}`}
|
className={`${CARD_CLASS} ${DISABLED_CLASS} flex min-h-12 w-full items-center justify-between gap-1 font-semibold ${enabled ? 'border-emerald-300/70 bg-emerald-800 text-emerald-50' : 'border-amber-300/70 bg-amber-900 text-amber-50'}`}
|
||||||
>
|
>
|
||||||
<span className="min-w-0 truncate">{control.name}</span>
|
<span className="min-w-0 truncate">{control.name}</span>
|
||||||
<span className="shrink-0 text-xs">{enabled ? 'On' : 'Off'}</span>
|
<span className="shrink-0 text-xs">{enabled ? 'On' : 'Off'}</span>
|
||||||
@@ -221,7 +225,7 @@ function MomentaryControl({ peripheralId, control, disabled, send, value }) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
className={`${CARD_CLASS} ${DISABLED_CLASS} flex min-h-12 w-full items-center justify-center text-center font-semibold transition active:scale-[0.99] ${pressed ? 'border-fuchsia-200 bg-fuchsia-600 text-white' : 'border-fuchsia-300/70 bg-fuchsia-900 text-fuchsia-50'}`}
|
className={`${CARD_CLASS} ${DISABLED_CLASS} flex min-h-12 w-full items-center justify-center text-center font-semibold ${pressed ? 'border-fuchsia-200 bg-fuchsia-600 text-white' : 'border-fuchsia-300/70 bg-fuchsia-900 text-fuchsia-50'}`}
|
||||||
>
|
>
|
||||||
{control.name}
|
{control.name}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import useRoverAccessories from './useRoverAccessories.js';
|
|||||||
|
|
||||||
const EMPTY_ACCESSORY_VALUES = Object.freeze({});
|
const EMPTY_ACCESSORY_VALUES = Object.freeze({});
|
||||||
|
|
||||||
export default function RoverAccessoryControls({ roverId, headerAction = null, className = '' }) {
|
export default function RoverAccessoryControls({
|
||||||
|
roverId,
|
||||||
|
headerAction = null,
|
||||||
|
plainCenteredHeadings = false,
|
||||||
|
className = '',
|
||||||
|
}) {
|
||||||
const { peripherals } = useRoverAccessories(roverId);
|
const { peripherals } = useRoverAccessories(roverId);
|
||||||
const canControl = useCanControlRover(roverId);
|
const canControl = useCanControlRover(roverId);
|
||||||
const { setPeripheralControl } = useControlActions();
|
const { setPeripheralControl } = useControlActions();
|
||||||
@@ -29,17 +34,25 @@ export default function RoverAccessoryControls({ roverId, headerAction = null, c
|
|||||||
aria-label="Rover accessories"
|
aria-label="Rover accessories"
|
||||||
>
|
>
|
||||||
{peripherals.map((peripheral, peripheralIndex) => {
|
{peripherals.map((peripheral, peripheralIndex) => {
|
||||||
const showHeading = peripherals.length > 1 || (peripheralIndex === 0 && headerAction);
|
|
||||||
return (
|
return (
|
||||||
<section key={peripheral.id} className="mb-0.5 last:mb-0">
|
<section key={peripheral.id} className="mb-0.5 flex flex-col gap-0.5 last:mb-0">
|
||||||
{/* The firmware's array order is authoritative. Mapping directly over
|
{/* The firmware's array order is authoritative. Mapping directly over
|
||||||
it keeps physical authoring order intact across every UI host. */}
|
it keeps physical authoring order intact across every UI host.
|
||||||
{showHeading ? (
|
Every peripheral keeps its heading even when it is the only
|
||||||
<div className="mb-0.5 flex min-h-7 items-center gap-1 bg-black/60 px-1 text-xs font-semibold text-cyan-100">
|
device, because its firmware-provided name identifies which
|
||||||
<h3 className="min-w-0 flex-1 truncate">{peripheral.name}</h3>
|
physical accessory owns the controls below it. */}
|
||||||
{peripheralIndex === 0 ? headerAction : null}
|
<div
|
||||||
</div>
|
className={`flex min-h-8 items-center gap-1 px-1 text-xs font-semibold text-white ${plainCenteredHeadings ? '' : 'bg-black/60'}`.trim()}
|
||||||
) : null}
|
>
|
||||||
|
{/* Desktop already supplies one continuous HUD background, so
|
||||||
|
its title needs neither a second tone nor left alignment.
|
||||||
|
Mobile keeps the ordinary heading because it also carries
|
||||||
|
the Back action on the opposite side. */}
|
||||||
|
<h3 className={`min-w-0 flex-1 truncate ${plainCenteredHeadings ? 'text-center' : ''}`.trim()}>
|
||||||
|
{peripheral.name}
|
||||||
|
</h3>
|
||||||
|
{peripheralIndex === 0 ? headerAction : null}
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
{peripheral.controls.map((control) => (
|
{peripheral.controls.map((control) => (
|
||||||
<AccessoryControlField
|
<AccessoryControlField
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import RoverDescriptionOverlay from '../HudOverlays/RoverDescriptionOverlay/inde
|
|||||||
import OvercurrentOverlay from '../HudOverlays/OvercurrentOverlay/index.jsx';
|
import OvercurrentOverlay from '../HudOverlays/OvercurrentOverlay/index.jsx';
|
||||||
import LowBatteryOverlay from '../HudOverlays/LowBatteryOverlay/index.jsx';
|
import LowBatteryOverlay from '../HudOverlays/LowBatteryOverlay/index.jsx';
|
||||||
import VerticalBatteryOverlay from '../HudOverlays/VerticalBatteryOverlay/index.jsx';
|
import VerticalBatteryOverlay from '../HudOverlays/VerticalBatteryOverlay/index.jsx';
|
||||||
|
import RoverHelpOverlay from '../RoverHelpOverlay/index.jsx';
|
||||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
|
|
||||||
export default function SpectateVideo({
|
export default function SpectateVideo({
|
||||||
@@ -11,6 +12,7 @@ export default function SpectateVideo({
|
|||||||
label,
|
label,
|
||||||
fitParent = false,
|
fitParent = false,
|
||||||
layoutFormat = 'desktop',
|
layoutFormat = 'desktop',
|
||||||
|
needsHelp = false,
|
||||||
}) {
|
}) {
|
||||||
const isExternalSpectatorSnapshotOnly = useSessionSelector((state) =>
|
const isExternalSpectatorSnapshotOnly = useSessionSelector((state) =>
|
||||||
state.session?.role === 'spectator' &&
|
state.session?.role === 'spectator' &&
|
||||||
@@ -46,6 +48,7 @@ export default function SpectateVideo({
|
|||||||
<OvercurrentOverlay roverId={roverId} compact={false} />
|
<OvercurrentOverlay roverId={roverId} compact={false} />
|
||||||
<LowBatteryOverlay roverId={roverId} compact={false} />
|
<LowBatteryOverlay roverId={roverId} compact={false} />
|
||||||
<VerticalBatteryOverlay show roverId={roverId} mobileHud={false} />
|
<VerticalBatteryOverlay show roverId={roverId} mobileHud={false} />
|
||||||
|
<RoverHelpOverlay active={needsHelp} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { useVisualTelemetrySelector } from '../../../context/TelemetryContext.js
|
|||||||
import { batteryTelemetryEqual, selectBatteryTelemetry } from '../../../context/telemetryViews.js';
|
import { batteryTelemetryEqual, selectBatteryTelemetry } from '../../../context/telemetryViews.js';
|
||||||
import BatteryBar from '../../../components/BatteryBar/index.jsx';
|
import BatteryBar from '../../../components/BatteryBar/index.jsx';
|
||||||
import RoverLabel from '../../../components/RoverLabel/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 {
|
import {
|
||||||
buildRoverStateText,
|
buildRoverStateText,
|
||||||
findDriverForRover,
|
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' : '',
|
!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" />
|
<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="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]">
|
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-[0.8vw]">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import useDefaultNickname from '../../hooks/useDefaultNickname.js';
|
|||||||
import useUserIdentitySync from '../../hooks/useUserIdentitySync.js';
|
import useUserIdentitySync from '../../hooks/useUserIdentitySync.js';
|
||||||
import PtzLiveVideo from '../../components/PtzLiveVideo/index.jsx';
|
import PtzLiveVideo from '../../components/PtzLiveVideo/index.jsx';
|
||||||
import RoverMediaPlayer from '../../components/RoverMediaPlayer/index.jsx';
|
import RoverMediaPlayer from '../../components/RoverMediaPlayer/index.jsx';
|
||||||
|
import RoverHelpOverlay from '../../components/RoverHelpOverlay/index.jsx';
|
||||||
import FitViewportFrame from './components/FitViewportFrame.jsx';
|
import FitViewportFrame from './components/FitViewportFrame.jsx';
|
||||||
import InfoColumn from './components/InfoColumn.jsx';
|
import InfoColumn from './components/InfoColumn.jsx';
|
||||||
import { ROTATE_MS } from './constants.js';
|
import { ROTATE_MS } from './constants.js';
|
||||||
@@ -352,6 +353,7 @@ export default function MiniSummaryContent() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</FitViewportFrame>
|
</FitViewportFrame>
|
||||||
|
<RoverHelpOverlay active={Boolean(rover.needsHelp)} />
|
||||||
</div>
|
</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 RoverMediaPlayer from '../../../components/RoverMediaPlayer/index.jsx';
|
||||||
import BatteryBar from '../../../components/BatteryBar/index.jsx';
|
import BatteryBar from '../../../components/BatteryBar/index.jsx';
|
||||||
import RoverLabel from '../../../components/RoverLabel/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 { getBatteryVisual } from '../utils.js';
|
||||||
import AutoFitText from './AutoFitText.jsx';
|
|
||||||
|
|
||||||
export default function InfoColumn({
|
export default function InfoColumn({
|
||||||
rover,
|
rover,
|
||||||
@@ -32,6 +33,10 @@ export default function InfoColumn({
|
|||||||
orientation={isActiveView ? 'vertical' : 'horizontal'}
|
orientation={isActiveView ? 'vertical' : 'horizontal'}
|
||||||
variant="background"
|
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 ? (
|
{isActiveView ? (
|
||||||
<div className="relative z-10 flex min-w-0 flex-1 flex-col justify-between text-center">
|
<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">
|
<div className="min-w-0 bg-transparent px-0 py-0 leading-none">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export default function RoverSpectatorCard({ rover }) {
|
|||||||
<SpectateVideo
|
<SpectateVideo
|
||||||
roverId={rover.id}
|
roverId={rover.id}
|
||||||
label={rover.name}
|
label={rover.name}
|
||||||
|
needsHelp={Boolean(rover.needsHelp)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
Reference in New Issue
Block a user