mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
idle service and lght lock improvements
This commit is contained in:
@@ -52,12 +52,11 @@ function createLightsCommand({ homeAssistantService, sanitizeMentions, discordCo
|
||||
// The bot command intentionally calls the shared policy setter instead of
|
||||
// issuing direct Home Assistant entity commands. That keeps all secondary
|
||||
// behavior centralized: web UI controls become disabled through the
|
||||
// session lightPolicy update, lock-on still forces configured lights to
|
||||
// white where possible, and commandService sees the same update event that
|
||||
// forces rover lasers off while the room is locked on.
|
||||
// session lightPolicy update, entering lock-on still sets configured
|
||||
// lights to white where possible once, and commandService sees the same
|
||||
// update event that forces rover lasers off while the room is locked on.
|
||||
await homeAssistantService.setLightsLockedOn(locked, {
|
||||
source: `bot-command:lights:${action}`,
|
||||
forceApply: true,
|
||||
});
|
||||
|
||||
await message.reply({
|
||||
|
||||
@@ -35,11 +35,26 @@ function registerHomeAssistantHooks(deps) {
|
||||
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 = () => {}) => {
|
||||
if (!hasPermission()) {
|
||||
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
||||
}
|
||||
if (isLightControlLocked()) {
|
||||
if (isBlockedByRoomControlLock()) {
|
||||
return cb({ error: 'Room controls are locked' });
|
||||
}
|
||||
try {
|
||||
@@ -55,7 +70,7 @@ function registerHomeAssistantHooks(deps) {
|
||||
if (!hasPermission()) {
|
||||
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
||||
}
|
||||
if (isLightControlLocked()) {
|
||||
if (isBlockedByRoomControlLock()) {
|
||||
return cb({ error: 'Room controls are locked' });
|
||||
}
|
||||
try {
|
||||
@@ -71,7 +86,7 @@ function registerHomeAssistantHooks(deps) {
|
||||
if (!hasPermission()) {
|
||||
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
||||
}
|
||||
if (isLightControlLocked()) {
|
||||
if (isBlockedByRoomControlLock()) {
|
||||
return cb({ error: 'Room controls are locked' });
|
||||
}
|
||||
try {
|
||||
@@ -90,7 +105,7 @@ function registerHomeAssistantHooks(deps) {
|
||||
if (!hasPermission()) {
|
||||
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
||||
}
|
||||
if (isLightControlLocked()) {
|
||||
if (isBlockedByRoomControlLock()) {
|
||||
return cb({ error: 'Room controls are locked' });
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -426,14 +426,26 @@ function createRuntimeEngine(deps) {
|
||||
async function setLightsLockedOn(nextValue, options = {}) {
|
||||
const next = Boolean(nextValue);
|
||||
const targetState = options?.targetState === 'off' ? 'off' : 'on';
|
||||
const forceApply = Boolean(options.forceApply);
|
||||
const nextLockState = next ? targetState : null;
|
||||
const changed = runtime.lightsLockState !== nextLockState;
|
||||
runtime.lightsLockState = nextLockState;
|
||||
|
||||
if (runtime.lightsLockState != null) {
|
||||
if ((changed || forceApply) && enabled) {
|
||||
if (changed && enabled) {
|
||||
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') {
|
||||
// The lock-on path is intentionally stronger than a normal bulk
|
||||
// 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) {
|
||||
const lockedOn = await toggleLightsLockedOn({
|
||||
source: 'ha-button:lightsLockToggle',
|
||||
forceApply: true,
|
||||
});
|
||||
const message = lockedOn ? LIGHTS_LOCKED_TTS : LIGHTS_UNLOCKED_TTS;
|
||||
sendTtsToNonPrivateRovers(message);
|
||||
|
||||
@@ -73,6 +73,12 @@ function clearIdleTimer() {
|
||||
|
||||
function scheduleIdleTimer() {
|
||||
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;
|
||||
logger.info('Idle timer scheduled', {
|
||||
timeoutMs: IDLE_TIMEOUT_MS,
|
||||
@@ -87,6 +93,13 @@ function scheduleIdleTimer() {
|
||||
return;
|
||||
}
|
||||
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();
|
||||
logger.info('Idle automation executed', {
|
||||
idleMs: IDLE_TIMEOUT_MS,
|
||||
@@ -102,6 +115,15 @@ function refreshIdleState() {
|
||||
logger.info('Idle state refresh', activity);
|
||||
if (activity.totalActive > 0) {
|
||||
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;
|
||||
}
|
||||
scheduleIdleTimer();
|
||||
|
||||
@@ -5,6 +5,7 @@ const runtime = {
|
||||
timer: null,
|
||||
deadlineAt: null,
|
||||
lastTriggeredAt: null,
|
||||
idleActionsCompleted: false,
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
|
||||
Reference in New Issue
Block a user