mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
laser locking and idle
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -78,7 +78,7 @@
|
|||||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||||
<title>Roomba Rover</title>
|
<title>Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BoLmfZBk.js"></script>
|
<script type="module" crossorigin src="/assets/index-C_cLKG0x.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CKlOAshP.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CKlOAshP.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -8,10 +8,53 @@ const { isAdmin, isLockdownAdmin } = require('../roleService');
|
|||||||
const { isDeterred } = require('../verificationService');
|
const { isDeterred } = require('../verificationService');
|
||||||
const logger = require('../../globals/logger').child('commandService');
|
const logger = require('../../globals/logger').child('commandService');
|
||||||
const { isHeadlightBlocked } = require('../../rewards/definitions/darkness');
|
const { isHeadlightBlocked } = require('../../rewards/definitions/darkness');
|
||||||
|
const homeAssistantService = require('../homeAssistantService');
|
||||||
|
|
||||||
const pendingCommands = new Map(); // id -> { roverId }
|
const pendingCommands = new Map(); // id -> { roverId }
|
||||||
const lastDriveActivity = new Map(); // roverId -> { ts, socketId, direction, speed, isAdmin }
|
const lastDriveActivity = new Map(); // roverId -> { ts, socketId, direction, speed, isAdmin }
|
||||||
const driveCooldowns = new Map(); // roverId -> blockedUntil
|
const driveCooldowns = new Map(); // roverId -> blockedUntil
|
||||||
|
let roomLightsWereLockedOn = Boolean(homeAssistantService.getLightPolicyState?.()?.lockedOn);
|
||||||
|
|
||||||
|
function isRoomLightsLockedOn() {
|
||||||
|
return Boolean(homeAssistantService.getLightPolicyState?.()?.lockedOn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLaserAction(payload = {}) {
|
||||||
|
return String(payload?.laser?.action || 'toggle').trim().toLowerCase() || 'toggle';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLaserCommandBlockedByRoomLightLock(payload = {}) {
|
||||||
|
if (!isRoomLightsLockedOn()) return false;
|
||||||
|
// A locked-on room-light policy means the laser must not emit. Explicit off
|
||||||
|
// commands are still allowed so cleanup paths can force a safe state.
|
||||||
|
return getLaserAction(payload) !== 'off';
|
||||||
|
}
|
||||||
|
|
||||||
|
function forceAllRoverLasersOff(reason) {
|
||||||
|
const attempted = [];
|
||||||
|
const failed = [];
|
||||||
|
roverManager.rovers.forEach((record) => {
|
||||||
|
if (!record?.ws || !record?.meta?.laser?.enabled) return;
|
||||||
|
const roverId = String(record.id);
|
||||||
|
try {
|
||||||
|
issueCommand(roverId, {
|
||||||
|
type: 'laser',
|
||||||
|
laser: { action: 'off' },
|
||||||
|
});
|
||||||
|
attempted.push(roverId);
|
||||||
|
} catch (err) {
|
||||||
|
failed.push({ roverId, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (attempted.length || failed.length) {
|
||||||
|
logger.info('Forced rover lasers off', { reason, attempted, failed });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enforceLaserRoomLightLock(reason) {
|
||||||
|
if (!isRoomLightsLockedOn()) return;
|
||||||
|
forceAllRoverLasersOff(reason);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeOutboundCommandPayload(payload = {}) {
|
function normalizeOutboundCommandPayload(payload = {}) {
|
||||||
if (payload?.type !== 'tts') return payload;
|
if (payload?.type !== 'tts') return payload;
|
||||||
@@ -38,6 +81,9 @@ function issueCommand(roverId, payload) {
|
|||||||
if (!record || !record.ws) {
|
if (!record || !record.ws) {
|
||||||
throw new Error('Rover offline');
|
throw new Error('Rover offline');
|
||||||
}
|
}
|
||||||
|
if (payload?.type === 'laser' && isLaserCommandBlockedByRoomLightLock(payload)) {
|
||||||
|
throw new Error('Laser disabled while room lights are locked on');
|
||||||
|
}
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
const normalizedPayload = normalizeOutboundCommandPayload(payload);
|
const normalizedPayload = normalizeOutboundCommandPayload(payload);
|
||||||
const message = { ...normalizedPayload, id };
|
const message = { ...normalizedPayload, id };
|
||||||
@@ -158,12 +204,21 @@ io.on('connection', (socket) => {
|
|||||||
if (type === 'audioLevels') {
|
if (type === 'audioLevels') {
|
||||||
throw new Error('audioLevels command is service-managed');
|
throw new Error('audioLevels command is service-managed');
|
||||||
}
|
}
|
||||||
|
const payload = data ? { ...data } : {};
|
||||||
if (type === 'headlight' && isHeadlightBlocked()) {
|
if (type === 'headlight' && isHeadlightBlocked()) {
|
||||||
logger.info('Ignoring headlight command while darkness lock is active', { socketId: socket.id, roverId });
|
logger.info('Ignoring headlight command while darkness lock is active', { socketId: socket.id, roverId });
|
||||||
reply({ ignored: true, reason: 'darknessActive' });
|
reply({ ignored: true, reason: 'darknessActive' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = data ? { ...data } : {};
|
if (type === 'laser' && isLaserCommandBlockedByRoomLightLock(payload)) {
|
||||||
|
logger.info('Ignoring laser command while room lights are locked on', {
|
||||||
|
socketId: socket.id,
|
||||||
|
roverId,
|
||||||
|
action: getLaserAction(payload),
|
||||||
|
});
|
||||||
|
reply({ ignored: true, reason: 'roomLightsLockedOn' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const isRebootCommand = type === 'reboot';
|
const isRebootCommand = type === 'reboot';
|
||||||
const isUpdateCommand = type === 'update';
|
const isUpdateCommand = type === 'update';
|
||||||
const isSongCommand = type === 'song' || (type === 'raw' && isSongRawPayload(payload));
|
const isSongCommand = type === 'song' || (type === 'raw' && isSongRawPayload(payload));
|
||||||
@@ -235,6 +290,23 @@ io.on('connection', (socket) => {
|
|||||||
socket.on('command:issue', handleCommand);
|
socket.on('command:issue', handleCommand);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
homeAssistantService.homeAssistantEvents.on('update', () => {
|
||||||
|
const lockedOn = isRoomLightsLockedOn();
|
||||||
|
if (lockedOn && !roomLightsWereLockedOn) {
|
||||||
|
enforceLaserRoomLightLock('roomLightsLockedOn');
|
||||||
|
}
|
||||||
|
roomLightsWereLockedOn = lockedOn;
|
||||||
|
});
|
||||||
|
|
||||||
|
roverManager.managerEvents.on('rover', (event = {}) => {
|
||||||
|
// A rover can reconnect with laser.initialOn enabled or stale hardware state.
|
||||||
|
// When room lights are locked on, every newly upserted rover gets an explicit
|
||||||
|
// off command so the lock policy is true even across reconnects.
|
||||||
|
if (event.action === 'upsert') {
|
||||||
|
enforceLaserRoomLightLock('roverUpsertWhileRoomLightsLockedOn');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function isSongRawPayload(payload) {
|
function isSongRawPayload(payload) {
|
||||||
if (!payload) return false;
|
if (!payload) return false;
|
||||||
const raw = payload.raw;
|
const raw = payload.raw;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const neatoService = require('../neatoService');
|
|||||||
const liftService = require('../liftService');
|
const liftService = require('../liftService');
|
||||||
const {
|
const {
|
||||||
HEADLIGHT_DISABLE_ACTION,
|
HEADLIGHT_DISABLE_ACTION,
|
||||||
|
LASER_DISABLE_ACTION,
|
||||||
DOCK_COMMAND_BASE64,
|
DOCK_COMMAND_BASE64,
|
||||||
} = require('./constants');
|
} = require('./constants');
|
||||||
|
|
||||||
@@ -79,6 +80,29 @@ async function disableAllRoverHeadlights() {
|
|||||||
return { action: 'disableRoverHeadlights', attempted, failed };
|
return { action: 'disableRoverHeadlights', attempted, failed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function disableAllRoverLasers() {
|
||||||
|
const attempted = [];
|
||||||
|
const failed = [];
|
||||||
|
roverManager.rovers.forEach((record) => {
|
||||||
|
if (!record?.ws || !record?.meta?.laser?.enabled) return;
|
||||||
|
const roverId = String(record.id);
|
||||||
|
try {
|
||||||
|
// Idle cleanup is allowed to send an explicit off command even when
|
||||||
|
// other laser commands are policy-blocked. The point of this action is
|
||||||
|
// to leave the rover in a non-emitting state when nobody is actively
|
||||||
|
// driving it.
|
||||||
|
issueCommand(roverId, {
|
||||||
|
type: 'laser',
|
||||||
|
laser: { action: LASER_DISABLE_ACTION },
|
||||||
|
});
|
||||||
|
attempted.push(roverId);
|
||||||
|
} catch (err) {
|
||||||
|
failed.push({ roverId, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { action: 'disableRoverLasers', attempted, failed };
|
||||||
|
}
|
||||||
|
|
||||||
async function sendNeatoHome() {
|
async function sendNeatoHome() {
|
||||||
try {
|
try {
|
||||||
await neatoService.sendHome();
|
await neatoService.sendHome();
|
||||||
@@ -101,6 +125,7 @@ const idleActions = [
|
|||||||
turnOffRoomControls,
|
turnOffRoomControls,
|
||||||
// dockAllRovers,
|
// dockAllRovers,
|
||||||
disableAllRoverHeadlights,
|
disableAllRoverHeadlights,
|
||||||
|
disableAllRoverLasers,
|
||||||
sendNeatoHome,
|
sendNeatoHome,
|
||||||
raiseLift,
|
raiseLift,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
// Scope: Centralizes immutable configuration for trigger windows and rover command payloads.
|
// Scope: Centralizes immutable configuration for trigger windows and rover command payloads.
|
||||||
const IDLE_TIMEOUT_MS = 2 * 60 * 1000;
|
const IDLE_TIMEOUT_MS = 2 * 60 * 1000;
|
||||||
const HEADLIGHT_DISABLE_ACTION = 'off';
|
const HEADLIGHT_DISABLE_ACTION = 'off';
|
||||||
|
const LASER_DISABLE_ACTION = 'off';
|
||||||
const DOCK_COMMAND_BASE64 = Buffer.from([143]).toString('base64');
|
const DOCK_COMMAND_BASE64 = Buffer.from([143]).toString('base64');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
IDLE_TIMEOUT_MS,
|
IDLE_TIMEOUT_MS,
|
||||||
HEADLIGHT_DISABLE_ACTION,
|
HEADLIGHT_DISABLE_ACTION,
|
||||||
|
LASER_DISABLE_ACTION,
|
||||||
DOCK_COMMAND_BASE64,
|
DOCK_COMMAND_BASE64,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import { AUX_ZERO } from './constants.js';
|
|||||||
import VacuumControls from './VacuumControls.jsx';
|
import VacuumControls from './VacuumControls.jsx';
|
||||||
import VerticalCameraTilt from './VerticalCameraTilt.jsx';
|
import VerticalCameraTilt from './VerticalCameraTilt.jsx';
|
||||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||||
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
|
|
||||||
function AuxColumnContent() {
|
function AuxColumnContent() {
|
||||||
const roverId = useControlSelector((control) => control.state.roverId);
|
const roverId = useControlSelector((control) => control.state.roverId);
|
||||||
|
const roomLightsLockedOn = useSessionSelector((state) => Boolean(state.session?.homeAssistant?.lightPolicy?.lockedOn));
|
||||||
const camera = useControlSelector((control) => control.state.camera);
|
const camera = useControlSelector((control) => control.state.camera);
|
||||||
const horn = useControlSelector((control) => control.state.horn);
|
const horn = useControlSelector((control) => control.state.horn);
|
||||||
const headlight = useControlSelector((control) => control.pipeline?.headlight);
|
const headlight = useControlSelector((control) => control.pipeline?.headlight);
|
||||||
@@ -120,7 +122,7 @@ function AuxColumnContent() {
|
|||||||
<GPIOToggleControl
|
<GPIOToggleControl
|
||||||
label="Laser"
|
label="Laser"
|
||||||
on={laserState?.laserOn}
|
on={laserState?.laserOn}
|
||||||
disabled={disabled}
|
disabled={disabled || roomLightsLockedOn}
|
||||||
onToggle={handleLaserToggle}
|
onToggle={handleLaserToggle}
|
||||||
heightClass="h-full"
|
heightClass="h-full"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ function TopDownMapPanel() {
|
|||||||
|
|
||||||
function DriveDockPanel() {
|
function DriveDockPanel() {
|
||||||
const roverId = useControlSelector((control) => control.state.roverId);
|
const roverId = useControlSelector((control) => control.state.roverId);
|
||||||
|
const roomLightsLockedOn = useSessionSelector((state) => Boolean(state.session?.homeAssistant?.lightPolicy?.lockedOn));
|
||||||
const keymap = useControlSelector((control) => control.state.keymap);
|
const keymap = useControlSelector((control) => control.state.keymap);
|
||||||
const camera = useControlSelector((control) => control.state.camera);
|
const camera = useControlSelector((control) => control.state.camera);
|
||||||
const horn = useControlSelector((control) => control.state.horn);
|
const horn = useControlSelector((control) => control.state.horn);
|
||||||
@@ -136,7 +137,7 @@ function DriveDockPanel() {
|
|||||||
<GPIOToggleControl
|
<GPIOToggleControl
|
||||||
label="Laser"
|
label="Laser"
|
||||||
on={laserState?.laserOn}
|
on={laserState?.laserOn}
|
||||||
disabled={!roverId}
|
disabled={!roverId || roomLightsLockedOn}
|
||||||
onToggle={trackedControls.setLaser}
|
onToggle={trackedControls.setLaser}
|
||||||
keyLabel={laserLabel}
|
keyLabel={laserLabel}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ export function ControlSystemProvider({ children }) {
|
|||||||
: true;
|
: true;
|
||||||
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||||
const homeAssistantEntities = useSessionSelector((state) => state.session?.homeAssistant?.entities ?? []);
|
const homeAssistantEntities = useSessionSelector((state) => state.session?.homeAssistant?.entities ?? []);
|
||||||
|
const roomLightsLockedOn = useSessionSelector((state) => Boolean(state.session?.homeAssistant?.lightPolicy?.lockedOn));
|
||||||
const { homeAssistantSetState } = useSessionActions();
|
const { homeAssistantSetState } = useSessionActions();
|
||||||
const overcurrentLimiter = useOvercurrentLimiter(roverId);
|
const overcurrentLimiter = useOvercurrentLimiter(roverId);
|
||||||
const driveTransform = useCallback(
|
const driveTransform = useCallback(
|
||||||
@@ -491,13 +492,14 @@ export function ControlSystemProvider({ children }) {
|
|||||||
const setLaser = useCallback(
|
const setLaser = useCallback(
|
||||||
(laserOn) => {
|
(laserOn) => {
|
||||||
if (!pipeline.laser) return;
|
if (!pipeline.laser) return;
|
||||||
|
if (roomLightsLockedOn && laserOn !== false) return;
|
||||||
// The laser shares the same logical toggle contract as the headlight; it
|
// The laser shares the same logical toggle contract as the headlight; it
|
||||||
// is separate only because it has its own GPIO pin, UI control, and keybind.
|
// is separate only because it has its own GPIO pin, UI control, and keybind.
|
||||||
const action = typeof laserOn === 'boolean' ? (laserOn ? 'on' : 'off') : 'toggle';
|
const action = typeof laserOn === 'boolean' ? (laserOn ? 'on' : 'off') : 'toggle';
|
||||||
pipeline.sendLaser(action);
|
pipeline.sendLaser(action);
|
||||||
recordControlIntent();
|
recordControlIntent();
|
||||||
},
|
},
|
||||||
[pipeline, recordControlIntent],
|
[pipeline, recordControlIntent, roomLightsLockedOn],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleLaser = useCallback(() => {
|
const toggleLaser = useCallback(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user