diff --git a/rulesdocs/refactor_regressions.md b/rulesdocs/refactor_regressions.md index b433e3c1..7793ad4b 100644 --- a/rulesdocs/refactor_regressions.md +++ b/rulesdocs/refactor_regressions.md @@ -1,3 +1,9 @@ -- discord verification and access requests dont work -- home assistant idle lights are all messed up. remove from home assistant and make new idle service -- snapshots are not working \ No newline at end of file +- discord verification and private access requests dont work. missing important info compared to before, and reactions dont work. +- replays are sent twice sometimes, look into it. +- home assistant idle lights are all messed up. remove from home assistant and make new idle service. + - idle service will trigger, after 2 minutes of no drivers: + - all room lights (room controls) off + - tell all rovers to dock + - turn off all rover night vision lights + - tell the neato to return to home + - the idle service should be easily expandable to add more things in the future diff --git a/server/index.js b/server/index.js index 846eeecf..27521fca 100644 --- a/server/index.js +++ b/server/index.js @@ -32,6 +32,7 @@ require('./src/services/embedHttpService'); require('./src/services/logStreamService'); require('./src/services/adminLogService'); require('./src/services/homeAssistantService'); +require('./src/services/idleService'); require('./src/services/neatoService'); require('./src/services/audioLevelsService'); require('./src/services/audioForwardService'); diff --git a/server/src/services/discordBotService/integrations/dmModeration.js b/server/src/services/discordBotService/integrations/dmModeration.js index eb086243..c8f167de 100644 --- a/server/src/services/discordBotService/integrations/dmModeration.js +++ b/server/src/services/discordBotService/integrations/dmModeration.js @@ -25,7 +25,16 @@ function createDmModerationHandlers(deps) { const payload = event?.payload || {}; const requestId = payload.id; if (!requestId) return; - const content = [`**Verification Request**`, `Request ID: \`${requestId}\``, `Nickname: ${sanitizeMentions(payload.nickname || 'unknown')}`, '', `React with ${APPROVE} to approve or ${DENY} to deny.`].join('\n'); + const content = [ + '**Verification Request**', + `Request ID: \`${requestId}\``, + `Nickname: ${sanitizeMentions(payload.nickname || 'unknown')}`, + `Identity Key: \`${String(payload.cookieUserId || 'unknown')}\``, + `IP: \`${String(payload.ip || 'unknown')}\``, + `Socket: \`${String(payload.socketId || 'unknown')}\``, + '', + `React with ${APPROVE} to approve or ${DENY} to deny.`, + ].join('\n'); await Promise.all(Array.from(lockdownAdminIds).map(async (adminId) => { try { const user = await client.users.fetch(String(adminId)); @@ -44,7 +53,20 @@ function createDmModerationHandlers(deps) { const payload = event?.payload || {}; const requestId = payload.id; if (!requestId) return; - const content = [`**Private Rover Access Request**`, `Request ID: \`${requestId}\``, '', `React with ${APPROVE} to approve or ${DENY} to deny.`].join('\n'); + const requester = payload?.requester || {}; + const content = [ + '**Private Rover Access Request**', + `Request ID: \`${requestId}\``, + `Rover: ${sanitizeMentions(payload.roverName || payload.roverId || 'unknown')} (\`${String(payload.roverId || 'unknown')}\`)`, + `Nickname: ${sanitizeMentions(requester.nickname || 'unknown')}`, + `Role: \`${String(requester.role || 'unknown')}\``, + `Verified: \`${requester.isVerified ? 'yes' : 'no'}\``, + `Identity Key: \`${String(requester.cookieUserId || 'unknown')}\``, + `IP: \`${String(requester.ip || 'unknown')}\``, + `Socket: \`${String(requester.socketId || 'unknown')}\``, + '', + `React with ${APPROVE} to approve or ${DENY} to deny.`, + ].join('\n'); await Promise.all(Array.from(lockdownAdminIds).map(async (adminId) => { try { const user = await client.users.fetch(String(adminId)); diff --git a/server/src/services/discordBotService/integrations/index.js b/server/src/services/discordBotService/integrations/index.js index 88bd22bf..bd9f4fe0 100644 --- a/server/src/services/discordBotService/integrations/index.js +++ b/server/src/services/discordBotService/integrations/index.js @@ -19,7 +19,7 @@ function createIntegrations(deps) { const dm = createDmModerationHandlers({ ...deps, sanitizeMentions }); const chat = createChatBridgeHandlers({ ...deps, clearTypingMessage, sendTypingMessage, formatWebhookUsername, getTypingId }); - const { handleBusEvent, handleReplayRequested } = createBusEventHandler({ ...deps, sendToChannel, schedulePresenceRotation, formatDuration }); + const { handleBusEvent } = createBusEventHandler({ ...deps, sendToChannel, schedulePresenceRotation, formatDuration }); function register() { client.on('typingStart', (typing) => { @@ -32,7 +32,6 @@ function createIntegrations(deps) { }); subscribe('*', handleBusEvent); - subscribe('replay.requested', handleReplayRequested); subscribe('verification.requested', dm.sendVerificationRequestDms); subscribe('privateRoverAccess.requested', dm.sendPrivateRoverAccessRequestDms); subscribe('chat:message', chat.handleChatBridgeOutbound); diff --git a/server/src/services/homeAssistantService/hooks.js b/server/src/services/homeAssistantService/hooks.js index 1bab35b0..4c827417 100644 --- a/server/src/services/homeAssistantService/hooks.js +++ b/server/src/services/homeAssistantService/hooks.js @@ -4,13 +4,11 @@ const io = require('../../globals/io'); const { getMode, MODES, modeEvents } = require('../modeManager'); const { isAdmin, isLockdownAdmin } = require('../roleService'); -const { turnEvents } = require('../turnService'); function registerHomeAssistantHooks(deps) { const { logger, haConfig, - evaluateLightAutomation, isLightControlLocked, setLightsLockedOn, toggleEntity, @@ -19,26 +17,14 @@ function registerHomeAssistantHooks(deps) { setLightWhite, } = deps; - turnEvents.on('activeDriver', () => { - evaluateLightAutomation(); - }); - - turnEvents.on('queue', () => { - evaluateLightAutomation(); - }); - modeEvents.on('change', (mode) => { if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) { if (isLightControlLocked()) { setLightsLockedOn(false, { source: 'modeGateReset' }).catch((err) => { logger.warn('Failed to disable lights lock on mode change', err.message); }); - } else { - evaluateLightAutomation(); } - return; } - evaluateLightAutomation(); }); io.on('connection', (socket) => { diff --git a/server/src/services/homeAssistantService/index.js b/server/src/services/homeAssistantService/index.js index 6a553991..b1fe54fb 100644 --- a/server/src/services/homeAssistantService/index.js +++ b/server/src/services/homeAssistantService/index.js @@ -36,12 +36,10 @@ callHomeAssistantServiceImpl = transport.callHomeAssistantService; runtimeEngine.loadEntityConfig(); runtimeEngine.loadTriggerConfig(); transport.connect(); -runtimeEngine.evaluateLightAutomation(); registerHomeAssistantHooks({ logger, haConfig, - evaluateLightAutomation: runtimeEngine.evaluateLightAutomation, isLightControlLocked: runtimeEngine.isLightControlLocked, setLightsLockedOn: runtimeEngine.setLightsLockedOn, toggleEntity: runtimeEngine.toggleEntity, @@ -57,11 +55,13 @@ module.exports = { getLightPolicyState: runtimeEngine.getLightPolicyState, isLightControlLocked: runtimeEngine.isLightControlLocked, getRawEntitySnapshot: runtimeEngine.getRawEntitySnapshot, + getControllableEntityIds: runtimeEngine.getControllableEntityIds, callHomeAssistantService: transport.callHomeAssistantService, toggleEntity: runtimeEngine.toggleEntity, setEntityState: runtimeEngine.setEntityState, setLightColor: runtimeEngine.setLightColor, setLightWhite: runtimeEngine.setLightWhite, + setAllControllableEntitiesState: runtimeEngine.setAllControllableEntitiesState, setLightsLockedOn: runtimeEngine.setLightsLockedOn, toggleLightsLockedOn: runtimeEngine.toggleLightsLockedOn, homeAssistantEvents: events, diff --git a/server/src/services/homeAssistantService/runtimeEngine.js b/server/src/services/homeAssistantService/runtimeEngine.js index 4605bc2c..10e13ebd 100644 --- a/server/src/services/homeAssistantService/runtimeEngine.js +++ b/server/src/services/homeAssistantService/runtimeEngine.js @@ -1,11 +1,8 @@ // Home Assistant Runtime Engine // Purpose: Implements entity/trigger processing, light automation policy, and exposed control operations. // Scope: Owns business logic while transport and event wiring are delegated to companion modules. -const roverManager = require('../roverManager'); -const { issueCommand } = require('../commandService'); -const { publishEvent } = require('../eventBus'); const { getMode } = require('../modeManager'); -const { getActiveDrivers } = require('../turnService'); +const { publishEvent } = require('../eventBus'); const { events, entityConfig, @@ -13,9 +10,7 @@ const { triggerConfig, triggerRuntime, HA_BUTTON_EVENT_TYPE, - LIGHT_IDLE_OFF_MS, DEFAULT_WHITE_KELVIN, - NIGHT_VISION_DISABLE_ACTION, runtime, } = require('./state'); const { normalizeConfigEntry, normalizeTriggerEntry, buildState } = require('./entityHelpers'); @@ -69,56 +64,6 @@ function createRuntimeEngine(deps) { return Array.from(entityConfig.values()).map((meta) => String(meta.id)); } - function getActiveDriverCount() { - const active = getActiveDrivers(); - const turnCount = active && typeof active === 'object' ? Object.keys(active).length : 0; - if (turnCount > 0) return turnCount; - let liveCount = 0; - roverManager.rovers.forEach((record) => { - if (record?.drivers?.size > 0) { - liveCount += 1; - } - }); - return liveCount; - } - - function hasActiveDrivers() { - return getActiveDriverCount() > 0; - } - - function turnOffAllRoverNightVision() { - const records = Array.from(roverManager.rovers.values()); - let attempted = 0; - let failed = 0; - const roverIds = []; - records.forEach((record) => { - if (!record?.ws) return; - roverIds.push(String(record.id)); - attempted += 1; - try { - issueCommand(record.id, { - type: 'nightVision', - nightVision: { action: NIGHT_VISION_DISABLE_ACTION }, - }); - } catch (err) { - failed += 1; - logger.warn('Failed to auto turn off rover night vision after idle', { roverId: record.id, error: err.message }); - } - }); - return { attempted, failed, roverIds }; - } - - function clearLightsIdleOffTimer(getState) { - if (runtime.lightsIdleOffTimer) { - clearTimeout(runtime.lightsIdleOffTimer); - runtime.lightsIdleOffTimer = null; - } - if (runtime.lightsIdleOffDeadline != null) { - runtime.lightsIdleOffDeadline = null; - emitUpdate(getState); - } - } - async function setEntityState(entityId, desiredState) { if (!enabled) throw new Error('Home Assistant not configured'); const meta = entityConfig.get(entityId); @@ -146,35 +91,6 @@ function createRuntimeEngine(deps) { } } - function scheduleLightsIdleOffTimer(getState, evaluateLightAutomation) { - if (!enabled) return; - if (getControllableEntityIds().length === 0) return; - if (runtime.lightsIdleOffTimer || runtime.lightsLockState != null || hasActiveDrivers()) { - return; - } - runtime.lightsIdleOffDeadline = Date.now() + LIGHT_IDLE_OFF_MS; - runtime.lightsIdleOffTimer = setTimeout(async () => { - runtime.lightsIdleOffTimer = null; - runtime.lightsIdleOffDeadline = null; - try { - await setAllControllableEntitiesState('off'); - const nightVisionResult = turnOffAllRoverNightVision(); - logger.info('Auto-turned off room lights due to no active drivers', { - idleMs: LIGHT_IDLE_OFF_MS, - nightVisionRovers: nightVisionResult.attempted, - nightVisionFailures: nightVisionResult.failed, - nightVisionRoverIds: nightVisionResult.roverIds, - }); - } catch (err) { - logger.warn('Failed auto light-off after idle', err.message); - } finally { - emitUpdate(getState); - evaluateLightAutomation(); - } - }, LIGHT_IDLE_OFF_MS); - emitUpdate(getState); - } - function triggerMatches(trigger, raw, runtimeState) { if (!raw) return false; const nextState = raw?.state ?? null; @@ -247,9 +163,9 @@ function createRuntimeEngine(deps) { locked: runtime.lightsLockState != null, lockState: runtime.lightsLockState, lockedOn: runtime.lightsLockState === 'on', - idleOffMs: LIGHT_IDLE_OFF_MS, - idleOffAt: runtime.lightsIdleOffDeadline, - activeDrivers: getActiveDriverCount(), + idleOffMs: null, + idleOffAt: null, + activeDrivers: null, }; } @@ -264,15 +180,7 @@ function createRuntimeEngine(deps) { } function evaluateLightAutomation() { - if (runtime.lightsLockState != null) { - clearLightsIdleOffTimer(getState); - return; - } - if (hasActiveDrivers()) { - clearLightsIdleOffTimer(getState); - return; - } - scheduleLightsIdleOffTimer(getState, evaluateLightAutomation); + // Idle automation moved to idleService; HA service only owns explicit room-control lock behavior. } function handleEntitySnapshot(snapshot = {}) { @@ -341,7 +249,6 @@ function createRuntimeEngine(deps) { runtime.lightsLockState = nextLockState; if (runtime.lightsLockState != null) { - clearLightsIdleOffTimer(getState); if ((changed || forceApply) && enabled) { await setAllControllableEntitiesState(runtime.lightsLockState); } @@ -379,10 +286,12 @@ function createRuntimeEngine(deps) { getLightPolicyState, isLightControlLocked, getRawEntitySnapshot, + getControllableEntityIds, toggleEntity, setEntityState, setLightColor, setLightWhite, + setAllControllableEntitiesState, setLightsLockedOn, toggleLightsLockedOn, }; diff --git a/server/src/services/homeAssistantService/state.js b/server/src/services/homeAssistantService/state.js index 0e8a0e29..b3b138f7 100644 --- a/server/src/services/homeAssistantService/state.js +++ b/server/src/services/homeAssistantService/state.js @@ -10,9 +10,7 @@ const triggerConfig = []; const triggerRuntime = new Map(); const HA_BUTTON_EVENT_TYPE = 'ha.button.action'; -const LIGHT_IDLE_OFF_MS = 2 * 60 * 1000; const DEFAULT_WHITE_KELVIN = 4000; -const NIGHT_VISION_DISABLE_ACTION = 'on'; const runtime = { latestEntitySnapshot: {}, @@ -21,8 +19,6 @@ const runtime = { reconnectTimer: null, connected: false, lightsLockState: null, - lightsIdleOffTimer: null, - lightsIdleOffDeadline: null, }; module.exports = { @@ -32,8 +28,6 @@ module.exports = { triggerConfig, triggerRuntime, HA_BUTTON_EVENT_TYPE, - LIGHT_IDLE_OFF_MS, DEFAULT_WHITE_KELVIN, - NIGHT_VISION_DISABLE_ACTION, runtime, }; diff --git a/server/src/services/idleService/actions.js b/server/src/services/idleService/actions.js new file mode 100644 index 00000000..14e3324d --- /dev/null +++ b/server/src/services/idleService/actions.js @@ -0,0 +1,86 @@ +// Idle Service Actions +// Purpose: Implements the expandable action pipeline executed when the system remains idle. +// Scope: Provides isolated action handlers and shared execution helpers for idle automation. +const logger = require('../../globals/logger').child('idleService'); +const roverManager = require('../roverManager'); +const { issueCommand } = require('../commandService'); +const homeAssistantService = require('../homeAssistantService'); +const neatoService = require('../neatoService'); +const { + NIGHT_VISION_DISABLE_ACTION, + DOCK_COMMAND_BASE64, +} = require('./constants'); + +async function turnOffRoomControls() { + await homeAssistantService.setAllControllableEntitiesState('off'); + return { action: 'roomControlsOff' }; +} + +async function dockAllRovers() { + const attempted = []; + const failed = []; + roverManager.rovers.forEach((record) => { + if (!record?.ws) return; + const roverId = String(record.id); + try { + issueCommand(roverId, { type: 'raw', raw: DOCK_COMMAND_BASE64 }); + attempted.push(roverId); + } catch (err) { + failed.push({ roverId, error: err.message }); + } + }); + return { action: 'dockAllRovers', attempted, failed }; +} + +async function disableAllRoverNightVision() { + const attempted = []; + const failed = []; + roverManager.rovers.forEach((record) => { + if (!record?.ws) return; + const roverId = String(record.id); + try { + issueCommand(roverId, { + type: 'nightVision', + nightVision: { action: NIGHT_VISION_DISABLE_ACTION }, + }); + attempted.push(roverId); + } catch (err) { + failed.push({ roverId, error: err.message }); + } + }); + return { action: 'disableRoverNightVision', attempted, failed }; +} + +async function sendNeatoHome() { + try { + await neatoService.sendHome(); + return { action: 'neatoSendHome', success: true }; + } catch (err) { + return { action: 'neatoSendHome', success: false, error: err.message }; + } +} + +const idleActions = [ + turnOffRoomControls, + dockAllRovers, + disableAllRoverNightVision, + sendNeatoHome, +]; + +async function runIdleActions() { + const results = []; + for (const action of idleActions) { + try { + const result = await action(); + results.push({ ok: true, result }); + } catch (err) { + logger.warn('Idle action failed', { action: action.name, error: err.message }); + results.push({ ok: false, action: action.name, error: err.message }); + } + } + return results; +} + +module.exports = { + runIdleActions, +}; diff --git a/server/src/services/idleService/constants.js b/server/src/services/idleService/constants.js new file mode 100644 index 00000000..612cf838 --- /dev/null +++ b/server/src/services/idleService/constants.js @@ -0,0 +1,12 @@ +// Idle Service Constants +// 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 DOCK_COMMAND_BASE64 = Buffer.from([143]).toString('base64'); + +module.exports = { + IDLE_TIMEOUT_MS, + NIGHT_VISION_DISABLE_ACTION, + DOCK_COMMAND_BASE64, +}; diff --git a/server/src/services/idleService/index.js b/server/src/services/idleService/index.js new file mode 100644 index 00000000..91778cce --- /dev/null +++ b/server/src/services/idleService/index.js @@ -0,0 +1,65 @@ +// Idle Service +// Purpose: Triggers a modular idle action pipeline after a sustained no-driver period. +// Scope: Observes driver activity events and coordinates timer-based idle automation execution. +const logger = require('../../globals/logger').child('idleService'); +const { getActiveDrivers, turnEvents } = require('../turnService'); +const roverManager = require('../roverManager'); +const { IDLE_TIMEOUT_MS } = require('./constants'); +const { runtime } = require('./state'); +const { runIdleActions } = require('./actions'); + +function getActiveDriverCount() { + const active = getActiveDrivers(); + const turnCount = active && typeof active === 'object' ? Object.keys(active).length : 0; + if (turnCount > 0) return turnCount; + let liveCount = 0; + roverManager.rovers.forEach((record) => { + if (record?.drivers?.size > 0) liveCount += 1; + }); + return liveCount; +} + +function clearIdleTimer() { + if (runtime.timer) { + clearTimeout(runtime.timer); + runtime.timer = null; + } + runtime.deadlineAt = null; +} + +function scheduleIdleTimer() { + if (runtime.timer) return; + runtime.deadlineAt = Date.now() + IDLE_TIMEOUT_MS; + runtime.timer = setTimeout(async () => { + runtime.timer = null; + runtime.deadlineAt = null; + if (getActiveDriverCount() > 0) { + return; + } + runtime.lastTriggeredAt = Date.now(); + const results = await runIdleActions(); + logger.info('Idle automation executed', { + idleMs: IDLE_TIMEOUT_MS, + resultCount: results.length, + failures: results.filter((entry) => !entry.ok).length, + }); + refreshIdleState(); + }, IDLE_TIMEOUT_MS); +} + +function refreshIdleState() { + if (getActiveDriverCount() > 0) { + clearIdleTimer(); + return; + } + scheduleIdleTimer(); +} + +turnEvents.on('activeDriver', refreshIdleState); +turnEvents.on('queue', refreshIdleState); + +refreshIdleState(); + +module.exports = { + refreshIdleState, +}; diff --git a/server/src/services/idleService/state.js b/server/src/services/idleService/state.js new file mode 100644 index 00000000..49efc878 --- /dev/null +++ b/server/src/services/idleService/state.js @@ -0,0 +1,12 @@ +// Idle Service State +// Purpose: Stores runtime timer and bookkeeping state for idle automation scheduling. +// Scope: Encapsulates mutable idle-tracking data shared by idle service modules. +const runtime = { + timer: null, + deadlineAt: null, + lastTriggeredAt: null, +}; + +module.exports = { + runtime, +};