mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
HELP!!!!
This commit is contained in:
@@ -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
@@ -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-B6mxStEr.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D5xo99Ua.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,56 @@
|
||||
// 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 HELP_ALERT_COLOR = '#ef4444';
|
||||
|
||||
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) {
|
||||
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) {
|
||||
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') monitor.removeRover(roverId);
|
||||
});
|
||||
|
||||
module.exports = { monitor };
|
||||
@@ -0,0 +1,159 @@
|
||||
// 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 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 docked = Boolean(sensors?.chargingSources?.homeBase);
|
||||
const oiMode = sensors?.oiMode?.label || null;
|
||||
if (oiMode === 'passive') state.dockGuardSawPassive = true;
|
||||
if (docked || (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,99 @@
|
||||
// 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']);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 drop-shadow-[0_0_0.08em_rgba(0,0,0,1)]"
|
||||
>
|
||||
HELP
|
||||
</AutoFitText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/* A one-second stepped cycle is urgent and room-readable without becoming a
|
||||
rapid strobe. Opacity applies to the entire shared treatment, so its border,
|
||||
background, and auto-fit word flash together on every rover surface. */
|
||||
@keyframes rover-help-flash {
|
||||
0%, 49% { opacity: 0.96; }
|
||||
50%, 100% { opacity: 0.12; }
|
||||
}
|
||||
|
||||
.rover-help-overlay {
|
||||
container-type: size;
|
||||
animation: rover-help-flash 1s steps(1, end) infinite;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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