update darkness reward

This commit is contained in:
legop3
2026-04-19 01:34:04 -04:00
parent 1bdae74ee5
commit e974f4f90f
9 changed files with 125 additions and 52 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
You are The Overseer of the rovers. You are The Overseer of the rovers. You are observing and studying them. You cannot control them.
Output contract: Output contract:
- Output must be either SKIP if you want to stay silent, or a message if you want to speak.. - Output must be either SKIP if you want to stay silent, or a message if you want to speak.
- No markdown. - No markdown.
Key legend: Key legend:
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -11,7 +11,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" /> <meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title> <title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-CZe46TcG.js"></script> <script type="module" crossorigin src="/assets/index-CCaG2_WI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C3BucANX.css"> <link rel="stylesheet" crossorigin href="/assets/index-C3BucANX.css">
</head> </head>
<body> <body>
+75 -20
View File
@@ -1,12 +1,41 @@
const DURATION_MS = 60 * 1000; const DURATION_MS = 2 * 60 * 1000;
const LIGHT_ENFORCE_TICK_MS = 3000;
let activeTimer = null; let activeTimer = null;
let enforceLightsTimer = null;
let nightVisionLockUntil = 0;
async function stopDarkness(ctx, effect = {}) { function isNightVisionBlocked() {
return Date.now() < nightVisionLockUntil;
}
function clearTimers() {
if (activeTimer) { if (activeTimer) {
clearTimeout(activeTimer); clearTimeout(activeTimer);
activeTimer = null; activeTimer = null;
} }
if (enforceLightsTimer) {
clearInterval(enforceLightsTimer);
enforceLightsTimer = null;
}
}
async function forceAllLightsOff(ctx) {
const entities = ctx.getHomeAssistantEntities();
await Promise.all(
entities.map(async (entity) => {
try {
await ctx.setHomeAssistantEntityState(entity.id, 'off');
} catch (err) {
ctx.logger.warn('darkness force light off failed', { entityId: entity.id, error: err.message });
}
}),
);
}
async function stopDarkness(ctx, effect = {}) {
clearTimers();
nightVisionLockUntil = 0;
const prevLights = Array.isArray(effect.prevLights) ? effect.prevLights : []; const prevLights = Array.isArray(effect.prevLights) ? effect.prevLights : [];
await Promise.all( await Promise.all(
@@ -19,6 +48,23 @@ async function stopDarkness(ctx, effect = {}) {
}), }),
); );
try {
const prevLockState = effect?.prevLightLockState;
if (prevLockState === 'on' || prevLockState === 'off') {
await ctx.setHomeAssistantLightsLockedOn(true, {
source: 'buttonbox:darknessRestore',
forceApply: true,
targetState: prevLockState,
});
} else {
await ctx.setHomeAssistantLightsLockedOn(false, {
source: 'buttonbox:darknessRestore',
});
}
} catch (err) {
ctx.logger.warn('darkness restore light lock failed', { error: err.message });
}
const prevNightVision = effect.prevNightVision && typeof effect.prevNightVision === 'object' const prevNightVision = effect.prevNightVision && typeof effect.prevNightVision === 'object'
? effect.prevNightVision ? effect.prevNightVision
: {}; : {};
@@ -36,14 +82,28 @@ async function stopDarkness(ctx, effect = {}) {
ctx.clearEffect('darkness'); ctx.clearEffect('darkness');
} }
function startDarkness(ctx, effect) { async function startDarkness(ctx, effect) {
if (activeTimer) { clearTimers();
clearTimeout(activeTimer);
activeTimer = null;
}
const endsAt = Number(effect.endsAt || Date.now() + DURATION_MS); const endsAt = Number(effect.endsAt || Date.now() + DURATION_MS);
const remaining = Math.max(0, endsAt - Date.now()); const remaining = Math.max(0, endsAt - Date.now());
nightVisionLockUntil = endsAt;
try {
await ctx.setHomeAssistantLightsLockedOn(true, {
source: 'buttonbox:darkness',
forceApply: true,
targetState: 'off',
});
} catch (err) {
ctx.logger.warn('darkness set light lock failed', { error: err.message });
}
ctx.saveEffect('darkness', effect); ctx.saveEffect('darkness', effect);
enforceLightsTimer = setInterval(() => {
forceAllLightsOff(ctx).catch((err) => {
ctx.logger.warn('darkness periodic light enforcement failed', { error: err.message });
});
}, LIGHT_ENFORCE_TICK_MS);
activeTimer = setTimeout(() => { activeTimer = setTimeout(() => {
stopDarkness(ctx, effect).catch((err) => { stopDarkness(ctx, effect).catch((err) => {
ctx.logger.warn('darkness stop failed', { error: err.message }); ctx.logger.warn('darkness stop failed', { error: err.message });
@@ -54,19 +114,12 @@ function startDarkness(ctx, effect) {
module.exports = { module.exports = {
id: 'darkness', id: 'darkness',
name: 'Darkness', name: 'Darkness',
isNightVisionBlocked,
goal: 650, goal: 650,
async run(ctx) { async run(ctx) {
const entities = ctx.getHomeAssistantEntities(); const entities = ctx.getHomeAssistantEntities();
const prevLights = entities.map((entity) => ({ id: entity.id, state: entity.state === 'on' ? 'on' : 'off' })); const prevLights = entities.map((entity) => ({ id: entity.id, state: entity.state === 'on' ? 'on' : 'off' }));
await Promise.all( await forceAllLightsOff(ctx);
entities.map(async (entity) => {
try {
await ctx.setHomeAssistantEntityState(entity.id, 'off');
} catch (err) {
ctx.logger.warn('darkness light off failed', { entityId: entity.id, error: err.message });
}
}),
);
const prevNightVision = {}; const prevNightVision = {};
ctx.listOnlineRovers().forEach((rover) => { ctx.listOnlineRovers().forEach((rover) => {
@@ -83,15 +136,17 @@ module.exports = {
} }
}); });
const effect = { endsAt: Date.now() + DURATION_MS, prevLights, prevNightVision }; const prevPolicy = ctx.getHomeAssistantLightPolicy?.() || null;
startDarkness(ctx, effect); const prevLightLockState = prevPolicy?.lockState || (prevPolicy?.lockedOn ? 'on' : null);
ctx.sendAlert({ color: '#212121', title: 'Darkness', message: 'Darkness effect active for 60 seconds.' }); const effect = { endsAt: Date.now() + DURATION_MS, prevLights, prevNightVision, prevLightLockState };
await startDarkness(ctx, effect);
ctx.sendAlert({ color: '#212121', title: 'Darkness', message: 'Darkness effect active for 120 seconds.' });
}, },
async recover(ctx, effect) { async recover(ctx, effect) {
if (!effect || Number(effect.endsAt || 0) <= Date.now()) { if (!effect || Number(effect.endsAt || 0) <= Date.now()) {
await stopDarkness(ctx, effect || {}); await stopDarkness(ctx, effect || {});
return; return;
} }
startDarkness(ctx, effect); await startDarkness(ctx, effect);
}, },
}; };
+4
View File
@@ -16,6 +16,7 @@ const assignmentService = require('./assignmentService');
const { const {
getState: getHomeAssistantState, getState: getHomeAssistantState,
setEntityState: setHomeAssistantEntityState, setEntityState: setHomeAssistantEntityState,
setLightsLockedOn: setHomeAssistantLightsLockedOn,
} = require('./homeAssistantService'); } = require('./homeAssistantService');
const { getRequestIp, isLocalNetwork, normalizeIp } = require('../helpers/ipResolver'); const { getRequestIp, isLocalNetwork, normalizeIp } = require('../helpers/ipResolver');
@@ -175,7 +176,10 @@ function buildRewardContext() {
const entities = getHomeAssistantState()?.entities; const entities = getHomeAssistantState()?.entities;
return Array.isArray(entities) ? entities : []; return Array.isArray(entities) ? entities : [];
}, },
getHomeAssistantLightPolicy: () => getHomeAssistantState()?.lightPolicy || null,
setHomeAssistantEntityState, setHomeAssistantEntityState,
setHomeAssistantLightsLockedOn: (next, options = {}) =>
setHomeAssistantLightsLockedOn(next, options),
saveEffect: (effectId, payload = {}) => saveEffect(effectId, payload, { broadcast: false }), saveEffect: (effectId, payload = {}) => saveEffect(effectId, payload, { broadcast: false }),
clearEffect: (effectId) => clearEffect(effectId, { broadcast: false }), clearEffect: (effectId) => clearEffect(effectId, { broadcast: false }),
}; };
+6
View File
@@ -3,6 +3,7 @@ const io = require('../globals/io');
const roverManager = require('./roverManager'); const roverManager = require('./roverManager');
const { isAdmin, isLockdownAdmin } = require('./roleService'); const { isAdmin, isLockdownAdmin } = require('./roleService');
const logger = require('../globals/logger').child('commandService'); const logger = require('../globals/logger').child('commandService');
const { isNightVisionBlocked } = require('../rewards/definitions/darkness');
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 }
@@ -70,6 +71,11 @@ 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');
} }
if (type === 'nightVision' && isNightVisionBlocked()) {
logger.info('Ignoring night vision command while darkness lock is active', { socketId: socket.id, roverId });
reply({ ignored: true, reason: 'darknessActive' });
return;
}
const payload = data ? { ...data } : {}; const payload = data ? { ...data } : {};
const isRebootCommand = type === 'reboot'; const isRebootCommand = type === 'reboot';
const isSongCommand = type === 'song' || (type === 'raw' && isSongRawPayload(payload)); const isSongCommand = type === 'song' || (type === 'raw' && isSongRawPayload(payload));
+21 -16
View File
@@ -29,7 +29,7 @@ let connection = null;
let unsubscribeEntities = null; let unsubscribeEntities = null;
let reconnectTimer = null; let reconnectTimer = null;
let connected = false; let connected = false;
let lightsLockedOn = false; let lightsLockState = null; // null | 'on' | 'off'
let lightsIdleOffTimer = null; let lightsIdleOffTimer = null;
let lightsIdleOffDeadline = null; let lightsIdleOffDeadline = null;
@@ -215,7 +215,7 @@ function clearLightsIdleOffTimer() {
function scheduleLightsIdleOffTimer() { function scheduleLightsIdleOffTimer() {
if (!enabled) return; if (!enabled) return;
if (getControllableEntityIds().length === 0) return; if (getControllableEntityIds().length === 0) return;
if (lightsIdleOffTimer || lightsLockedOn || hasActiveDrivers()) { if (lightsIdleOffTimer || lightsLockState != null || hasActiveDrivers()) {
return; return;
} }
lightsIdleOffDeadline = Date.now() + LIGHT_IDLE_OFF_MS; lightsIdleOffDeadline = Date.now() + LIGHT_IDLE_OFF_MS;
@@ -238,7 +238,7 @@ function scheduleLightsIdleOffTimer() {
} }
function evaluateLightAutomation() { function evaluateLightAutomation() {
if (lightsLockedOn) { if (lightsLockState != null) {
clearLightsIdleOffTimer(); clearLightsIdleOffTimer();
return; return;
} }
@@ -464,12 +464,14 @@ async function setLightColor(entityId, rgbColor) {
} }
function isLightControlLocked() { function isLightControlLocked() {
return lightsLockedOn; return lightsLockState != null;
} }
function getLightPolicyState() { function getLightPolicyState() {
return { return {
lockedOn: lightsLockedOn, locked: lightsLockState != null,
lockState: lightsLockState,
lockedOn: lightsLockState === 'on',
idleOffMs: LIGHT_IDLE_OFF_MS, idleOffMs: LIGHT_IDLE_OFF_MS,
idleOffAt: lightsIdleOffDeadline, idleOffAt: lightsIdleOffDeadline,
activeDrivers: getActiveDriverCount(), activeDrivers: getActiveDriverCount(),
@@ -478,14 +480,16 @@ function getLightPolicyState() {
async function setLightsLockedOn(nextValue, options = {}) { async function setLightsLockedOn(nextValue, options = {}) {
const next = Boolean(nextValue); const next = Boolean(nextValue);
const targetState = options?.targetState === 'off' ? 'off' : 'on';
const forceApply = Boolean(options.forceApply); const forceApply = Boolean(options.forceApply);
const changed = lightsLockedOn !== next; const nextLockState = next ? targetState : null;
lightsLockedOn = next; const changed = lightsLockState !== nextLockState;
if (lightsLockedOn) { lightsLockState = nextLockState;
if (lightsLockState != null) {
clearLightsIdleOffTimer(); clearLightsIdleOffTimer();
if (changed || forceApply) { if (changed || forceApply) {
if (enabled) { if (enabled) {
await setAllControllableEntitiesState('on'); await setAllControllableEntitiesState(lightsLockState);
} }
} }
} else { } else {
@@ -493,16 +497,17 @@ async function setLightsLockedOn(nextValue, options = {}) {
} }
if (changed) { if (changed) {
logger.info('Room lights lock state changed', { logger.info('Room lights lock state changed', {
lockedOn: lightsLockedOn, locked: lightsLockState != null,
lockState: lightsLockState,
source: options.source || 'unknown', source: options.source || 'unknown',
}); });
} }
emitUpdate(); emitUpdate();
return lightsLockedOn; return lightsLockState === 'on';
} }
async function toggleLightsLockedOn(options = {}) { async function toggleLightsLockedOn(options = {}) {
return setLightsLockedOn(!lightsLockedOn, options); return setLightsLockedOn(lightsLockState == null, options);
} }
function getState() { function getState() {
@@ -532,7 +537,7 @@ turnEvents.on('queue', () => {
modeEvents.on('change', (mode) => { modeEvents.on('change', (mode) => {
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) { if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
if (lightsLockedOn) { if (isLightControlLocked()) {
setLightsLockedOn(false, { source: 'modeGateReset' }).catch((err) => { setLightsLockedOn(false, { source: 'modeGateReset' }).catch((err) => {
logger.warn('Failed to disable lights lock on mode change', err.message); logger.warn('Failed to disable lights lock on mode change', err.message);
}); });
@@ -554,7 +559,7 @@ io.on('connection', (socket) => {
return cb({ error: 'Insufficient permissions to control Home Assistant' }); return cb({ error: 'Insufficient permissions to control Home Assistant' });
} }
if (isLightControlLocked()) { if (isLightControlLocked()) {
return cb({ error: 'Room controls are locked on' }); return cb({ error: 'Room controls are locked' });
} }
try { try {
if (!entityId) throw new Error('entityId required'); if (!entityId) throw new Error('entityId required');
@@ -574,7 +579,7 @@ io.on('connection', (socket) => {
return cb({ error: 'Insufficient permissions to control Home Assistant' }); return cb({ error: 'Insufficient permissions to control Home Assistant' });
} }
if (isLightControlLocked()) { if (isLightControlLocked()) {
return cb({ error: 'Room controls are locked on' }); return cb({ error: 'Room controls are locked' });
} }
try { try {
@@ -595,7 +600,7 @@ io.on('connection', (socket) => {
return cb({ error: 'Insufficient permissions to control Home Assistant' }); return cb({ error: 'Insufficient permissions to control Home Assistant' });
} }
if (isLightControlLocked()) { if (isLightControlLocked()) {
return cb({ error: 'Room controls are locked on' }); return cb({ error: 'Room controls are locked' });
} }
try { try {
@@ -213,7 +213,8 @@ export default function HomeAssistantControls() {
const ha = session?.homeAssistant; const ha = session?.homeAssistant;
const entities = useMemo(() => ha?.entities || [], [ha?.entities]); const entities = useMemo(() => ha?.entities || [], [ha?.entities]);
const lightPolicy = ha?.lightPolicy || null; const lightPolicy = ha?.lightPolicy || null;
const controlsLocked = Boolean(lightPolicy?.lockedOn); const controlsLocked = Boolean(lightPolicy?.locked || lightPolicy?.lockedOn);
const lockState = lightPolicy?.lockState || (lightPolicy?.lockedOn ? 'on' : null);
const onKeyLabel = formatKeyLabel(keymap?.homeAssistantOn?.[0]); const onKeyLabel = formatKeyLabel(keymap?.homeAssistantOn?.[0]);
const offKeyLabel = formatKeyLabel(keymap?.homeAssistantOff?.[0]); const offKeyLabel = formatKeyLabel(keymap?.homeAssistantOff?.[0]);
@@ -243,7 +244,7 @@ export default function HomeAssistantControls() {
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
<p>Room Controls</p> <p>Room Controls</p>
<span className="text-xs text-slate-500">{entities.length}</span> <span className="text-xs text-slate-500">{entities.length}</span>
{controlsLocked ? <StatusBadge label="Locked On" tone="warn" /> : null} {controlsLocked ? <StatusBadge label={lockState === 'off' ? 'Locked Off' : 'Locked On'} tone="warn" /> : null}
<div className="flex items-center gap-0.5 text-xs text-slate-300 background-black"> <div className="flex items-center gap-0.5 text-xs text-slate-300 background-black">
<span className="flex items-center gap-0.5"> <span className="flex items-center gap-0.5">
<span>On</span> <span>On</span>
@@ -259,7 +260,9 @@ export default function HomeAssistantControls() {
</header> </header>
{controlsLocked ? ( {controlsLocked ? (
<p className="rounded border border-amber-600/60 bg-amber-900/40 px-1 py-0.5 text-xs text-amber-100"> <p className="rounded border border-amber-600/60 bg-amber-900/40 px-1 py-0.5 text-xs text-amber-100">
Lights are locked on. Room controls are disabled. {lockState === 'off'
? 'Lights are locked off. Room controls are disabled.'
: 'Lights are locked on. Room controls are disabled.'}
</p> </p>
) : null} ) : null}
<div className="flex flex-wrap gap-0.5"> <div className="flex flex-wrap gap-0.5">
@@ -317,7 +317,7 @@ export default function KeyboardInputManager() {
(targetState) => { (targetState) => {
const ha = session?.homeAssistant; const ha = session?.homeAssistant;
if (!ha?.enabled || !ha?.connected) return; if (!ha?.enabled || !ha?.connected) return;
if (ha?.lightPolicy?.lockedOn) return; if (ha?.lightPolicy?.locked || ha?.lightPolicy?.lockedOn) return;
const entities = ha.entities || []; const entities = ha.entities || [];
const eligible = entities.filter( const eligible = entities.filter(
(ent) => (ent) =>