This commit is contained in:
legop3
2026-04-29 18:45:41 -04:00
parent 01a8d044e7
commit 9bf449ca19
12 changed files with 219 additions and 127 deletions
+9 -3
View File
@@ -1,3 +1,9 @@
- discord verification and access requests dont work - discord verification and private access requests dont work. missing important info compared to before, and reactions dont work.
- home assistant idle lights are all messed up. remove from home assistant and make new idle service - replays are sent twice sometimes, look into it.
- snapshots are not working - 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
+1
View File
@@ -32,6 +32,7 @@ require('./src/services/embedHttpService');
require('./src/services/logStreamService'); require('./src/services/logStreamService');
require('./src/services/adminLogService'); require('./src/services/adminLogService');
require('./src/services/homeAssistantService'); require('./src/services/homeAssistantService');
require('./src/services/idleService');
require('./src/services/neatoService'); require('./src/services/neatoService');
require('./src/services/audioLevelsService'); require('./src/services/audioLevelsService');
require('./src/services/audioForwardService'); require('./src/services/audioForwardService');
@@ -25,7 +25,16 @@ function createDmModerationHandlers(deps) {
const payload = event?.payload || {}; const payload = event?.payload || {};
const requestId = payload.id; const requestId = payload.id;
if (!requestId) return; 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) => { await Promise.all(Array.from(lockdownAdminIds).map(async (adminId) => {
try { try {
const user = await client.users.fetch(String(adminId)); const user = await client.users.fetch(String(adminId));
@@ -44,7 +53,20 @@ function createDmModerationHandlers(deps) {
const payload = event?.payload || {}; const payload = event?.payload || {};
const requestId = payload.id; const requestId = payload.id;
if (!requestId) return; 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) => { await Promise.all(Array.from(lockdownAdminIds).map(async (adminId) => {
try { try {
const user = await client.users.fetch(String(adminId)); const user = await client.users.fetch(String(adminId));
@@ -19,7 +19,7 @@ function createIntegrations(deps) {
const dm = createDmModerationHandlers({ ...deps, sanitizeMentions }); const dm = createDmModerationHandlers({ ...deps, sanitizeMentions });
const chat = createChatBridgeHandlers({ ...deps, clearTypingMessage, sendTypingMessage, formatWebhookUsername, getTypingId }); 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() { function register() {
client.on('typingStart', (typing) => { client.on('typingStart', (typing) => {
@@ -32,7 +32,6 @@ function createIntegrations(deps) {
}); });
subscribe('*', handleBusEvent); subscribe('*', handleBusEvent);
subscribe('replay.requested', handleReplayRequested);
subscribe('verification.requested', dm.sendVerificationRequestDms); subscribe('verification.requested', dm.sendVerificationRequestDms);
subscribe('privateRoverAccess.requested', dm.sendPrivateRoverAccessRequestDms); subscribe('privateRoverAccess.requested', dm.sendPrivateRoverAccessRequestDms);
subscribe('chat:message', chat.handleChatBridgeOutbound); subscribe('chat:message', chat.handleChatBridgeOutbound);
@@ -4,13 +4,11 @@
const io = require('../../globals/io'); const io = require('../../globals/io');
const { getMode, MODES, modeEvents } = require('../modeManager'); const { getMode, MODES, modeEvents } = require('../modeManager');
const { isAdmin, isLockdownAdmin } = require('../roleService'); const { isAdmin, isLockdownAdmin } = require('../roleService');
const { turnEvents } = require('../turnService');
function registerHomeAssistantHooks(deps) { function registerHomeAssistantHooks(deps) {
const { const {
logger, logger,
haConfig, haConfig,
evaluateLightAutomation,
isLightControlLocked, isLightControlLocked,
setLightsLockedOn, setLightsLockedOn,
toggleEntity, toggleEntity,
@@ -19,26 +17,14 @@ function registerHomeAssistantHooks(deps) {
setLightWhite, setLightWhite,
} = deps; } = deps;
turnEvents.on('activeDriver', () => {
evaluateLightAutomation();
});
turnEvents.on('queue', () => {
evaluateLightAutomation();
});
modeEvents.on('change', (mode) => { modeEvents.on('change', (mode) => {
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) { if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
if (isLightControlLocked()) { 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);
}); });
} else {
evaluateLightAutomation();
} }
return;
} }
evaluateLightAutomation();
}); });
io.on('connection', (socket) => { io.on('connection', (socket) => {
@@ -36,12 +36,10 @@ callHomeAssistantServiceImpl = transport.callHomeAssistantService;
runtimeEngine.loadEntityConfig(); runtimeEngine.loadEntityConfig();
runtimeEngine.loadTriggerConfig(); runtimeEngine.loadTriggerConfig();
transport.connect(); transport.connect();
runtimeEngine.evaluateLightAutomation();
registerHomeAssistantHooks({ registerHomeAssistantHooks({
logger, logger,
haConfig, haConfig,
evaluateLightAutomation: runtimeEngine.evaluateLightAutomation,
isLightControlLocked: runtimeEngine.isLightControlLocked, isLightControlLocked: runtimeEngine.isLightControlLocked,
setLightsLockedOn: runtimeEngine.setLightsLockedOn, setLightsLockedOn: runtimeEngine.setLightsLockedOn,
toggleEntity: runtimeEngine.toggleEntity, toggleEntity: runtimeEngine.toggleEntity,
@@ -57,11 +55,13 @@ module.exports = {
getLightPolicyState: runtimeEngine.getLightPolicyState, getLightPolicyState: runtimeEngine.getLightPolicyState,
isLightControlLocked: runtimeEngine.isLightControlLocked, isLightControlLocked: runtimeEngine.isLightControlLocked,
getRawEntitySnapshot: runtimeEngine.getRawEntitySnapshot, getRawEntitySnapshot: runtimeEngine.getRawEntitySnapshot,
getControllableEntityIds: runtimeEngine.getControllableEntityIds,
callHomeAssistantService: transport.callHomeAssistantService, callHomeAssistantService: transport.callHomeAssistantService,
toggleEntity: runtimeEngine.toggleEntity, toggleEntity: runtimeEngine.toggleEntity,
setEntityState: runtimeEngine.setEntityState, setEntityState: runtimeEngine.setEntityState,
setLightColor: runtimeEngine.setLightColor, setLightColor: runtimeEngine.setLightColor,
setLightWhite: runtimeEngine.setLightWhite, setLightWhite: runtimeEngine.setLightWhite,
setAllControllableEntitiesState: runtimeEngine.setAllControllableEntitiesState,
setLightsLockedOn: runtimeEngine.setLightsLockedOn, setLightsLockedOn: runtimeEngine.setLightsLockedOn,
toggleLightsLockedOn: runtimeEngine.toggleLightsLockedOn, toggleLightsLockedOn: runtimeEngine.toggleLightsLockedOn,
homeAssistantEvents: events, homeAssistantEvents: events,
@@ -1,11 +1,8 @@
// Home Assistant Runtime Engine // Home Assistant Runtime Engine
// Purpose: Implements entity/trigger processing, light automation policy, and exposed control operations. // 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. // 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 { getMode } = require('../modeManager');
const { getActiveDrivers } = require('../turnService'); const { publishEvent } = require('../eventBus');
const { const {
events, events,
entityConfig, entityConfig,
@@ -13,9 +10,7 @@ const {
triggerConfig, triggerConfig,
triggerRuntime, triggerRuntime,
HA_BUTTON_EVENT_TYPE, HA_BUTTON_EVENT_TYPE,
LIGHT_IDLE_OFF_MS,
DEFAULT_WHITE_KELVIN, DEFAULT_WHITE_KELVIN,
NIGHT_VISION_DISABLE_ACTION,
runtime, runtime,
} = require('./state'); } = require('./state');
const { normalizeConfigEntry, normalizeTriggerEntry, buildState } = require('./entityHelpers'); const { normalizeConfigEntry, normalizeTriggerEntry, buildState } = require('./entityHelpers');
@@ -69,56 +64,6 @@ function createRuntimeEngine(deps) {
return Array.from(entityConfig.values()).map((meta) => String(meta.id)); 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) { async function setEntityState(entityId, desiredState) {
if (!enabled) throw new Error('Home Assistant not configured'); if (!enabled) throw new Error('Home Assistant not configured');
const meta = entityConfig.get(entityId); 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) { function triggerMatches(trigger, raw, runtimeState) {
if (!raw) return false; if (!raw) return false;
const nextState = raw?.state ?? null; const nextState = raw?.state ?? null;
@@ -247,9 +163,9 @@ function createRuntimeEngine(deps) {
locked: runtime.lightsLockState != null, locked: runtime.lightsLockState != null,
lockState: runtime.lightsLockState, lockState: runtime.lightsLockState,
lockedOn: runtime.lightsLockState === 'on', lockedOn: runtime.lightsLockState === 'on',
idleOffMs: LIGHT_IDLE_OFF_MS, idleOffMs: null,
idleOffAt: runtime.lightsIdleOffDeadline, idleOffAt: null,
activeDrivers: getActiveDriverCount(), activeDrivers: null,
}; };
} }
@@ -264,15 +180,7 @@ function createRuntimeEngine(deps) {
} }
function evaluateLightAutomation() { function evaluateLightAutomation() {
if (runtime.lightsLockState != null) { // Idle automation moved to idleService; HA service only owns explicit room-control lock behavior.
clearLightsIdleOffTimer(getState);
return;
}
if (hasActiveDrivers()) {
clearLightsIdleOffTimer(getState);
return;
}
scheduleLightsIdleOffTimer(getState, evaluateLightAutomation);
} }
function handleEntitySnapshot(snapshot = {}) { function handleEntitySnapshot(snapshot = {}) {
@@ -341,7 +249,6 @@ function createRuntimeEngine(deps) {
runtime.lightsLockState = nextLockState; runtime.lightsLockState = nextLockState;
if (runtime.lightsLockState != null) { if (runtime.lightsLockState != null) {
clearLightsIdleOffTimer(getState);
if ((changed || forceApply) && enabled) { if ((changed || forceApply) && enabled) {
await setAllControllableEntitiesState(runtime.lightsLockState); await setAllControllableEntitiesState(runtime.lightsLockState);
} }
@@ -379,10 +286,12 @@ function createRuntimeEngine(deps) {
getLightPolicyState, getLightPolicyState,
isLightControlLocked, isLightControlLocked,
getRawEntitySnapshot, getRawEntitySnapshot,
getControllableEntityIds,
toggleEntity, toggleEntity,
setEntityState, setEntityState,
setLightColor, setLightColor,
setLightWhite, setLightWhite,
setAllControllableEntitiesState,
setLightsLockedOn, setLightsLockedOn,
toggleLightsLockedOn, toggleLightsLockedOn,
}; };
@@ -10,9 +10,7 @@ const triggerConfig = [];
const triggerRuntime = new Map(); const triggerRuntime = new Map();
const HA_BUTTON_EVENT_TYPE = 'ha.button.action'; const HA_BUTTON_EVENT_TYPE = 'ha.button.action';
const LIGHT_IDLE_OFF_MS = 2 * 60 * 1000;
const DEFAULT_WHITE_KELVIN = 4000; const DEFAULT_WHITE_KELVIN = 4000;
const NIGHT_VISION_DISABLE_ACTION = 'on';
const runtime = { const runtime = {
latestEntitySnapshot: {}, latestEntitySnapshot: {},
@@ -21,8 +19,6 @@ const runtime = {
reconnectTimer: null, reconnectTimer: null,
connected: false, connected: false,
lightsLockState: null, lightsLockState: null,
lightsIdleOffTimer: null,
lightsIdleOffDeadline: null,
}; };
module.exports = { module.exports = {
@@ -32,8 +28,6 @@ module.exports = {
triggerConfig, triggerConfig,
triggerRuntime, triggerRuntime,
HA_BUTTON_EVENT_TYPE, HA_BUTTON_EVENT_TYPE,
LIGHT_IDLE_OFF_MS,
DEFAULT_WHITE_KELVIN, DEFAULT_WHITE_KELVIN,
NIGHT_VISION_DISABLE_ACTION,
runtime, runtime,
}; };
@@ -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,
};
@@ -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,
};
+65
View File
@@ -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,
};
+12
View File
@@ -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,
};