mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
HELP!!!!
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user