idle service and lght lock improvements

This commit is contained in:
legop3
2026-07-14 17:38:17 -04:00
parent 0d6b4d68de
commit 7fe5730953
14 changed files with 126 additions and 49 deletions
File diff suppressed because one or more lines are too long
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/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-DfbLYlWD.js"></script> <script type="module" crossorigin src="/assets/index-CmNL6XEv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-sm_EKOxJ.css"> <link rel="stylesheet" crossorigin href="/assets/index-sm_EKOxJ.css">
</head> </head>
<body> <body>
+7 -13
View File
@@ -1,10 +1,8 @@
// Reward Definition: Darkness // Reward Definition: Darkness
// Purpose: Defines the darkness reward that alters visibility/lighting behavior. Scope: Encapsulates reward metadata and effect configuration for runtime execution. // 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 DURATION_MS = 15 * 60 * 1000;
const LIGHT_ENFORCE_TICK_MS = 3000;
let activeTimer = null; let activeTimer = null;
let enforceLightsTimer = null;
let headlightLockUntil = 0; let headlightLockUntil = 0;
function isHeadlightBlocked() { function isHeadlightBlocked() {
@@ -16,10 +14,6 @@ function clearTimers() {
clearTimeout(activeTimer); clearTimeout(activeTimer);
activeTimer = null; activeTimer = null;
} }
if (enforceLightsTimer) {
clearInterval(enforceLightsTimer);
enforceLightsTimer = null;
}
} }
async function forceAllLightsOff(ctx) { async function forceAllLightsOff(ctx) {
@@ -55,7 +49,6 @@ async function stopDarkness(ctx, effect = {}) {
if (prevLockState === 'on' || prevLockState === 'off') { if (prevLockState === 'on' || prevLockState === 'off') {
await ctx.setHomeAssistantLightsLockedOn(true, { await ctx.setHomeAssistantLightsLockedOn(true, {
source: 'buttonbox:darknessRestore', source: 'buttonbox:darknessRestore',
forceApply: true,
targetState: prevLockState, targetState: prevLockState,
}); });
} else { } else {
@@ -92,7 +85,6 @@ async function startDarkness(ctx, effect) {
try { try {
await ctx.setHomeAssistantLightsLockedOn(true, { await ctx.setHomeAssistantLightsLockedOn(true, {
source: 'buttonbox:darkness', source: 'buttonbox:darkness',
forceApply: true,
targetState: 'off', targetState: 'off',
}); });
} catch (err) { } catch (err) {
@@ -100,11 +92,13 @@ async function startDarkness(ctx, effect) {
} }
ctx.saveEffect('darkness', effect); ctx.saveEffect('darkness', effect);
enforceLightsTimer = setInterval(() => { /*
forceAllLightsOff(ctx).catch((err) => { Darkness locks the room-light policy off and performs the initial off
ctx.logger.warn('darkness periodic light enforcement failed', { error: err.message }); command through setHomeAssistantLightsLockedOn above. It deliberately does
}); not keep a polling interval that re-forces Home Assistant entities off:
}, LIGHT_ENFORCE_TICK_MS); after the lock is established, out-of-band manual controls must remain able
to change individual room lights without the server fighting them.
*/
activeTimer = setTimeout(() => { activeTimer = setTimeout(() => {
stopDarkness(ctx, effect).catch((err) => { stopDarkness(ctx, effect).catch((err) => {
@@ -52,12 +52,11 @@ function createLightsCommand({ homeAssistantService, sanitizeMentions, discordCo
// The bot command intentionally calls the shared policy setter instead of // The bot command intentionally calls the shared policy setter instead of
// issuing direct Home Assistant entity commands. That keeps all secondary // issuing direct Home Assistant entity commands. That keeps all secondary
// behavior centralized: web UI controls become disabled through the // behavior centralized: web UI controls become disabled through the
// session lightPolicy update, lock-on still forces configured lights to // session lightPolicy update, entering lock-on still sets configured
// white where possible, and commandService sees the same update event that // lights to white where possible once, and commandService sees the same
// forces rover lasers off while the room is locked on. // update event that forces rover lasers off while the room is locked on.
await homeAssistantService.setLightsLockedOn(locked, { await homeAssistantService.setLightsLockedOn(locked, {
source: `bot-command:lights:${action}`, source: `bot-command:lights:${action}`,
forceApply: true,
}); });
await message.reply({ await message.reply({
@@ -35,11 +35,26 @@ function registerHomeAssistantHooks(deps) {
return true; return true;
} }
function isBlockedByRoomControlLock() {
/*
The lock is meant to keep normal users and automated room-control
surfaces from changing the preferred room-light policy. Admins are the
exception because they may need to correct a single lamp, verify a Home
Assistant integration, or make an operational adjustment while the
public controls remain locked.
This server-side bypass is the authoritative rule. The React UI also
enables admin controls for usability, but clients are not trusted to
enforce permissions.
*/
return isLightControlLocked() && !isAdmin(socket);
}
socket.on('homeAssistant:toggle', async ({ entityId } = {}, cb = () => {}) => { socket.on('homeAssistant:toggle', async ({ entityId } = {}, cb = () => {}) => {
if (!hasPermission()) { if (!hasPermission()) {
return cb({ error: 'Insufficient permissions to control Home Assistant' }); return cb({ error: 'Insufficient permissions to control Home Assistant' });
} }
if (isLightControlLocked()) { if (isBlockedByRoomControlLock()) {
return cb({ error: 'Room controls are locked' }); return cb({ error: 'Room controls are locked' });
} }
try { try {
@@ -55,7 +70,7 @@ function registerHomeAssistantHooks(deps) {
if (!hasPermission()) { if (!hasPermission()) {
return cb({ error: 'Insufficient permissions to control Home Assistant' }); return cb({ error: 'Insufficient permissions to control Home Assistant' });
} }
if (isLightControlLocked()) { if (isBlockedByRoomControlLock()) {
return cb({ error: 'Room controls are locked' }); return cb({ error: 'Room controls are locked' });
} }
try { try {
@@ -71,7 +86,7 @@ function registerHomeAssistantHooks(deps) {
if (!hasPermission()) { if (!hasPermission()) {
return cb({ error: 'Insufficient permissions to control Home Assistant' }); return cb({ error: 'Insufficient permissions to control Home Assistant' });
} }
if (isLightControlLocked()) { if (isBlockedByRoomControlLock()) {
return cb({ error: 'Room controls are locked' }); return cb({ error: 'Room controls are locked' });
} }
try { try {
@@ -90,7 +105,7 @@ function registerHomeAssistantHooks(deps) {
if (!hasPermission()) { if (!hasPermission()) {
return cb({ error: 'Insufficient permissions to control Home Assistant' }); return cb({ error: 'Insufficient permissions to control Home Assistant' });
} }
if (isLightControlLocked()) { if (isBlockedByRoomControlLock()) {
return cb({ error: 'Room controls are locked' }); return cb({ error: 'Room controls are locked' });
} }
try { try {
@@ -426,14 +426,26 @@ function createRuntimeEngine(deps) {
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 targetState = options?.targetState === 'off' ? 'off' : 'on';
const forceApply = Boolean(options.forceApply);
const nextLockState = next ? targetState : null; const nextLockState = next ? targetState : null;
const changed = runtime.lightsLockState !== nextLockState; const changed = runtime.lightsLockState !== nextLockState;
runtime.lightsLockState = nextLockState; runtime.lightsLockState = nextLockState;
if (runtime.lightsLockState != null) { if (runtime.lightsLockState != null) {
if ((changed || forceApply) && enabled) { if (changed && enabled) {
const source = String(options?.source || 'homeAssistant:setLightsLockedOn'); const source = String(options?.source || 'homeAssistant:setLightsLockedOn');
/*
A room-light lock is a policy boundary, not an ongoing reconciliation
loop. Entering locked-on or locked-off sets every configured room
control to the preferred state once so the room starts from the
requested condition. After that first transition, the server leaves
Home Assistant alone so out-of-band controls such as wall switches,
Home Assistant dashboards, or vendor apps can still adjust individual
lights without being periodically overwritten.
Older callers may still pass forceApply from the previous behavior.
It is intentionally ignored here because repeated lock requests must
not become repeated light commands.
*/
if (runtime.lightsLockState === 'on') { if (runtime.lightsLockState === 'on') {
// The lock-on path is intentionally stronger than a normal bulk // The lock-on path is intentionally stronger than a normal bulk
// turn_on. It makes actual light entities white while still turning // turn_on. It makes actual light entities white while still turning
@@ -151,7 +151,6 @@ async function handleTrigger(event = {}) {
if (action === LIGHTS_LOCK_TOGGLE_ACTION) { if (action === LIGHTS_LOCK_TOGGLE_ACTION) {
const lockedOn = await toggleLightsLockedOn({ const lockedOn = await toggleLightsLockedOn({
source: 'ha-button:lightsLockToggle', source: 'ha-button:lightsLockToggle',
forceApply: true,
}); });
const message = lockedOn ? LIGHTS_LOCKED_TTS : LIGHTS_UNLOCKED_TTS; const message = lockedOn ? LIGHTS_LOCKED_TTS : LIGHTS_UNLOCKED_TTS;
sendTtsToNonPrivateRovers(message); sendTtsToNonPrivateRovers(message);
+22
View File
@@ -73,6 +73,12 @@ function clearIdleTimer() {
function scheduleIdleTimer() { function scheduleIdleTimer() {
if (runtime.timer) return; if (runtime.timer) return;
if (runtime.idleActionsCompleted) {
logger.info('Idle timer not scheduled; idle actions already completed for this no-operator window', {
lastTriggeredAt: runtime.lastTriggeredAt,
});
return;
}
runtime.deadlineAt = Date.now() + IDLE_TIMEOUT_MS; runtime.deadlineAt = Date.now() + IDLE_TIMEOUT_MS;
logger.info('Idle timer scheduled', { logger.info('Idle timer scheduled', {
timeoutMs: IDLE_TIMEOUT_MS, timeoutMs: IDLE_TIMEOUT_MS,
@@ -87,6 +93,13 @@ function scheduleIdleTimer() {
return; return;
} }
runtime.lastTriggeredAt = Date.now(); runtime.lastTriggeredAt = Date.now();
/*
Mark this idle window as handled before running the action pipeline. The
pipeline can take time and can call into services that emit their own
state changes; setting the guard first prevents any nested refresh from
scheduling a second timer for the same continuous no-operator period.
*/
runtime.idleActionsCompleted = true;
const results = await runIdleActions(); const results = await runIdleActions();
logger.info('Idle automation executed', { logger.info('Idle automation executed', {
idleMs: IDLE_TIMEOUT_MS, idleMs: IDLE_TIMEOUT_MS,
@@ -102,6 +115,15 @@ function refreshIdleState() {
logger.info('Idle state refresh', activity); logger.info('Idle state refresh', activity);
if (activity.totalActive > 0) { if (activity.totalActive > 0) {
clearIdleTimer(); clearIdleTimer();
if (runtime.idleActionsCompleted) {
logger.info('Idle action one-shot reset; operator is online again', activity);
}
/*
A user/admin coming online starts a new activity window. When the room
later becomes idle again, the cleanup pipeline should be allowed to run
once for that new idle period.
*/
runtime.idleActionsCompleted = false;
return; return;
} }
scheduleIdleTimer(); scheduleIdleTimer();
+1
View File
@@ -5,6 +5,7 @@ const runtime = {
timer: null, timer: null,
deadlineAt: null, deadlineAt: null,
lastTriggeredAt: null, lastTriggeredAt: null,
idleActionsCompleted: false,
}; };
module.exports = { module.exports = {
@@ -187,9 +187,13 @@ function HomeAssistantControlsContent() {
const ha = useSessionSelector((state) => state.session?.homeAssistant || null); const ha = useSessionSelector((state) => state.session?.homeAssistant || null);
const { homeAssistantToggle, homeAssistantSetLightColor, homeAssistantSetLightWhite } = const { homeAssistantToggle, homeAssistantSetLightColor, homeAssistantSetLightWhite } =
useSessionActions(); useSessionActions();
const role = useSessionSelector((state) => state.session?.role || null);
const mode = useSessionSelector((state) => state.session?.mode || null);
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?.locked || lightPolicy?.lockedOn); const adminCanControlLockedLights = role === 'lockdown' || (role === 'admin' && mode !== 'lockdown');
const lightPolicyLocked = Boolean(lightPolicy?.locked || lightPolicy?.lockedOn);
const controlsLocked = lightPolicyLocked && !adminCanControlLockedLights;
const lockState = lightPolicy?.lockState || (lightPolicy?.lockedOn ? 'on' : null); 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]);
@@ -224,16 +228,20 @@ function HomeAssistantControlsContent() {
{offKeyLabel ? <KeyPill label={offKeyLabel} /> : null} {offKeyLabel ? <KeyPill label={offKeyLabel} /> : null}
</span> </span>
</div> </div>
{controlsLocked ? <StatusBadge label={lockState === 'off' ? 'Locked Off' : 'Locked On'} tone="warn" /> : null} {lightPolicyLocked ? <StatusBadge label={lockState === 'off' ? 'Locked Off' : 'Locked On'} tone="warn" /> : null}
<StatusBadge label={connected ? 'Connected' : 'Offline'} tone={connected ? 'success' : 'warn'} /> <StatusBadge label={connected ? 'Connected' : 'Offline'} tone={connected ? 'success' : 'warn'} />
</> </>
); );
return ( return (
<CardFrame title="Room Controls" actions={actions} bodyClassName="space-y-0.5 text-base"> <CardFrame title="Room Controls" actions={actions} bodyClassName="space-y-0.5 text-base">
{controlsLocked ? ( {lightPolicyLocked ? (
<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">
{lockState === 'off' {adminCanControlLockedLights
? lockState === 'off'
? 'Lights are locked off. Admin room controls remain available.'
: 'Lights are locked on. Admin room controls remain available.'
: lockState === 'off'
? 'Lights are locked off. Room controls are disabled.' ? 'Lights are locked off. Room controls are disabled.'
: 'Lights are locked on. Room controls are disabled.'} : 'Lights are locked on. Room controls are disabled.'}
</p> </p>
+18 -2
View File
@@ -168,7 +168,12 @@ 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 roomLightsLocked = useSessionSelector((state) =>
Boolean(state.session?.homeAssistant?.lightPolicy?.locked || state.session?.homeAssistant?.lightPolicy?.lockedOn),
);
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(
@@ -183,6 +188,17 @@ export function ControlSystemProvider({ children }) {
const ptzControls = usePtzControlAdapter(); const ptzControls = usePtzControlAdapter();
const turnOnAllLights = useCallback(() => { const turnOnAllLights = useCallback(() => {
/*
Automatic drive-mode lighting is convenience behavior for the open room.
A room-light lock is an explicit policy decision, including locked-off,
so this helper must not issue any Home Assistant commands while that
policy is active. Admins can still use the dedicated room controls when
they need to override individual lamps.
*/
if (roomLightsLocked) {
pendingLightsRef.current = false;
return;
}
const entities = homeAssistantEntities || []; const entities = homeAssistantEntities || [];
const targets = entities.filter( const targets = entities.filter(
(ent) => (ent) =>
@@ -200,7 +216,7 @@ export function ControlSystemProvider({ children }) {
}); });
// Give the loop a chance; clear pending after issuing commands. // Give the loop a chance; clear pending after issuing commands.
pendingLightsRef.current = false; pendingLightsRef.current = false;
}, [homeAssistantEntities, homeAssistantSetState]); }, [homeAssistantEntities, homeAssistantSetState, roomLightsLocked]);
useEffect(() => { useEffect(() => {
dispatch({ type: 'control/set-rover', payload: pipeline.roverId }); dispatch({ type: 'control/set-rover', payload: pipeline.roverId });
@@ -108,6 +108,9 @@ export default function KeyboardInputManager() {
const roverId = useControlSelector((control) => control.state.roverId); const roverId = useControlSelector((control) => control.state.roverId);
const hornActive = useControlSelector((control) => Boolean(control.state.horn?.active)); const hornActive = useControlSelector((control) => Boolean(control.state.horn?.active));
const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null); const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null);
const role = useSessionSelector((state) => state.session?.role || null);
const mode = useSessionSelector((state) => state.session?.mode || null);
const adminCanControlLockedLights = role === 'lockdown' || (role === 'admin' && mode !== 'lockdown');
const dockAssist = useManualDockAssist(); const dockAssist = useManualDockAssist();
const { homeAssistantSetState, pushAlert } = useSessionActions(); const { homeAssistantSetState, pushAlert } = useSessionActions();
const { focusChat } = useChatActions(); const { focusChat } = useChatActions();
@@ -310,7 +313,14 @@ export default function KeyboardInputManager() {
const latest = latestRef.current; const latest = latestRef.current;
const ha = latest?.homeAssistant; const ha = latest?.homeAssistant;
if (!ha?.enabled || !ha?.connected) return; if (!ha?.enabled || !ha?.connected) return;
if (ha?.lightPolicy?.locked || ha?.lightPolicy?.lockedOn) return; /*
Room-light lock disables keyboard cycling for normal users because those
shortcuts are part of the public room-control surface. Admin sessions are
allowed through when the current site mode would also allow their socket
command, so keyboard behavior matches the server-side authorization and
the clickable Room Controls panel.
*/
if ((ha?.lightPolicy?.locked || ha?.lightPolicy?.lockedOn) && !latest?.adminCanControlLockedLights) return;
const entities = ha.entities || []; const entities = ha.entities || [];
const eligible = entities.filter( const eligible = entities.filter(
(ent) => (ent) =>
@@ -371,6 +381,7 @@ export default function KeyboardInputManager() {
homeAssistant, homeAssistant,
homeAssistantSetState, homeAssistantSetState,
isChatFocused, isChatFocused,
adminCanControlLockedLights,
keyboardSpeeds, keyboardSpeeds,
keymap, keymap,
nudgeServo, nudgeServo,