mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
459 lines
14 KiB
JavaScript
459 lines
14 KiB
JavaScript
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 { loadConfig } = require('../helpers/configLoader');
|
|
const { getMode } = require('./modeManager');
|
|
const { isAdmin, isLockdownAdmin } = require('./roleService');
|
|
const { publishEvent } = require('./eventBus');
|
|
|
|
// home-assistant-js-websocket expects a global WebSocket in Node.
|
|
if (!global.WebSocket) {
|
|
global.WebSocket = WebSocket;
|
|
}
|
|
|
|
const config = loadConfig();
|
|
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';
|
|
|
|
let connection = null;
|
|
let unsubscribeEntities = null;
|
|
let reconnectTimer = null;
|
|
let connected = false;
|
|
|
|
const enabled = Boolean(haConfig?.url && haConfig?.token);
|
|
|
|
function buildAuth() {
|
|
const token = haConfig?.token?.trim();
|
|
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) {
|
|
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 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 handleEntitySnapshot(snapshot = {}) {
|
|
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 callService(connection, domain, service, { entity_id: entityId });
|
|
logger.info('Issued Home Assistant command', { entityId, domain, service });
|
|
}
|
|
|
|
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 callService(connection, 'light', 'turn_on', { entity_id: entityId, rgb_color: normalized });
|
|
logger.info('Issued Home Assistant color command', { entityId, rgbColor: normalized });
|
|
}
|
|
|
|
function getState() {
|
|
const entities = Array.from(entityConfig.values()).map(
|
|
(meta) => entityState.get(meta.id) || buildState(meta, null),
|
|
);
|
|
return { enabled, connected, entities };
|
|
}
|
|
|
|
loadEntityConfig();
|
|
loadTriggerConfig();
|
|
connect();
|
|
|
|
io.on('connection', (socket) => {
|
|
socket.on('homeAssistant:toggle', 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' });
|
|
}
|
|
|
|
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 = () => {}) => {
|
|
const mode = getMode();
|
|
if (
|
|
(mode === 'admin' && isAdmin(socket) !== true) ||
|
|
(mode === 'lockdown' && isLockdownAdmin(socket) !== true)
|
|
) {
|
|
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
|
}
|
|
|
|
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' });
|
|
}
|
|
|
|
try {
|
|
if (!entityId) throw new Error('entityId required');
|
|
await setLightColor(entityId, rgbColor);
|
|
cb({ success: true });
|
|
} catch (err) {
|
|
cb({ error: err.message });
|
|
}
|
|
});
|
|
});
|
|
|
|
module.exports = {
|
|
getState,
|
|
toggleEntity,
|
|
setEntityState,
|
|
setLightColor,
|
|
homeAssistantEvents: events,
|
|
};
|