headlight rework and laser addition

This commit is contained in:
legop3
2026-06-27 15:57:47 -04:00
parent 3c1b1dab6e
commit 8e322ed57b
45 changed files with 596 additions and 373 deletions
+20 -26
View File
@@ -2,19 +2,13 @@
// Purpose: Defines the darkness reward that alters visibility/lighting behavior. Scope: Encapsulates reward metadata and effect configuration for runtime execution.
const DURATION_MS = 15 * 60 * 1000;
const LIGHT_ENFORCE_TICK_MS = 3000;
// Rover daemon semantics are inverted:
// action "on" => IR LED on => nightVisionOn=false
// action "off" => IR LED off => nightVisionOn=true
function actionForNightVisionState(nightVisionOn) {
return nightVisionOn ? 'off' : 'on';
}
let activeTimer = null;
let enforceLightsTimer = null;
let nightVisionLockUntil = 0;
let headlightLockUntil = 0;
function isNightVisionBlocked() {
return Date.now() < nightVisionLockUntil;
function isHeadlightBlocked() {
return Date.now() < headlightLockUntil;
}
function clearTimers() {
@@ -43,7 +37,7 @@ async function forceAllLightsOff(ctx) {
async function stopDarkness(ctx, effect = {}) {
clearTimers();
nightVisionLockUntil = 0;
headlightLockUntil = 0;
const prevLights = Array.isArray(effect.prevLights) ? effect.prevLights : [];
await Promise.all(
@@ -73,17 +67,17 @@ async function stopDarkness(ctx, effect = {}) {
ctx.logger.warn('darkness restore light lock failed', { error: err.message });
}
const prevNightVision = effect.prevNightVision && typeof effect.prevNightVision === 'object'
? effect.prevNightVision
const prevHeadlights = effect.prevHeadlights && typeof effect.prevHeadlights === 'object'
? effect.prevHeadlights
: {};
Object.entries(prevNightVision).forEach(([roverId, wasOn]) => {
Object.entries(prevHeadlights).forEach(([roverId, wasOn]) => {
try {
ctx.issueCommand(String(roverId), {
type: 'nightVision',
nightVision: { action: actionForNightVisionState(Boolean(wasOn)) },
type: 'headlight',
headlight: { action: Boolean(wasOn) ? 'on' : 'off' },
});
} catch (err) {
ctx.logger.warn('darkness restore nightVision failed', { roverId, error: err.message });
ctx.logger.warn('darkness restore headlight failed', { roverId, error: err.message });
}
});
@@ -94,7 +88,7 @@ async function startDarkness(ctx, effect) {
clearTimers();
const endsAt = Number(effect.endsAt || Date.now() + DURATION_MS);
const remaining = Math.max(0, endsAt - Date.now());
nightVisionLockUntil = endsAt;
headlightLockUntil = endsAt;
try {
await ctx.setHomeAssistantLightsLockedOn(true, {
source: 'buttonbox:darkness',
@@ -122,31 +116,31 @@ async function startDarkness(ctx, effect) {
module.exports = {
id: 'darkness',
name: 'Darkness',
isNightVisionBlocked,
isHeadlightBlocked,
goal: 400,
async run(ctx) {
const entities = ctx.getHomeAssistantEntities();
const prevLights = entities.map((entity) => ({ id: entity.id, state: entity.state === 'on' ? 'on' : 'off' }));
await forceAllLightsOff(ctx);
const prevNightVision = {};
const prevHeadlights = {};
ctx.listOnlineRovers().forEach((rover) => {
const state = rover?.nightVision?.state;
const nightVisionOn = Boolean(state && state.nightVisionOn === true);
prevNightVision[String(rover.id)] = nightVisionOn;
const state = rover?.headlight?.state;
const headlightOn = Boolean(state && state.headlightOn === true);
prevHeadlights[String(rover.id)] = headlightOn;
try {
ctx.issueCommand(String(rover.id), {
type: 'nightVision',
nightVision: { action: actionForNightVisionState(false) },
type: 'headlight',
headlight: { action: 'off' },
});
} catch (err) {
ctx.logger.warn('darkness nightVision off failed', { roverId: rover.id, error: err.message });
ctx.logger.warn('darkness headlight off failed', { roverId: rover.id, error: err.message });
}
});
const prevPolicy = ctx.getHomeAssistantLightPolicy?.() || null;
const prevLightLockState = prevPolicy?.lockState || (prevPolicy?.lockedOn ? 'on' : null);
const effect = { endsAt: Date.now() + DURATION_MS, prevLights, prevNightVision, prevLightLockState };
const effect = { endsAt: Date.now() + DURATION_MS, prevLights, prevHeadlights, prevLightLockState };
await startDarkness(ctx, effect);
ctx.sendAlert({ color: '#212121', title: 'Darkness', message: 'Darkness effect active for 120 seconds.' });
},
+4 -4
View File
@@ -7,7 +7,7 @@ const roverManager = require('../roverManager');
const { isAdmin, isLockdownAdmin } = require('../roleService');
const { isDeterred } = require('../verificationService');
const logger = require('../../globals/logger').child('commandService');
const { isNightVisionBlocked } = require('../../rewards/definitions/darkness');
const { isHeadlightBlocked } = require('../../rewards/definitions/darkness');
const pendingCommands = new Map(); // id -> { roverId }
const lastDriveActivity = new Map(); // roverId -> { ts, socketId, direction, speed, isAdmin }
@@ -132,7 +132,7 @@ function shouldRecordTurnActivity(type, payload = {}) {
/*
Other accepted commands are left as activity because they are tied to an
explicit user control: servo moves, night vision toggles, raw OI commands,
explicit user control: servo moves, headlight/laser toggles, raw OI commands,
songs, reboot/update admin actions, and similar commands.
*/
return true;
@@ -158,8 +158,8 @@ io.on('connection', (socket) => {
if (type === 'audioLevels') {
throw new Error('audioLevels command is service-managed');
}
if (type === 'nightVision' && isNightVisionBlocked()) {
logger.info('Ignoring night vision command while darkness lock is active', { socketId: socket.id, roverId });
if (type === 'headlight' && isHeadlightBlocked()) {
logger.info('Ignoring headlight command while darkness lock is active', { socketId: socket.id, roverId });
reply({ ignored: true, reason: 'darknessActive' });
return;
}
+6 -6
View File
@@ -8,7 +8,7 @@ const homeAssistantService = require('../homeAssistantService');
const neatoService = require('../neatoService');
const liftService = require('../liftService');
const {
NIGHT_VISION_DISABLE_ACTION,
HEADLIGHT_DISABLE_ACTION,
DOCK_COMMAND_BASE64,
} = require('./constants');
@@ -60,7 +60,7 @@ async function dockAllRovers() {
return { action: 'dockAllRovers', attempted, failed };
}
async function disableAllRoverNightVision() {
async function disableAllRoverHeadlights() {
const attempted = [];
const failed = [];
roverManager.rovers.forEach((record) => {
@@ -68,15 +68,15 @@ async function disableAllRoverNightVision() {
const roverId = String(record.id);
try {
issueCommand(roverId, {
type: 'nightVision',
nightVision: { action: NIGHT_VISION_DISABLE_ACTION },
type: 'headlight',
headlight: { action: HEADLIGHT_DISABLE_ACTION },
});
attempted.push(roverId);
} catch (err) {
failed.push({ roverId, error: err.message });
}
});
return { action: 'disableRoverNightVision', attempted, failed };
return { action: 'disableRoverHeadlights', attempted, failed };
}
async function sendNeatoHome() {
@@ -100,7 +100,7 @@ async function raiseLift() {
const idleActions = [
turnOffRoomControls,
// dockAllRovers,
disableAllRoverNightVision,
disableAllRoverHeadlights,
sendNeatoHome,
raiseLift,
];
+2 -2
View File
@@ -2,11 +2,11 @@
// Purpose: Defines idle timing and command constants used by idle automation workflows.
// Scope: Centralizes immutable configuration for trigger windows and rover command payloads.
const IDLE_TIMEOUT_MS = 2 * 60 * 1000;
const NIGHT_VISION_DISABLE_ACTION = 'on';
const HEADLIGHT_DISABLE_ACTION = 'off';
const DOCK_COMMAND_BASE64 = Buffer.from([143]).toString('base64');
module.exports = {
IDLE_TIMEOUT_MS,
NIGHT_VISION_DISABLE_ACTION,
HEADLIGHT_DISABLE_ACTION,
DOCK_COMMAND_BASE64,
};
@@ -23,6 +23,24 @@ function coerceBool(value) {
const HEARTBEAT_INTERVAL_MS = 15000;
function handleToggleEvent(roverId, msg) {
if (msg.event === 'headlight.state') {
const headlightOn = coerceBool(msg.data?.headlightOn);
if (headlightOn != null) {
roverManager.setToggleState(roverId, 'headlight', headlightOn);
return true;
}
}
if (msg.event === 'laser.state') {
const laserOn = coerceBool(msg.data?.laserOn);
if (laserOn != null) {
roverManager.setToggleState(roverId, 'laser', laserOn);
return true;
}
}
return false;
}
function handleMessage(roverId, msg) {
switch (msg.type) {
case 'hello':
@@ -38,9 +56,7 @@ function handleMessage(roverId, msg) {
roverManager.handleHostStats(roverId, msg);
break;
case 'event': {
const nightVisionOn = coerceBool(msg.data?.nightVisionOn);
if (msg.event === 'nightVision.state' && nightVisionOn != null) {
roverManager.setNightVisionState(roverId, nightVisionOn);
if (handleToggleEvent(roverId, msg)) {
break;
}
sendAlert({ color: ALERT_COLOR, title: `${roverId} event`, message: msg.event });
@@ -100,10 +116,7 @@ roverWSS.on('connection', (ws) => {
} else if (msg.type === 'ack') {
handleAck(msg);
} else if (msg.type === 'event') {
const nightVisionOn = coerceBool(msg.data?.nightVisionOn);
if (msg.event === 'nightVision.state' && nightVisionOn != null) {
roverManager.setNightVisionState(roverId, nightVisionOn);
} else {
if (!handleToggleEvent(roverId, msg)) {
sendAlert({ color: ALERT_COLOR, title: `${roverId}`, message: msg.event });
}
}
+2 -2
View File
@@ -127,7 +127,7 @@ const {
getRoster,
getRosterForSocket,
broadcastRoster,
setNightVisionState,
setToggleState,
handleHostStats,
canSeeRover,
canRequestControl,
@@ -261,7 +261,7 @@ module.exports = {
getRoster,
getRosterForSocket,
broadcastRoster,
setNightVisionState,
setToggleState,
handleHostStats,
handleSensorFrame,
requestControl,
@@ -40,7 +40,8 @@ function createRosterLifecycle(deps) {
locked: false,
lockReason: null,
batteryState: null,
nightVisionState: null,
headlightState: null,
laserState: null,
room: `rover:${id}`,
lastSeen: Date.now(),
lastMovementAt: Date.now(),
@@ -73,10 +74,19 @@ function createRosterLifecycle(deps) {
} else {
record.privateOpen = true;
}
if (record.nightVisionState == null && meta?.nightVision?.enabled) {
const ledOn = Boolean(meta.nightVision.initialOn);
record.nightVisionState = {
nightVisionOn: !ledOn,
if (!meta?.headlight?.enabled) {
record.headlightState = null;
} else if (record.headlightState == null) {
record.headlightState = {
headlightOn: Boolean(meta.headlight.initialOn),
updatedAt: Date.now(),
};
}
if (!meta?.laser?.enabled) {
record.laserState = null;
} else if (record.laserState == null) {
record.laserState = {
laserOn: Boolean(meta.laser.initialOn),
updatedAt: Date.now(),
};
}
@@ -221,9 +231,12 @@ function createRosterLifecycle(deps) {
cameraServo: record.meta?.cameraServo,
audio: record.meta?.audio,
horn: record.meta?.horn,
nightVision: record.meta?.nightVision
? { ...record.meta.nightVision, state: record.nightVisionState }
: record.meta?.nightVision,
headlight: record.meta?.headlight
? { ...record.meta.headlight, state: record.headlightState }
: record.meta?.headlight,
laser: record.meta?.laser
? { ...record.meta.laser, state: record.laserState }
: record.meta?.laser,
locked: record.locked || (isPrivateRecord(record) && !isPrivateOpen(record)),
lockReason: record.lockReason || (isPrivateRecord(record) && !isPrivateOpen(record) ? 'private' : null),
lastSeen: record.lastSeen,
@@ -258,16 +271,19 @@ function createRosterLifecycle(deps) {
});
}
function setNightVisionState(roverId, nightVisionOn) {
function setToggleState(roverId, device, on) {
const record = rovers.get(roverId);
if (!record) return;
if (typeof nightVisionOn !== 'boolean') return;
record.nightVisionState = {
nightVisionOn,
if (typeof on !== 'boolean') return;
if (device !== 'headlight' && device !== 'laser') return;
const stateKey = device === 'headlight' ? 'headlightState' : 'laserState';
const onKey = `${device}On`;
record[stateKey] = {
[onKey]: on,
updatedAt: Date.now(),
};
broadcastRoster();
managerEvents.emit('rover', { roverId, action: 'nightVision', record });
managerEvents.emit('rover', { roverId, action: device, record });
}
function handleHostStats(roverId, msg = {}) {
@@ -316,7 +332,7 @@ function createRosterLifecycle(deps) {
getRosterForSocket,
syncSpectatorRooms,
broadcastRoster,
setNightVisionState,
setToggleState,
handleHostStats,
canSeeRover,
canRequestControl,
@@ -10,7 +10,7 @@ const serverTimezone = config.timezone || null;
const configuredSocials = Array.isArray(config.socials) ? config.socials : null;
const ACTIVITY_SYNC_COOLDOWN_MS = 3000;
const NIGHT_VISION_SYNC_COOLDOWN_MS = 1000;
const GPIO_TOGGLE_SYNC_COOLDOWN_MS = 1000;
const PERIODIC_SYNC_MS = 20000;
module.exports = {
@@ -19,6 +19,6 @@ module.exports = {
serverTimezone,
configuredSocials,
ACTIVITY_SYNC_COOLDOWN_MS,
NIGHT_VISION_SYNC_COOLDOWN_MS,
GPIO_TOGGLE_SYNC_COOLDOWN_MS,
PERIODIC_SYNC_MS,
};
+14 -14
View File
@@ -41,7 +41,7 @@ const {
serverTimezone,
configuredSocials,
ACTIVITY_SYNC_COOLDOWN_MS,
NIGHT_VISION_SYNC_COOLDOWN_MS,
GPIO_TOGGLE_SYNC_COOLDOWN_MS,
PERIODIC_SYNC_MS,
} = require('./constants');
const { getState, setState } = require('./state');
@@ -175,29 +175,29 @@ modeEvents.on('change', () => {
managerEvents.on('rover', (event = {}) => {
const state = getState();
if (event.action === 'nightVision') {
if (event.action === 'headlight' || event.action === 'laser') {
const now = Date.now();
const elapsed = now - state.lastNightVisionSync;
if (elapsed >= NIGHT_VISION_SYNC_COOLDOWN_MS) {
setState({ lastNightVisionSync: now });
logger.info('Night vision update; syncing all clients (immediate)');
const elapsed = now - state.lastGPIOToggleSync;
if (elapsed >= GPIO_TOGGLE_SYNC_COOLDOWN_MS) {
setState({ lastGPIOToggleSync: now });
logger.info('GPIO toggle update; syncing all clients (immediate)');
syncAll();
return;
}
if (!state.pendingNightVisionSync) {
const delay = NIGHT_VISION_SYNC_COOLDOWN_MS - elapsed;
if (!state.pendingGPIOToggleSync) {
const delay = GPIO_TOGGLE_SYNC_COOLDOWN_MS - elapsed;
const timer = setTimeout(() => {
setState({ lastNightVisionSync: Date.now(), pendingNightVisionSync: null });
logger.info('Night vision update; syncing all clients (delayed)');
setState({ lastGPIOToggleSync: Date.now(), pendingGPIOToggleSync: null });
logger.info('GPIO toggle update; syncing all clients (delayed)');
syncAll();
}, delay);
setState({ pendingNightVisionSync: timer });
setState({ pendingGPIOToggleSync: timer });
}
return;
}
if (state.pendingNightVisionSync) {
clearTimeout(state.pendingNightVisionSync);
setState({ pendingNightVisionSync: null });
if (state.pendingGPIOToggleSync) {
clearTimeout(state.pendingGPIOToggleSync);
setState({ pendingGPIOToggleSync: null });
}
logger.info('Rover roster change; syncing all clients');
syncAll();
+8 -8
View File
@@ -3,15 +3,15 @@
// Scope: Keeps runtime behavior unchanged while centralizing mutable session-sync state in one module.
let lastActivitySync = 0;
let pendingActivitySync = null;
let lastNightVisionSync = 0;
let pendingNightVisionSync = null;
let lastGPIOToggleSync = 0;
let pendingGPIOToggleSync = null;
function getState() {
return {
lastActivitySync,
pendingActivitySync,
lastNightVisionSync,
pendingNightVisionSync,
lastGPIOToggleSync,
pendingGPIOToggleSync,
};
}
@@ -22,11 +22,11 @@ function setState(patch = {}) {
if (Object.prototype.hasOwnProperty.call(patch, 'pendingActivitySync')) {
pendingActivitySync = patch.pendingActivitySync;
}
if (Object.prototype.hasOwnProperty.call(patch, 'lastNightVisionSync')) {
lastNightVisionSync = patch.lastNightVisionSync;
if (Object.prototype.hasOwnProperty.call(patch, 'lastGPIOToggleSync')) {
lastGPIOToggleSync = patch.lastGPIOToggleSync;
}
if (Object.prototype.hasOwnProperty.call(patch, 'pendingNightVisionSync')) {
pendingNightVisionSync = patch.pendingNightVisionSync;
if (Object.prototype.hasOwnProperty.call(patch, 'pendingGPIOToggleSync')) {
pendingGPIOToggleSync = patch.pendingGPIOToggleSync;
}
}