mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
homessis
This commit is contained in:
@@ -45,7 +45,7 @@
|
|||||||
- [ ] button box service
|
- [ ] button box service
|
||||||
- [ ] chat service
|
- [ ] chat service
|
||||||
- [ ] discord bot service
|
- [ ] discord bot service
|
||||||
- [ ] home assistant service
|
- [x] home assistant service
|
||||||
- [ ] llm commentary service
|
- [ ] llm commentary service
|
||||||
- [x] private rover access request service
|
- [x] private rover access request service
|
||||||
- [ ] replay services (already partly split; reformat consistently)
|
- [ ] replay services (already partly split; reformat consistently)
|
||||||
@@ -84,6 +84,7 @@
|
|||||||
- Finished `verificationService` decomposition by extracting persisted store handling to `verificationService/store.js`, identity/selector normalization to `verificationService/identity.js`, verification/deterrence/request lifecycle logic to `verificationService/verificationFlow.js`, `verificationService/deterrenceFlow.js`, and `verificationService/requestFlow.js`, plus socket/role event wiring to `verificationService/hooks.js`; `verificationService/index.js` is now a thin composition layer.
|
- Finished `verificationService` decomposition by extracting persisted store handling to `verificationService/store.js`, identity/selector normalization to `verificationService/identity.js`, verification/deterrence/request lifecycle logic to `verificationService/verificationFlow.js`, `verificationService/deterrenceFlow.js`, and `verificationService/requestFlow.js`, plus socket/role event wiring to `verificationService/hooks.js`; `verificationService/index.js` is now a thin composition layer.
|
||||||
- Finished `videoAuthService` decomposition by extracting MediaMTX stream parsing to `videoAuthService/streamParsing.js`, role/mode/stream policy checks to `videoAuthService/policy.js`, and auth HTTP transport wiring to `videoAuthService/httpRoute.js`; `videoAuthService/index.js` is now a thin composition layer.
|
- Finished `videoAuthService` decomposition by extracting MediaMTX stream parsing to `videoAuthService/streamParsing.js`, role/mode/stream policy checks to `videoAuthService/policy.js`, and auth HTTP transport wiring to `videoAuthService/httpRoute.js`; `videoAuthService/index.js` is now a thin composition layer.
|
||||||
- Finished `privateRoverAccessRequestService` decomposition by extracting in-memory maps/events/constants to `privateRoverAccessRequestService/state.js`, shared keying/lookup helpers to `privateRoverAccessRequestService/helpers.js`, request/grant business logic to `privateRoverAccessRequestService/core.js`, and rover/socket event wiring to `privateRoverAccessRequestService/hooks.js`; `privateRoverAccessRequestService/index.js` is now a thin composition layer.
|
- Finished `privateRoverAccessRequestService` decomposition by extracting in-memory maps/events/constants to `privateRoverAccessRequestService/state.js`, shared keying/lookup helpers to `privateRoverAccessRequestService/helpers.js`, request/grant business logic to `privateRoverAccessRequestService/core.js`, and rover/socket event wiring to `privateRoverAccessRequestService/hooks.js`; `privateRoverAccessRequestService/index.js` is now a thin composition layer.
|
||||||
|
- Finished `homeAssistantService` decomposition by extracting shared runtime caches/constants to `homeAssistantService/state.js`, entity/trigger normalization helpers to `homeAssistantService/entityHelpers.js`, automation/state engine logic to `homeAssistantService/runtimeEngine.js`, websocket transport/reconnect lifecycle to `homeAssistantService/transport.js`, and mode/turn/socket event wiring to `homeAssistantService/hooks.js`; `homeAssistantService/index.js` is now a thin composition layer.
|
||||||
|
|
||||||
## WebUI frontend
|
## WebUI frontend
|
||||||
### BIGGEST OFFENDERS
|
### BIGGEST OFFENDERS
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// Home Assistant Entity Helpers
|
||||||
|
// Purpose: Normalizes Home Assistant entity/button config and converts raw snapshots into stable UI state records.
|
||||||
|
// Scope: Provides pure helper logic for config loading, entity typing, and state-shape generation.
|
||||||
|
function inferType(entityId, explicitType) {
|
||||||
|
if (explicitType === 'light' || explicitType === 'switch') {
|
||||||
|
return explicitType;
|
||||||
|
}
|
||||||
|
const domain = String(entityId || '').split('.')[0];
|
||||||
|
if (domain === 'light') return 'light';
|
||||||
|
return 'switch';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfigEntry(entry) {
|
||||||
|
if (!entry) return null;
|
||||||
|
const id = entry.id || entry.entityId || entry.entity_id;
|
||||||
|
if (!id) return null;
|
||||||
|
const type = inferType(id, entry.type);
|
||||||
|
const name = entry.name || null;
|
||||||
|
return { id: String(id), name, type };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTriggerEntry(entry, index) {
|
||||||
|
if (!entry || typeof entry !== 'object') return null;
|
||||||
|
const action = String(entry.action || '').trim();
|
||||||
|
if (!action) return null;
|
||||||
|
const entityId = String(entry.entityId || entry.entity_id || '').trim();
|
||||||
|
if (!entityId) return null;
|
||||||
|
const stateEqualsRaw = entry.stateEquals ?? entry.state_equals;
|
||||||
|
const stateEquals =
|
||||||
|
stateEqualsRaw === null || stateEqualsRaw === undefined ? null : String(stateEqualsRaw).trim();
|
||||||
|
const runtimeKey = `${action}::entity::${entityId}::${stateEquals || '*'}::${index}`;
|
||||||
|
const cooldownMs = Number.isFinite(Number(entry.cooldownMs)) ? Math.max(0, Number(entry.cooldownMs)) : 0;
|
||||||
|
const allowedModes = Array.isArray(entry.allowedModes)
|
||||||
|
? entry.allowedModes.map((mode) => String(mode || '').trim().toLowerCase()).filter(Boolean)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
runtimeKey,
|
||||||
|
entityId,
|
||||||
|
action,
|
||||||
|
stateEquals,
|
||||||
|
payload: entry.payload && typeof entry.payload === 'object' ? entry.payload : {},
|
||||||
|
cooldownMs,
|
||||||
|
allowedModes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildState(meta, raw) {
|
||||||
|
if (!meta) return null;
|
||||||
|
const name = meta.name || raw?.attributes?.friendly_name || meta.id;
|
||||||
|
const supportedColorModes = Array.isArray(raw?.attributes?.supported_color_modes)
|
||||||
|
? raw.attributes.supported_color_modes.map((mode) => String(mode))
|
||||||
|
: [];
|
||||||
|
const rgbColor = Array.isArray(raw?.attributes?.rgb_color) ? raw.attributes.rgb_color : null;
|
||||||
|
const hsColor = Array.isArray(raw?.attributes?.hs_color) ? raw.attributes.hs_color : null;
|
||||||
|
const supportsColor =
|
||||||
|
meta.type === 'light' &&
|
||||||
|
(rgbColor || hsColor || supportedColorModes.some((mode) => mode === 'hs' || mode === 'rgb' || mode === 'xy'));
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return {
|
||||||
|
id: meta.id,
|
||||||
|
name,
|
||||||
|
type: meta.type,
|
||||||
|
state: 'unknown',
|
||||||
|
available: false,
|
||||||
|
lastChanged: null,
|
||||||
|
lastUpdated: null,
|
||||||
|
supportedColorModes,
|
||||||
|
colorMode: null,
|
||||||
|
rgbColor: null,
|
||||||
|
hsColor: null,
|
||||||
|
supportsColor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawState = raw.state;
|
||||||
|
const unavailable = rawState === 'unavailable' || rawState === 'unknown';
|
||||||
|
const state = unavailable ? 'unavailable' : rawState === 'on' ? 'on' : 'off';
|
||||||
|
return {
|
||||||
|
id: meta.id,
|
||||||
|
name,
|
||||||
|
type: meta.type,
|
||||||
|
state,
|
||||||
|
available: !unavailable,
|
||||||
|
lastChanged: raw.last_changed || null,
|
||||||
|
lastUpdated: raw.last_updated || null,
|
||||||
|
supportedColorModes,
|
||||||
|
colorMode: raw?.attributes?.color_mode || null,
|
||||||
|
rgbColor,
|
||||||
|
hsColor,
|
||||||
|
supportsColor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
normalizeConfigEntry,
|
||||||
|
normalizeTriggerEntry,
|
||||||
|
buildState,
|
||||||
|
};
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// Home Assistant Hooks
|
||||||
|
// Purpose: Registers turn/mode/socket event handlers that invoke Home Assistant runtime operations.
|
||||||
|
// Scope: Owns permission-gated socket routes and service-triggered automation reevaluation wiring.
|
||||||
|
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,
|
||||||
|
setEntityState,
|
||||||
|
setLightColor,
|
||||||
|
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) => {
|
||||||
|
function hasPermission() {
|
||||||
|
const mode = getMode();
|
||||||
|
if (mode === 'admin' && isAdmin(socket) !== true) return false;
|
||||||
|
if (mode === 'lockdown' && isLockdownAdmin(socket) !== true) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on('homeAssistant:toggle', async ({ entityId } = {}, cb = () => {}) => {
|
||||||
|
if (!hasPermission()) {
|
||||||
|
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
||||||
|
}
|
||||||
|
if (isLightControlLocked()) {
|
||||||
|
return cb({ error: 'Room controls are locked' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!entityId) throw new Error('entityId required');
|
||||||
|
await toggleEntity(entityId);
|
||||||
|
cb({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('homeAssistant:setState', async ({ entityId, state } = {}, cb = () => {}) => {
|
||||||
|
if (!hasPermission()) {
|
||||||
|
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
||||||
|
}
|
||||||
|
if (isLightControlLocked()) {
|
||||||
|
return cb({ error: 'Room controls are locked' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!entityId) throw new Error('entityId required');
|
||||||
|
await setEntityState(entityId, state);
|
||||||
|
cb({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('homeAssistant:lightColor', async ({ entityId, rgbColor } = {}, cb = () => {}) => {
|
||||||
|
if (!hasPermission()) {
|
||||||
|
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
||||||
|
}
|
||||||
|
if (isLightControlLocked()) {
|
||||||
|
return cb({ error: 'Room controls are locked' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!entityId) throw new Error('entityId required');
|
||||||
|
await setLightColor(entityId, rgbColor);
|
||||||
|
cb({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('homeAssistant:lightWhite', async ({ entityId } = {}, cb = () => {}) => {
|
||||||
|
if (!hasPermission()) {
|
||||||
|
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
||||||
|
}
|
||||||
|
if (isLightControlLocked()) {
|
||||||
|
return cb({ error: 'Room controls are locked' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!entityId) throw new Error('entityId required');
|
||||||
|
await setLightWhite(entityId, haConfig?.whiteKelvin);
|
||||||
|
cb({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
registerHomeAssistantHooks,
|
||||||
|
};
|
||||||
@@ -1,747 +1,68 @@
|
|||||||
// home Assistant Service
|
// Home Assistant Service
|
||||||
// Purpose: Defines the home Assistant Service module and the helpers/state used by this service unit.
|
// Purpose: Composes Home Assistant transport, runtime automation engine, and event/socket hooks.
|
||||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
// Scope: Exposes stable room-control APIs while delegating internals to focused modules.
|
||||||
const EventEmitter = require('events');
|
|
||||||
const WebSocket = require('ws');
|
|
||||||
const { createConnection, subscribeEntities, callService, Auth } = require('home-assistant-js-websocket');
|
|
||||||
const io = require('../../globals/io');
|
|
||||||
const logger = require('../../globals/logger').child('homeAssistantService');
|
const logger = require('../../globals/logger').child('homeAssistantService');
|
||||||
const { loadConfig } = require('../../helpers/configLoader');
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
const { getMode, MODES, modeEvents } = require('../modeManager');
|
const { events } = require('./state');
|
||||||
const { isAdmin, isLockdownAdmin } = require('../roleService');
|
const { createRuntimeEngine } = require('./runtimeEngine');
|
||||||
const roverManager = require('../roverManager');
|
const { createTransport } = require('./transport');
|
||||||
const { issueCommand } = require('../commandService');
|
const { registerHomeAssistantHooks } = require('./hooks');
|
||||||
const { publishEvent } = require('../eventBus');
|
|
||||||
const { getActiveDrivers, turnEvents } = require('../turnService');
|
|
||||||
|
|
||||||
// home-assistant-js-websocket expects a global WebSocket in Node.
|
|
||||||
if (!global.WebSocket) {
|
|
||||||
global.WebSocket = WebSocket;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const haConfig = config.homeAssistant || {};
|
const haConfig = config.homeAssistant || {};
|
||||||
|
|
||||||
const events = new EventEmitter();
|
|
||||||
const entityConfig = new Map(); // entityId -> { id, name, type }
|
|
||||||
const entityState = new Map(); // entityId -> normalized state
|
|
||||||
const triggerConfig = []; // [{ runtimeKey, entityId, action, stateEquals, payload, cooldownMs, allowedModes }]
|
|
||||||
const triggerRuntime = new Map(); // triggerId -> { lastFiredAt, lastState, lastChanged, lastUpdated }
|
|
||||||
const HA_BUTTON_EVENT_TYPE = 'ha.button.action';
|
|
||||||
const LIGHT_IDLE_OFF_MS = 2 * 60 * 1000;
|
|
||||||
const DEFAULT_WHITE_KELVIN = 4000;
|
|
||||||
let latestEntitySnapshot = {};
|
|
||||||
// Rover daemon uses inverted semantics: action "on" powers IR LEDs, which means nightVisionOn=false.
|
|
||||||
const NIGHT_VISION_DISABLE_ACTION = 'on';
|
|
||||||
|
|
||||||
let connection = null;
|
|
||||||
let unsubscribeEntities = null;
|
|
||||||
let reconnectTimer = null;
|
|
||||||
let connected = false;
|
|
||||||
let lightsLockState = null; // null | 'on' | 'off'
|
|
||||||
let lightsIdleOffTimer = null;
|
|
||||||
let lightsIdleOffDeadline = null;
|
|
||||||
|
|
||||||
const enabled = Boolean(haConfig?.url && haConfig?.token);
|
const enabled = Boolean(haConfig?.url && haConfig?.token);
|
||||||
|
|
||||||
function buildAuth() {
|
let callHomeAssistantServiceImpl = async () => {
|
||||||
const token = haConfig?.token?.trim();
|
throw new Error('Home Assistant not connected');
|
||||||
const url = haConfig?.url?.trim();
|
};
|
||||||
if (!token || !url) {
|
|
||||||
throw new Error('Home Assistant url/token missing');
|
|
||||||
}
|
|
||||||
// Long-lived tokens last 10 years; set a far future expiry to avoid refresh attempts.
|
|
||||||
return new Auth(
|
|
||||||
{
|
|
||||||
hassUrl: url.replace(/\/+$/, ''),
|
|
||||||
access_token: token,
|
|
||||||
expires: Date.now() + 1000 * 60 * 60 * 24 * 365 * 10,
|
|
||||||
refresh_token: null,
|
|
||||||
clientId: 'multiroomba-rover',
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferType(entityId, explicitType) {
|
const runtimeEngine = createRuntimeEngine({
|
||||||
if (explicitType === 'light' || explicitType === 'switch') {
|
logger,
|
||||||
return explicitType;
|
enabled,
|
||||||
}
|
haConfig,
|
||||||
const domain = String(entityId || '').split('.')[0];
|
callHomeAssistantService: (...args) => callHomeAssistantServiceImpl(...args),
|
||||||
if (domain === 'light') return 'light';
|
|
||||||
return 'switch';
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeConfigEntry(entry) {
|
|
||||||
if (!entry) return null;
|
|
||||||
const id = entry.id || entry.entityId || entry.entity_id;
|
|
||||||
if (!id) return null;
|
|
||||||
const type = inferType(id, entry.type);
|
|
||||||
const name = entry.name || null;
|
|
||||||
return { id: String(id), name, type };
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadEntityConfig() {
|
|
||||||
entityConfig.clear();
|
|
||||||
const list = Array.isArray(haConfig?.entities) ? haConfig.entities : [];
|
|
||||||
list.forEach((entry) => {
|
|
||||||
const normalized = normalizeConfigEntry(entry);
|
|
||||||
if (normalized) {
|
|
||||||
entityConfig.set(normalized.id, normalized);
|
|
||||||
if (!entityState.has(normalized.id)) {
|
|
||||||
entityState.set(normalized.id, buildState(normalized, null));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
logger.info('Loaded Home Assistant entities', { count: entityConfig.size });
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTriggerEntry(entry, index) {
|
|
||||||
if (!entry || typeof entry !== 'object') return null;
|
|
||||||
const action = String(entry.action || '').trim();
|
|
||||||
if (!action) return null;
|
|
||||||
const entityId = String(entry.entityId || entry.entity_id || '').trim();
|
|
||||||
if (!entityId) return null;
|
|
||||||
const stateEqualsRaw = entry.stateEquals ?? entry.state_equals;
|
|
||||||
const stateEquals =
|
|
||||||
stateEqualsRaw === null || stateEqualsRaw === undefined ? null : String(stateEqualsRaw).trim();
|
|
||||||
const runtimeKey = `${action}::entity::${entityId}::${stateEquals || '*'}::${index}`;
|
|
||||||
const cooldownMs = Number.isFinite(Number(entry.cooldownMs)) ? Math.max(0, Number(entry.cooldownMs)) : 0;
|
|
||||||
const allowedModes = Array.isArray(entry.allowedModes)
|
|
||||||
? entry.allowedModes.map((mode) => String(mode || '').trim().toLowerCase()).filter(Boolean)
|
|
||||||
: null;
|
|
||||||
return {
|
|
||||||
runtimeKey,
|
|
||||||
entityId,
|
|
||||||
action,
|
|
||||||
stateEquals,
|
|
||||||
payload: entry.payload && typeof entry.payload === 'object' ? entry.payload : {},
|
|
||||||
cooldownMs,
|
|
||||||
allowedModes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadTriggerConfig() {
|
|
||||||
triggerConfig.length = 0;
|
|
||||||
const list = Array.isArray(haConfig?.buttons) ? haConfig.buttons : [];
|
|
||||||
list.forEach((entry, index) => {
|
|
||||||
const normalized = normalizeTriggerEntry(entry, index);
|
|
||||||
if (!normalized) return;
|
|
||||||
triggerConfig.push(normalized);
|
|
||||||
if (!triggerRuntime.has(normalized.runtimeKey)) {
|
|
||||||
triggerRuntime.set(normalized.runtimeKey, {
|
|
||||||
lastFiredAt: 0,
|
|
||||||
lastState: null,
|
|
||||||
lastChanged: null,
|
|
||||||
lastUpdated: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
logger.info('Loaded Home Assistant buttons', { count: triggerConfig.length });
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildState(meta, raw) {
|
|
||||||
if (!meta) return null;
|
|
||||||
const name = meta.name || raw?.attributes?.friendly_name || meta.id;
|
|
||||||
const supportedColorModes = Array.isArray(raw?.attributes?.supported_color_modes)
|
|
||||||
? raw.attributes.supported_color_modes.map((mode) => String(mode))
|
|
||||||
: [];
|
|
||||||
const rgbColor = Array.isArray(raw?.attributes?.rgb_color) ? raw.attributes.rgb_color : null;
|
|
||||||
const hsColor = Array.isArray(raw?.attributes?.hs_color) ? raw.attributes.hs_color : null;
|
|
||||||
const supportsColor =
|
|
||||||
meta.type === 'light' &&
|
|
||||||
(rgbColor ||
|
|
||||||
hsColor ||
|
|
||||||
supportedColorModes.some((mode) => mode === 'hs' || mode === 'rgb' || mode === 'xy'));
|
|
||||||
if (!raw) {
|
|
||||||
return {
|
|
||||||
id: meta.id,
|
|
||||||
name,
|
|
||||||
type: meta.type,
|
|
||||||
state: 'unknown',
|
|
||||||
available: false,
|
|
||||||
lastChanged: null,
|
|
||||||
lastUpdated: null,
|
|
||||||
supportedColorModes,
|
|
||||||
colorMode: null,
|
|
||||||
rgbColor: null,
|
|
||||||
hsColor: null,
|
|
||||||
supportsColor,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const rawState = raw.state;
|
|
||||||
const unavailable = rawState === 'unavailable' || rawState === 'unknown';
|
|
||||||
const state = unavailable ? 'unavailable' : rawState === 'on' ? 'on' : 'off';
|
|
||||||
return {
|
|
||||||
id: meta.id,
|
|
||||||
name,
|
|
||||||
type: meta.type,
|
|
||||||
state,
|
|
||||||
available: !unavailable,
|
|
||||||
lastChanged: raw.last_changed || null,
|
|
||||||
lastUpdated: raw.last_updated || null,
|
|
||||||
supportedColorModes,
|
|
||||||
colorMode: raw?.attributes?.color_mode || null,
|
|
||||||
rgbColor,
|
|
||||||
hsColor,
|
|
||||||
supportsColor,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitUpdate() {
|
|
||||||
events.emit('update', getState());
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitStatus() {
|
|
||||||
events.emit('status', getState());
|
|
||||||
}
|
|
||||||
|
|
||||||
function getControllableEntityIds() {
|
|
||||||
return Array.from(entityConfig.values()).map((meta) => String(meta.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
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 getActiveDriverCount() {
|
|
||||||
const active = getActiveDrivers();
|
|
||||||
const turnCount = active && typeof active === 'object' ? Object.keys(active).length : 0;
|
|
||||||
if (turnCount > 0) return turnCount;
|
|
||||||
// Fallback: trust live rover driver sets if turn-service tracking is temporarily out of sync.
|
|
||||||
let liveCount = 0;
|
|
||||||
roverManager.rovers.forEach((record) => {
|
|
||||||
if (record?.drivers?.size > 0) {
|
|
||||||
liveCount += 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return liveCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasActiveDrivers() {
|
|
||||||
return getActiveDriverCount() > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearLightsIdleOffTimer() {
|
|
||||||
if (lightsIdleOffTimer) {
|
|
||||||
clearTimeout(lightsIdleOffTimer);
|
|
||||||
lightsIdleOffTimer = null;
|
|
||||||
}
|
|
||||||
if (lightsIdleOffDeadline != null) {
|
|
||||||
lightsIdleOffDeadline = null;
|
|
||||||
emitUpdate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleLightsIdleOffTimer() {
|
|
||||||
if (!enabled) return;
|
|
||||||
if (getControllableEntityIds().length === 0) return;
|
|
||||||
if (lightsIdleOffTimer || lightsLockState != null || hasActiveDrivers()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lightsIdleOffDeadline = Date.now() + LIGHT_IDLE_OFF_MS;
|
|
||||||
lightsIdleOffTimer = setTimeout(async () => {
|
|
||||||
lightsIdleOffTimer = null;
|
|
||||||
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();
|
|
||||||
evaluateLightAutomation();
|
|
||||||
}
|
|
||||||
}, LIGHT_IDLE_OFF_MS);
|
|
||||||
emitUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
function evaluateLightAutomation() {
|
|
||||||
if (lightsLockState != null) {
|
|
||||||
clearLightsIdleOffTimer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (hasActiveDrivers()) {
|
|
||||||
clearLightsIdleOffTimer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
scheduleLightsIdleOffTimer();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEntitySnapshot(snapshot = {}) {
|
|
||||||
latestEntitySnapshot = snapshot || {};
|
|
||||||
events.emit('snapshot', latestEntitySnapshot);
|
|
||||||
let changed = false;
|
|
||||||
entityConfig.forEach((meta, id) => {
|
|
||||||
const raw = snapshot[id];
|
|
||||||
const next = buildState(meta, raw);
|
|
||||||
const prev = entityState.get(id);
|
|
||||||
if (
|
|
||||||
!prev ||
|
|
||||||
prev.state !== next.state ||
|
|
||||||
prev.available !== next.available ||
|
|
||||||
prev.lastChanged !== next.lastChanged
|
|
||||||
) {
|
|
||||||
entityState.set(id, next);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (changed) {
|
|
||||||
emitUpdate();
|
|
||||||
}
|
|
||||||
evaluateTriggers(snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerMatches(trigger, raw, runtimeState) {
|
|
||||||
if (!raw) return false;
|
|
||||||
const nextState = raw?.state ?? null;
|
|
||||||
const nextChanged = raw?.last_changed ?? null;
|
|
||||||
const nextUpdated = raw?.last_updated ?? null;
|
|
||||||
const changed =
|
|
||||||
runtimeState.lastState !== nextState ||
|
|
||||||
runtimeState.lastChanged !== nextChanged ||
|
|
||||||
runtimeState.lastUpdated !== nextUpdated;
|
|
||||||
if (!changed) {
|
|
||||||
return { matched: false, nextState, nextChanged, nextUpdated };
|
|
||||||
}
|
|
||||||
if (trigger.stateEquals != null && String(trigger.stateEquals) !== String(nextState)) {
|
|
||||||
return { matched: false, nextState, nextChanged, nextUpdated };
|
|
||||||
}
|
|
||||||
return { matched: true, nextState, nextChanged, nextUpdated };
|
|
||||||
}
|
|
||||||
|
|
||||||
function dispatchButtonAction(trigger, runtimeState, payload = {}) {
|
|
||||||
const now = Date.now();
|
|
||||||
const mode = String(getMode() || '').toLowerCase();
|
|
||||||
if (trigger.allowedModes?.length && !trigger.allowedModes.includes(mode)) return;
|
|
||||||
if (trigger.cooldownMs > 0 && now - runtimeState.lastFiredAt < trigger.cooldownMs) return;
|
|
||||||
runtimeState.lastFiredAt = now;
|
|
||||||
triggerRuntime.set(trigger.runtimeKey, runtimeState);
|
|
||||||
const basePayload = {
|
|
||||||
buttonId: trigger.action,
|
|
||||||
action: trigger.action,
|
|
||||||
firedAt: now,
|
|
||||||
...(payload || {}),
|
|
||||||
...(trigger.payload || {}),
|
|
||||||
};
|
|
||||||
logger.info('Home Assistant button action fired', {
|
|
||||||
action: trigger.action,
|
|
||||||
source: 'entity',
|
|
||||||
entityId: payload?.entityId || null,
|
|
||||||
});
|
|
||||||
events.emit('trigger', basePayload);
|
|
||||||
publishEvent({
|
|
||||||
source: 'homeAssistant',
|
|
||||||
type: HA_BUTTON_EVENT_TYPE,
|
|
||||||
payload: basePayload,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function evaluateTriggers(snapshot = {}) {
|
|
||||||
if (!triggerConfig.length) return;
|
|
||||||
triggerConfig.forEach((trigger) => {
|
|
||||||
const runtimeState = triggerRuntime.get(trigger.runtimeKey) || {
|
|
||||||
lastFiredAt: 0,
|
|
||||||
lastState: null,
|
|
||||||
lastChanged: null,
|
|
||||||
lastUpdated: null,
|
|
||||||
};
|
|
||||||
const raw = snapshot?.[trigger.entityId] || null;
|
|
||||||
const evalResult = triggerMatches(trigger, raw, runtimeState);
|
|
||||||
runtimeState.lastState = evalResult.nextState;
|
|
||||||
runtimeState.lastChanged = evalResult.nextChanged;
|
|
||||||
runtimeState.lastUpdated = evalResult.nextUpdated;
|
|
||||||
triggerRuntime.set(trigger.runtimeKey, runtimeState);
|
|
||||||
if (!evalResult.matched) return;
|
|
||||||
dispatchButtonAction(trigger, runtimeState, {
|
|
||||||
entityId: trigger.entityId,
|
|
||||||
state: raw?.state ?? null,
|
|
||||||
attributes: raw?.attributes || {},
|
|
||||||
lastChanged: raw?.last_changed || null,
|
|
||||||
lastUpdated: raw?.last_updated || null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function teardownConnection() {
|
|
||||||
if (unsubscribeEntities) {
|
|
||||||
try {
|
|
||||||
unsubscribeEntities();
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Failed to unsubscribe entity stream', err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsubscribeEntities = null;
|
|
||||||
if (connection) {
|
|
||||||
try {
|
|
||||||
connection.close();
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Error closing Home Assistant connection', err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
connection = null;
|
|
||||||
const wasConnected = connected;
|
|
||||||
connected = false;
|
|
||||||
if (wasConnected) {
|
|
||||||
emitStatus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleReconnect(delayMs = 5000) {
|
|
||||||
if (!enabled) return;
|
|
||||||
if (reconnectTimer) return;
|
|
||||||
reconnectTimer = setTimeout(() => {
|
|
||||||
reconnectTimer = null;
|
|
||||||
connect();
|
|
||||||
}, delayMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function connect() {
|
|
||||||
if (!enabled) {
|
|
||||||
logger.info('Home Assistant integration disabled; missing url/token in config');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (connection) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const auth = buildAuth();
|
|
||||||
connection = await createConnection({ auth, setupRetry: 0 });
|
|
||||||
connected = true;
|
|
||||||
emitStatus();
|
|
||||||
logger.info('Connected to Home Assistant');
|
|
||||||
unsubscribeEntities = subscribeEntities(connection, handleEntitySnapshot);
|
|
||||||
connection.addEventListener('disconnected', () => {
|
|
||||||
logger.warn('Home Assistant connection lost');
|
|
||||||
teardownConnection();
|
|
||||||
scheduleReconnect();
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Home Assistant connection failed', err.message);
|
|
||||||
teardownConnection();
|
|
||||||
scheduleReconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setEntityState(entityId, desiredState) {
|
|
||||||
if (!enabled) {
|
|
||||||
throw new Error('Home Assistant not configured');
|
|
||||||
}
|
|
||||||
const meta = entityConfig.get(entityId);
|
|
||||||
if (!meta) {
|
|
||||||
throw new Error('Unknown Home Assistant entity');
|
|
||||||
}
|
|
||||||
if (!connection) {
|
|
||||||
throw new Error('Home Assistant not connected');
|
|
||||||
}
|
|
||||||
const nextState = desiredState === 'on' ? 'on' : 'off';
|
|
||||||
const domain = meta.type === 'light' ? 'light' : 'switch';
|
|
||||||
const service = nextState === 'on' ? 'turn_on' : 'turn_off';
|
|
||||||
await callHomeAssistantService(domain, service, { entity_id: entityId });
|
|
||||||
logger.info('Issued Home Assistant command', { entityId, domain, service });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setAllControllableEntitiesState(desiredState) {
|
|
||||||
const ids = getControllableEntityIds();
|
|
||||||
if (!ids.length) return;
|
|
||||||
const results = await Promise.allSettled(ids.map((id) => setEntityState(id, desiredState)));
|
|
||||||
const failures = results.filter((result) => result.status === 'rejected');
|
|
||||||
if (failures.length) {
|
|
||||||
logger.warn('Some Home Assistant entity state updates failed', {
|
|
||||||
desiredState,
|
|
||||||
total: ids.length,
|
|
||||||
failed: failures.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleEntity(entityId) {
|
|
||||||
const current = entityState.get(entityId);
|
|
||||||
const nextState = current?.state === 'on' ? 'off' : 'on';
|
|
||||||
return setEntityState(entityId, nextState);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setLightColor(entityId, rgbColor) {
|
|
||||||
if (!enabled) {
|
|
||||||
throw new Error('Home Assistant not configured');
|
|
||||||
}
|
|
||||||
const meta = entityConfig.get(entityId);
|
|
||||||
if (!meta || meta.type !== 'light') {
|
|
||||||
throw new Error('Home Assistant light required');
|
|
||||||
}
|
|
||||||
if (!connection) {
|
|
||||||
throw new Error('Home Assistant not connected');
|
|
||||||
}
|
|
||||||
if (!Array.isArray(rgbColor) || rgbColor.length !== 3) {
|
|
||||||
throw new Error('rgbColor required');
|
|
||||||
}
|
|
||||||
const normalized = rgbColor.map((value) => {
|
|
||||||
const next = Number(value);
|
|
||||||
if (Number.isNaN(next)) return 0;
|
|
||||||
return Math.max(0, Math.min(255, Math.round(next)));
|
|
||||||
});
|
|
||||||
await callHomeAssistantService('light', 'turn_on', { entity_id: entityId, rgb_color: normalized });
|
|
||||||
logger.info('Issued Home Assistant color command', { entityId, rgbColor: normalized });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setLightWhite(entityId, kelvin = DEFAULT_WHITE_KELVIN) {
|
|
||||||
if (!enabled) {
|
|
||||||
throw new Error('Home Assistant not configured');
|
|
||||||
}
|
|
||||||
const meta = entityConfig.get(entityId);
|
|
||||||
if (!meta || meta.type !== 'light') {
|
|
||||||
throw new Error('Home Assistant light required');
|
|
||||||
}
|
|
||||||
if (!connection) {
|
|
||||||
throw new Error('Home Assistant not connected');
|
|
||||||
}
|
|
||||||
const nextKelvin = Number(kelvin);
|
|
||||||
const normalizedKelvin = Number.isFinite(nextKelvin)
|
|
||||||
? Math.max(2000, Math.min(6500, Math.round(nextKelvin)))
|
|
||||||
: DEFAULT_WHITE_KELVIN;
|
|
||||||
await callHomeAssistantService('light', 'turn_on', {
|
|
||||||
entity_id: entityId,
|
|
||||||
color_temp_kelvin: normalizedKelvin,
|
|
||||||
});
|
|
||||||
logger.info('Issued Home Assistant white command', {
|
|
||||||
entityId,
|
|
||||||
colorTempKelvin: normalizedKelvin,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLightControlLocked() {
|
|
||||||
return lightsLockState != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLightPolicyState() {
|
|
||||||
return {
|
|
||||||
locked: lightsLockState != null,
|
|
||||||
lockState: lightsLockState,
|
|
||||||
lockedOn: lightsLockState === 'on',
|
|
||||||
idleOffMs: LIGHT_IDLE_OFF_MS,
|
|
||||||
idleOffAt: lightsIdleOffDeadline,
|
|
||||||
activeDrivers: getActiveDriverCount(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = lightsLockState !== nextLockState;
|
|
||||||
lightsLockState = nextLockState;
|
|
||||||
if (lightsLockState != null) {
|
|
||||||
clearLightsIdleOffTimer();
|
|
||||||
if (changed || forceApply) {
|
|
||||||
if (enabled) {
|
|
||||||
await setAllControllableEntitiesState(lightsLockState);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
evaluateLightAutomation();
|
|
||||||
}
|
|
||||||
if (changed) {
|
|
||||||
logger.info('Room lights lock state changed', {
|
|
||||||
locked: lightsLockState != null,
|
|
||||||
lockState: lightsLockState,
|
|
||||||
source: options.source || 'unknown',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
emitUpdate();
|
|
||||||
return lightsLockState === 'on';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleLightsLockedOn(options = {}) {
|
|
||||||
return setLightsLockedOn(lightsLockState == null, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getState() {
|
|
||||||
const entities = Array.from(entityConfig.values()).map(
|
|
||||||
(meta) => entityState.get(meta.id) || buildState(meta, null),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
enabled,
|
|
||||||
connected,
|
|
||||||
entities,
|
|
||||||
lightPolicy: getLightPolicyState(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function isConnected() {
|
|
||||||
return Boolean(connection && connected);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRawEntitySnapshot(entityId) {
|
|
||||||
if (!entityId) return null;
|
|
||||||
return latestEntitySnapshot?.[String(entityId)] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function callHomeAssistantService(domain, service, serviceData = {}) {
|
|
||||||
if (!enabled) {
|
|
||||||
throw new Error('Home Assistant not configured');
|
|
||||||
}
|
|
||||||
if (!connection) {
|
|
||||||
throw new Error('Home Assistant not connected');
|
|
||||||
}
|
|
||||||
if (!domain || !service) {
|
|
||||||
throw new Error('domain and service required');
|
|
||||||
}
|
|
||||||
await callService(connection, String(domain), String(service), serviceData || {});
|
|
||||||
}
|
|
||||||
|
|
||||||
loadEntityConfig();
|
|
||||||
loadTriggerConfig();
|
|
||||||
connect();
|
|
||||||
evaluateLightAutomation();
|
|
||||||
|
|
||||||
turnEvents.on('activeDriver', () => {
|
|
||||||
evaluateLightAutomation();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
turnEvents.on('queue', () => {
|
const transport = createTransport({
|
||||||
evaluateLightAutomation();
|
logger,
|
||||||
|
enabled,
|
||||||
|
haConfig,
|
||||||
|
onSnapshot: runtimeEngine.handleEntitySnapshot,
|
||||||
|
onStatus: () => runtimeEngine.emitStatus(runtimeEngine.getState),
|
||||||
});
|
});
|
||||||
|
|
||||||
modeEvents.on('change', (mode) => {
|
callHomeAssistantServiceImpl = transport.callHomeAssistantService;
|
||||||
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) => {
|
runtimeEngine.loadEntityConfig();
|
||||||
socket.on('homeAssistant:toggle', async ({ entityId } = {}, cb = () => {}) => {
|
runtimeEngine.loadTriggerConfig();
|
||||||
const mode = getMode();
|
transport.connect();
|
||||||
if (
|
runtimeEngine.evaluateLightAutomation();
|
||||||
(mode === 'admin' && isAdmin(socket) !== true) ||
|
|
||||||
(mode === 'lockdown' && isLockdownAdmin(socket) !== true)
|
|
||||||
) {
|
|
||||||
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
|
||||||
}
|
|
||||||
if (isLightControlLocked()) {
|
|
||||||
return cb({ error: 'Room controls are locked' });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (!entityId) throw new Error('entityId required');
|
|
||||||
await toggleEntity(entityId);
|
|
||||||
cb({ success: true });
|
|
||||||
} catch (err) {
|
|
||||||
cb({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('homeAssistant:setState', async ({ entityId, state } = {}, cb = () => {}) => {
|
registerHomeAssistantHooks({
|
||||||
const mode = getMode();
|
logger,
|
||||||
if (
|
haConfig,
|
||||||
(mode === 'admin' && isAdmin(socket) !== true) ||
|
evaluateLightAutomation: runtimeEngine.evaluateLightAutomation,
|
||||||
(mode === 'lockdown' && isLockdownAdmin(socket) !== true)
|
isLightControlLocked: runtimeEngine.isLightControlLocked,
|
||||||
) {
|
setLightsLockedOn: runtimeEngine.setLightsLockedOn,
|
||||||
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
toggleEntity: runtimeEngine.toggleEntity,
|
||||||
}
|
setEntityState: runtimeEngine.setEntityState,
|
||||||
if (isLightControlLocked()) {
|
setLightColor: runtimeEngine.setLightColor,
|
||||||
return cb({ error: 'Room controls are locked' });
|
setLightWhite: runtimeEngine.setLightWhite,
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!entityId) throw new Error('entityId required');
|
|
||||||
await setEntityState(entityId, state);
|
|
||||||
cb({ success: true });
|
|
||||||
} catch (err) {
|
|
||||||
cb({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('homeAssistant:lightColor', async ({ entityId, rgbColor } = {}, cb = () => {}) => {
|
|
||||||
const mode = getMode();
|
|
||||||
if (
|
|
||||||
(mode === 'admin' && isAdmin(socket) !== true) ||
|
|
||||||
(mode === 'lockdown' && isLockdownAdmin(socket) !== true)
|
|
||||||
) {
|
|
||||||
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
|
||||||
}
|
|
||||||
if (isLightControlLocked()) {
|
|
||||||
return cb({ error: 'Room controls are locked' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!entityId) throw new Error('entityId required');
|
|
||||||
await setLightColor(entityId, rgbColor);
|
|
||||||
cb({ success: true });
|
|
||||||
} catch (err) {
|
|
||||||
cb({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('homeAssistant:lightWhite', async ({ entityId } = {}, cb = () => {}) => {
|
|
||||||
const mode = getMode();
|
|
||||||
if (
|
|
||||||
(mode === 'admin' && isAdmin(socket) !== true) ||
|
|
||||||
(mode === 'lockdown' && isLockdownAdmin(socket) !== true)
|
|
||||||
) {
|
|
||||||
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
|
||||||
}
|
|
||||||
if (isLightControlLocked()) {
|
|
||||||
return cb({ error: 'Room controls are locked' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!entityId) throw new Error('entityId required');
|
|
||||||
await setLightWhite(entityId, haConfig?.whiteKelvin);
|
|
||||||
cb({ success: true });
|
|
||||||
} catch (err) {
|
|
||||||
cb({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getState,
|
getState: runtimeEngine.getState,
|
||||||
isConnected,
|
isConnected: transport.isConnected,
|
||||||
enabled,
|
enabled,
|
||||||
getLightPolicyState,
|
getLightPolicyState: runtimeEngine.getLightPolicyState,
|
||||||
isLightControlLocked,
|
isLightControlLocked: runtimeEngine.isLightControlLocked,
|
||||||
getRawEntitySnapshot,
|
getRawEntitySnapshot: runtimeEngine.getRawEntitySnapshot,
|
||||||
callHomeAssistantService,
|
callHomeAssistantService: transport.callHomeAssistantService,
|
||||||
toggleEntity,
|
toggleEntity: runtimeEngine.toggleEntity,
|
||||||
setEntityState,
|
setEntityState: runtimeEngine.setEntityState,
|
||||||
setLightColor,
|
setLightColor: runtimeEngine.setLightColor,
|
||||||
setLightWhite,
|
setLightWhite: runtimeEngine.setLightWhite,
|
||||||
setLightsLockedOn,
|
setLightsLockedOn: runtimeEngine.setLightsLockedOn,
|
||||||
toggleLightsLockedOn,
|
toggleLightsLockedOn: runtimeEngine.toggleLightsLockedOn,
|
||||||
homeAssistantEvents: events,
|
homeAssistantEvents: events,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
// 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 {
|
||||||
|
events,
|
||||||
|
entityConfig,
|
||||||
|
entityState,
|
||||||
|
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');
|
||||||
|
|
||||||
|
function createRuntimeEngine(deps) {
|
||||||
|
const { logger, enabled, haConfig, callHomeAssistantService } = deps;
|
||||||
|
|
||||||
|
function emitUpdate(getState) {
|
||||||
|
events.emit('update', getState());
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitStatus(getState) {
|
||||||
|
events.emit('status', getState());
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEntityConfig() {
|
||||||
|
entityConfig.clear();
|
||||||
|
const list = Array.isArray(haConfig?.entities) ? haConfig.entities : [];
|
||||||
|
list.forEach((entry) => {
|
||||||
|
const normalized = normalizeConfigEntry(entry);
|
||||||
|
if (normalized) {
|
||||||
|
entityConfig.set(normalized.id, normalized);
|
||||||
|
if (!entityState.has(normalized.id)) {
|
||||||
|
entityState.set(normalized.id, buildState(normalized, null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
logger.info('Loaded Home Assistant entities', { count: entityConfig.size });
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTriggerConfig() {
|
||||||
|
triggerConfig.length = 0;
|
||||||
|
const list = Array.isArray(haConfig?.buttons) ? haConfig.buttons : [];
|
||||||
|
list.forEach((entry, index) => {
|
||||||
|
const normalized = normalizeTriggerEntry(entry, index);
|
||||||
|
if (!normalized) return;
|
||||||
|
triggerConfig.push(normalized);
|
||||||
|
if (!triggerRuntime.has(normalized.runtimeKey)) {
|
||||||
|
triggerRuntime.set(normalized.runtimeKey, {
|
||||||
|
lastFiredAt: 0,
|
||||||
|
lastState: null,
|
||||||
|
lastChanged: null,
|
||||||
|
lastUpdated: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
logger.info('Loaded Home Assistant buttons', { count: triggerConfig.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getControllableEntityIds() {
|
||||||
|
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);
|
||||||
|
if (!meta) throw new Error('Unknown Home Assistant entity');
|
||||||
|
if (!runtime.connection) throw new Error('Home Assistant not connected');
|
||||||
|
|
||||||
|
const nextState = desiredState === 'on' ? 'on' : 'off';
|
||||||
|
const domain = meta.type === 'light' ? 'light' : 'switch';
|
||||||
|
const service = nextState === 'on' ? 'turn_on' : 'turn_off';
|
||||||
|
await callHomeAssistantService(domain, service, { entity_id: entityId });
|
||||||
|
logger.info('Issued Home Assistant command', { entityId, domain, service });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setAllControllableEntitiesState(desiredState) {
|
||||||
|
const ids = getControllableEntityIds();
|
||||||
|
if (!ids.length) return;
|
||||||
|
const results = await Promise.allSettled(ids.map((id) => setEntityState(id, desiredState)));
|
||||||
|
const failures = results.filter((result) => result.status === 'rejected');
|
||||||
|
if (failures.length) {
|
||||||
|
logger.warn('Some Home Assistant entity state updates failed', {
|
||||||
|
desiredState,
|
||||||
|
total: ids.length,
|
||||||
|
failed: failures.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
const nextChanged = raw?.last_changed ?? null;
|
||||||
|
const nextUpdated = raw?.last_updated ?? null;
|
||||||
|
const changed =
|
||||||
|
runtimeState.lastState !== nextState ||
|
||||||
|
runtimeState.lastChanged !== nextChanged ||
|
||||||
|
runtimeState.lastUpdated !== nextUpdated;
|
||||||
|
if (!changed) {
|
||||||
|
return { matched: false, nextState, nextChanged, nextUpdated };
|
||||||
|
}
|
||||||
|
if (trigger.stateEquals != null && String(trigger.stateEquals) !== String(nextState)) {
|
||||||
|
return { matched: false, nextState, nextChanged, nextUpdated };
|
||||||
|
}
|
||||||
|
return { matched: true, nextState, nextChanged, nextUpdated };
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchButtonAction(trigger, runtimeState, payload = {}) {
|
||||||
|
const now = Date.now();
|
||||||
|
const mode = String(getMode() || '').toLowerCase();
|
||||||
|
if (trigger.allowedModes?.length && !trigger.allowedModes.includes(mode)) return;
|
||||||
|
if (trigger.cooldownMs > 0 && now - runtimeState.lastFiredAt < trigger.cooldownMs) return;
|
||||||
|
runtimeState.lastFiredAt = now;
|
||||||
|
triggerRuntime.set(trigger.runtimeKey, runtimeState);
|
||||||
|
const basePayload = {
|
||||||
|
buttonId: trigger.action,
|
||||||
|
action: trigger.action,
|
||||||
|
firedAt: now,
|
||||||
|
...(payload || {}),
|
||||||
|
...(trigger.payload || {}),
|
||||||
|
};
|
||||||
|
logger.info('Home Assistant button action fired', {
|
||||||
|
action: trigger.action,
|
||||||
|
source: 'entity',
|
||||||
|
entityId: payload?.entityId || null,
|
||||||
|
});
|
||||||
|
events.emit('trigger', basePayload);
|
||||||
|
publishEvent({ source: 'homeAssistant', type: HA_BUTTON_EVENT_TYPE, payload: basePayload });
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateTriggers(snapshot = {}) {
|
||||||
|
if (!triggerConfig.length) return;
|
||||||
|
triggerConfig.forEach((trigger) => {
|
||||||
|
const runtimeState = triggerRuntime.get(trigger.runtimeKey) || {
|
||||||
|
lastFiredAt: 0,
|
||||||
|
lastState: null,
|
||||||
|
lastChanged: null,
|
||||||
|
lastUpdated: null,
|
||||||
|
};
|
||||||
|
const raw = snapshot?.[trigger.entityId] || null;
|
||||||
|
const evalResult = triggerMatches(trigger, raw, runtimeState);
|
||||||
|
runtimeState.lastState = evalResult.nextState;
|
||||||
|
runtimeState.lastChanged = evalResult.nextChanged;
|
||||||
|
runtimeState.lastUpdated = evalResult.nextUpdated;
|
||||||
|
triggerRuntime.set(trigger.runtimeKey, runtimeState);
|
||||||
|
if (!evalResult.matched) return;
|
||||||
|
dispatchButtonAction(trigger, runtimeState, {
|
||||||
|
entityId: trigger.entityId,
|
||||||
|
state: raw?.state ?? null,
|
||||||
|
attributes: raw?.attributes || {},
|
||||||
|
lastChanged: raw?.last_changed || null,
|
||||||
|
lastUpdated: raw?.last_updated || null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLightPolicyState() {
|
||||||
|
return {
|
||||||
|
locked: runtime.lightsLockState != null,
|
||||||
|
lockState: runtime.lightsLockState,
|
||||||
|
lockedOn: runtime.lightsLockState === 'on',
|
||||||
|
idleOffMs: LIGHT_IDLE_OFF_MS,
|
||||||
|
idleOffAt: runtime.lightsIdleOffDeadline,
|
||||||
|
activeDrivers: getActiveDriverCount(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getState() {
|
||||||
|
const entities = Array.from(entityConfig.values()).map((meta) => entityState.get(meta.id) || buildState(meta, null));
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
connected: runtime.connected,
|
||||||
|
entities,
|
||||||
|
lightPolicy: getLightPolicyState(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateLightAutomation() {
|
||||||
|
if (runtime.lightsLockState != null) {
|
||||||
|
clearLightsIdleOffTimer(getState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasActiveDrivers()) {
|
||||||
|
clearLightsIdleOffTimer(getState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scheduleLightsIdleOffTimer(getState, evaluateLightAutomation);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEntitySnapshot(snapshot = {}) {
|
||||||
|
runtime.latestEntitySnapshot = snapshot || {};
|
||||||
|
events.emit('snapshot', runtime.latestEntitySnapshot);
|
||||||
|
let changed = false;
|
||||||
|
entityConfig.forEach((meta, id) => {
|
||||||
|
const raw = snapshot[id];
|
||||||
|
const next = buildState(meta, raw);
|
||||||
|
const prev = entityState.get(id);
|
||||||
|
if (!prev || prev.state !== next.state || prev.available !== next.available || prev.lastChanged !== next.lastChanged) {
|
||||||
|
entityState.set(id, next);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (changed) emitUpdate(getState);
|
||||||
|
evaluateTriggers(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleEntity(entityId) {
|
||||||
|
const current = entityState.get(entityId);
|
||||||
|
const nextState = current?.state === 'on' ? 'off' : 'on';
|
||||||
|
return setEntityState(entityId, nextState);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setLightColor(entityId, rgbColor) {
|
||||||
|
if (!enabled) throw new Error('Home Assistant not configured');
|
||||||
|
const meta = entityConfig.get(entityId);
|
||||||
|
if (!meta || meta.type !== 'light') throw new Error('Home Assistant light required');
|
||||||
|
if (!runtime.connection) throw new Error('Home Assistant not connected');
|
||||||
|
if (!Array.isArray(rgbColor) || rgbColor.length !== 3) throw new Error('rgbColor required');
|
||||||
|
|
||||||
|
const normalized = rgbColor.map((value) => {
|
||||||
|
const next = Number(value);
|
||||||
|
if (Number.isNaN(next)) return 0;
|
||||||
|
return Math.max(0, Math.min(255, Math.round(next)));
|
||||||
|
});
|
||||||
|
await callHomeAssistantService('light', 'turn_on', { entity_id: entityId, rgb_color: normalized });
|
||||||
|
logger.info('Issued Home Assistant color command', { entityId, rgbColor: normalized });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setLightWhite(entityId, kelvin = DEFAULT_WHITE_KELVIN) {
|
||||||
|
if (!enabled) throw new Error('Home Assistant not configured');
|
||||||
|
const meta = entityConfig.get(entityId);
|
||||||
|
if (!meta || meta.type !== 'light') throw new Error('Home Assistant light required');
|
||||||
|
if (!runtime.connection) throw new Error('Home Assistant not connected');
|
||||||
|
|
||||||
|
const nextKelvin = Number(kelvin);
|
||||||
|
const normalizedKelvin = Number.isFinite(nextKelvin)
|
||||||
|
? Math.max(2000, Math.min(6500, Math.round(nextKelvin)))
|
||||||
|
: DEFAULT_WHITE_KELVIN;
|
||||||
|
await callHomeAssistantService('light', 'turn_on', { entity_id: entityId, color_temp_kelvin: normalizedKelvin });
|
||||||
|
logger.info('Issued Home Assistant white command', { entityId, colorTempKelvin: normalizedKelvin });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLightControlLocked() {
|
||||||
|
return runtime.lightsLockState != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
clearLightsIdleOffTimer(getState);
|
||||||
|
if ((changed || forceApply) && enabled) {
|
||||||
|
await setAllControllableEntitiesState(runtime.lightsLockState);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
evaluateLightAutomation();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
logger.info('Room lights lock state changed', {
|
||||||
|
locked: runtime.lightsLockState != null,
|
||||||
|
lockState: runtime.lightsLockState,
|
||||||
|
source: options.source || 'unknown',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
emitUpdate(getState);
|
||||||
|
return runtime.lightsLockState === 'on';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleLightsLockedOn(options = {}) {
|
||||||
|
return setLightsLockedOn(runtime.lightsLockState == null, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRawEntitySnapshot(entityId) {
|
||||||
|
if (!entityId) return null;
|
||||||
|
return runtime.latestEntitySnapshot?.[String(entityId)] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loadEntityConfig,
|
||||||
|
loadTriggerConfig,
|
||||||
|
handleEntitySnapshot,
|
||||||
|
evaluateLightAutomation,
|
||||||
|
emitStatus,
|
||||||
|
getState,
|
||||||
|
getLightPolicyState,
|
||||||
|
isLightControlLocked,
|
||||||
|
getRawEntitySnapshot,
|
||||||
|
toggleEntity,
|
||||||
|
setEntityState,
|
||||||
|
setLightColor,
|
||||||
|
setLightWhite,
|
||||||
|
setLightsLockedOn,
|
||||||
|
toggleLightsLockedOn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createRuntimeEngine,
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Home Assistant Runtime State
|
||||||
|
// Purpose: Stores shared mutable runtime state and constants for Home Assistant integration.
|
||||||
|
// Scope: Centralizes event emitters, entity caches, trigger runtime, and connection/light policy flags.
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
|
||||||
|
const events = new EventEmitter();
|
||||||
|
const entityConfig = new Map();
|
||||||
|
const entityState = new Map();
|
||||||
|
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: {},
|
||||||
|
connection: null,
|
||||||
|
unsubscribeEntities: null,
|
||||||
|
reconnectTimer: null,
|
||||||
|
connected: false,
|
||||||
|
lightsLockState: null,
|
||||||
|
lightsIdleOffTimer: null,
|
||||||
|
lightsIdleOffDeadline: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
events,
|
||||||
|
entityConfig,
|
||||||
|
entityState,
|
||||||
|
triggerConfig,
|
||||||
|
triggerRuntime,
|
||||||
|
HA_BUTTON_EVENT_TYPE,
|
||||||
|
LIGHT_IDLE_OFF_MS,
|
||||||
|
DEFAULT_WHITE_KELVIN,
|
||||||
|
NIGHT_VISION_DISABLE_ACTION,
|
||||||
|
runtime,
|
||||||
|
};
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// Home Assistant Transport
|
||||||
|
// Purpose: Manages websocket auth connection lifecycle, entity subscription, and reconnect behavior.
|
||||||
|
// Scope: Handles Home Assistant network transport and service-call plumbing without business policy logic.
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
const { createConnection, subscribeEntities, callService, Auth } = require('home-assistant-js-websocket');
|
||||||
|
const { runtime } = require('./state');
|
||||||
|
|
||||||
|
if (!global.WebSocket) {
|
||||||
|
global.WebSocket = WebSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTransport(deps) {
|
||||||
|
const { logger, enabled, haConfig, onSnapshot, onStatus } = deps;
|
||||||
|
|
||||||
|
function buildAuth() {
|
||||||
|
const token = haConfig?.token?.trim();
|
||||||
|
const url = haConfig?.url?.trim();
|
||||||
|
if (!token || !url) {
|
||||||
|
throw new Error('Home Assistant url/token missing');
|
||||||
|
}
|
||||||
|
return new Auth(
|
||||||
|
{
|
||||||
|
hassUrl: url.replace(/\/+$/, ''),
|
||||||
|
access_token: token,
|
||||||
|
expires: Date.now() + 1000 * 60 * 60 * 24 * 365 * 10,
|
||||||
|
refresh_token: null,
|
||||||
|
clientId: 'multiroomba-rover',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function teardownConnection() {
|
||||||
|
if (runtime.unsubscribeEntities) {
|
||||||
|
try {
|
||||||
|
runtime.unsubscribeEntities();
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to unsubscribe entity stream', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runtime.unsubscribeEntities = null;
|
||||||
|
|
||||||
|
if (runtime.connection) {
|
||||||
|
try {
|
||||||
|
runtime.connection.close();
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Error closing Home Assistant connection', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.connection = null;
|
||||||
|
const wasConnected = runtime.connected;
|
||||||
|
runtime.connected = false;
|
||||||
|
if (wasConnected) {
|
||||||
|
onStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect(delayMs = 5000) {
|
||||||
|
if (!enabled) return;
|
||||||
|
if (runtime.reconnectTimer) return;
|
||||||
|
runtime.reconnectTimer = setTimeout(() => {
|
||||||
|
runtime.reconnectTimer = null;
|
||||||
|
connect();
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
if (!enabled) {
|
||||||
|
logger.info('Home Assistant integration disabled; missing url/token in config');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (runtime.connection) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const auth = buildAuth();
|
||||||
|
runtime.connection = await createConnection({ auth, setupRetry: 0 });
|
||||||
|
runtime.connected = true;
|
||||||
|
onStatus();
|
||||||
|
logger.info('Connected to Home Assistant');
|
||||||
|
runtime.unsubscribeEntities = subscribeEntities(runtime.connection, onSnapshot);
|
||||||
|
runtime.connection.addEventListener('disconnected', () => {
|
||||||
|
logger.warn('Home Assistant connection lost');
|
||||||
|
teardownConnection();
|
||||||
|
scheduleReconnect();
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Home Assistant connection failed', err.message);
|
||||||
|
teardownConnection();
|
||||||
|
scheduleReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConnected() {
|
||||||
|
return Boolean(runtime.connection && runtime.connected);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callHomeAssistantService(domain, service, serviceData = {}) {
|
||||||
|
if (!enabled) throw new Error('Home Assistant not configured');
|
||||||
|
if (!runtime.connection) throw new Error('Home Assistant not connected');
|
||||||
|
if (!domain || !service) throw new Error('domain and service required');
|
||||||
|
await callService(runtime.connection, String(domain), String(service), serviceData || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
connect,
|
||||||
|
isConnected,
|
||||||
|
callHomeAssistantService,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createTransport,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user