laser locking and idle

This commit is contained in:
legop3
2026-06-27 18:06:24 -04:00
parent 0cfc16528b
commit 4ee4f7d06e
9 changed files with 256 additions and 152 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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/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>
<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">
</head>
<body>
+73 -1
View File
@@ -8,10 +8,53 @@ const { isAdmin, isLockdownAdmin } = require('../roleService');
const { isDeterred } = require('../verificationService');
const logger = require('../../globals/logger').child('commandService');
const { isHeadlightBlocked } = require('../../rewards/definitions/darkness');
const homeAssistantService = require('../homeAssistantService');
const pendingCommands = new Map(); // id -> { roverId }
const lastDriveActivity = new Map(); // roverId -> { ts, socketId, direction, speed, isAdmin }
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 = {}) {
if (payload?.type !== 'tts') return payload;
@@ -38,6 +81,9 @@ function issueCommand(roverId, payload) {
if (!record || !record.ws) {
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 normalizedPayload = normalizeOutboundCommandPayload(payload);
const message = { ...normalizedPayload, id };
@@ -158,12 +204,21 @@ io.on('connection', (socket) => {
if (type === 'audioLevels') {
throw new Error('audioLevels command is service-managed');
}
const payload = data ? { ...data } : {};
if (type === 'headlight' && isHeadlightBlocked()) {
logger.info('Ignoring headlight command while darkness lock is active', { socketId: socket.id, roverId });
reply({ ignored: true, reason: 'darknessActive' });
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 isUpdateCommand = type === 'update';
const isSongCommand = type === 'song' || (type === 'raw' && isSongRawPayload(payload));
@@ -235,6 +290,23 @@ io.on('connection', (socket) => {
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) {
if (!payload) return false;
const raw = payload.raw;
@@ -9,6 +9,7 @@ const neatoService = require('../neatoService');
const liftService = require('../liftService');
const {
HEADLIGHT_DISABLE_ACTION,
LASER_DISABLE_ACTION,
DOCK_COMMAND_BASE64,
} = require('./constants');
@@ -79,6 +80,29 @@ async function disableAllRoverHeadlights() {
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() {
try {
await neatoService.sendHome();
@@ -101,6 +125,7 @@ const idleActions = [
turnOffRoomControls,
// dockAllRovers,
disableAllRoverHeadlights,
disableAllRoverLasers,
sendNeatoHome,
raiseLift,
];
@@ -3,10 +3,12 @@
// Scope: Centralizes immutable configuration for trigger windows and rover command payloads.
const IDLE_TIMEOUT_MS = 2 * 60 * 1000;
const HEADLIGHT_DISABLE_ACTION = 'off';
const LASER_DISABLE_ACTION = 'off';
const DOCK_COMMAND_BASE64 = Buffer.from([143]).toString('base64');
module.exports = {
IDLE_TIMEOUT_MS,
HEADLIGHT_DISABLE_ACTION,
LASER_DISABLE_ACTION,
DOCK_COMMAND_BASE64,
};
@@ -10,9 +10,11 @@ import { AUX_ZERO } from './constants.js';
import VacuumControls from './VacuumControls.jsx';
import VerticalCameraTilt from './VerticalCameraTilt.jsx';
import { trackAnalyticsEvent } from '../../analytics/index.js';
import { useSessionSelector } from '../../context/SessionContext.jsx';
function AuxColumnContent() {
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 horn = useControlSelector((control) => control.state.horn);
const headlight = useControlSelector((control) => control.pipeline?.headlight);
@@ -120,7 +122,7 @@ function AuxColumnContent() {
<GPIOToggleControl
label="Laser"
on={laserState?.laserOn}
disabled={disabled}
disabled={disabled || roomLightsLockedOn}
onToggle={handleLaserToggle}
heightClass="h-full"
/>
+2 -1
View File
@@ -59,6 +59,7 @@ function TopDownMapPanel() {
function DriveDockPanel() {
const roverId = useControlSelector((control) => control.state.roverId);
const roomLightsLockedOn = useSessionSelector((state) => Boolean(state.session?.homeAssistant?.lightPolicy?.lockedOn));
const keymap = useControlSelector((control) => control.state.keymap);
const camera = useControlSelector((control) => control.state.camera);
const horn = useControlSelector((control) => control.state.horn);
@@ -136,7 +137,7 @@ function DriveDockPanel() {
<GPIOToggleControl
label="Laser"
on={laserState?.laserOn}
disabled={!roverId}
disabled={!roverId || roomLightsLockedOn}
onToggle={trackedControls.setLaser}
keyLabel={laserLabel}
/>
+3 -1
View File
@@ -166,6 +166,7 @@ export function ControlSystemProvider({ children }) {
: true;
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const homeAssistantEntities = useSessionSelector((state) => state.session?.homeAssistant?.entities ?? []);
const roomLightsLockedOn = useSessionSelector((state) => Boolean(state.session?.homeAssistant?.lightPolicy?.lockedOn));
const { homeAssistantSetState } = useSessionActions();
const overcurrentLimiter = useOvercurrentLimiter(roverId);
const driveTransform = useCallback(
@@ -491,13 +492,14 @@ export function ControlSystemProvider({ children }) {
const setLaser = useCallback(
(laserOn) => {
if (!pipeline.laser) return;
if (roomLightsLockedOn && laserOn !== false) return;
// 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.
const action = typeof laserOn === 'boolean' ? (laserOn ? 'on' : 'off') : 'toggle';
pipeline.sendLaser(action);
recordControlIntent();
},
[pipeline, recordControlIntent],
[pipeline, recordControlIntent, roomLightsLockedOn],
);
const toggleLaser = useCallback(() => {