mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -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/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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user