From 353efeeffd554ea3b16bddf73d97809a9180e7e0 Mon Sep 17 00:00:00 2001 From: legop3 Date: Thu, 9 Apr 2026 16:25:48 -0400 Subject: [PATCH] add button support --- server/config.example.yaml | 18 +-- server/src/services/homeAssistantService.js | 132 +++++++++++++++----- 2 files changed, 113 insertions(+), 37 deletions(-) diff --git a/server/config.example.yaml b/server/config.example.yaml index 231b0289..fb41b635 100644 --- a/server/config.example.yaml +++ b/server/config.example.yaml @@ -42,15 +42,19 @@ homeAssistant: # type is optional; if omitted it is inferred from the entity id (light/switch) buttons: # All Home Assistant button automations live here. - - entityId: "sensor.human_alert_button_action" - stateEquals: "single" + # Option A (state-based entity trigger): + # - entityId: "sensor.human_alert_button_action" + # stateEquals: "single" + # cooldownMs: 15000 + # action: "humanAlert" + # + # Option B (direct HA event trigger, for stateless button.* setups): + - eventType: "zha_event" + eventData: + device_id: "REPLACE_WITH_DEVICE_ID" + command: "single" cooldownMs: 15000 action: "humanAlert" - # Example future button: - # - entityId: "sensor.replay_button_action" - # stateEquals: "single" - # cooldownMs: 10000 - # action: "replayClip" roomCameras: - id: "lobby" name: "Lobby Camera" diff --git a/server/src/services/homeAssistantService.js b/server/src/services/homeAssistantService.js index 79f311ed..84fdc48b 100644 --- a/server/src/services/homeAssistantService.js +++ b/server/src/services/homeAssistantService.js @@ -19,12 +19,13 @@ 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 triggerConfig = []; // [{ runtimeKey, source, entityId?, eventType?, eventData?, 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 eventUnsubscribers = []; let reconnectTimer = null; let connected = false; @@ -84,22 +85,34 @@ function loadEntityConfig() { function normalizeTriggerEntry(entry, index) { if (!entry || typeof entry !== 'object') return null; - const entityId = String(entry.entityId || entry.entity_id || '').trim(); const action = String(entry.action || '').trim(); - if (!entityId || !action) return null; + if (!action) return null; + const entityId = String(entry.entityId || entry.entity_id || '').trim(); + const eventType = String(entry.eventType || entry.event_type || '').trim(); + let source = null; + if (entityId) source = 'entity'; + if (!source && eventType) source = 'event'; + if (!source) return null; const stateEqualsRaw = entry.stateEquals ?? entry.state_equals; const stateEquals = stateEqualsRaw === null || stateEqualsRaw === undefined ? null : String(stateEqualsRaw).trim(); - const runtimeKey = `${action}::${entityId}::${stateEquals || '*'}::${index}`; + const eventData = entry.eventData && typeof entry.eventData === 'object' ? entry.eventData : null; + const runtimeKey = + source === 'entity' + ? `${action}::entity::${entityId}::${stateEquals || '*'}::${index}` + : `${action}::event::${eventType}::${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, + source, + entityId: source === 'entity' ? entityId : null, + eventType: source === 'event' ? eventType : null, + eventData: source === 'event' ? eventData : null, action, - stateEquals, + stateEquals: source === 'entity' ? stateEquals : null, payload: entry.payload && typeof entry.payload === 'object' ? entry.payload : {}, cooldownMs, allowedModes, @@ -221,11 +234,44 @@ function triggerMatches(trigger, raw, runtimeState) { return { matched: true, nextState, nextChanged, nextUpdated }; } -function evaluateTriggers(snapshot = {}) { - if (!triggerConfig.length) return; +function objectContainsSubset(actual, expected) { + if (!expected || typeof expected !== 'object') return true; + if (!actual || typeof actual !== 'object') return false; + return Object.entries(expected).every(([key, expectedValue]) => { + const actualValue = actual[key]; + if (expectedValue && typeof expectedValue === 'object' && !Array.isArray(expectedValue)) { + return objectContainsSubset(actualValue, expectedValue); + } + return String(actualValue) === String(expectedValue); + }); +} + +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 || {}), + }; + events.emit('trigger', basePayload); + publishEvent({ + source: 'homeAssistant', + type: HA_BUTTON_EVENT_TYPE, + payload: basePayload, + }); +} + +function evaluateTriggers(snapshot = {}) { + if (!triggerConfig.length) return; triggerConfig.forEach((trigger) => { + if (trigger.source !== 'entity') return; const runtimeState = triggerRuntime.get(trigger.runtimeKey) || { lastFiredAt: 0, lastState: null, @@ -239,39 +285,47 @@ function evaluateTriggers(snapshot = {}) { runtimeState.lastUpdated = evalResult.nextUpdated; triggerRuntime.set(trigger.runtimeKey, runtimeState); if (!evalResult.matched) return; - 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); - events.emit('trigger', { - buttonId: trigger.action, + dispatchButtonAction(trigger, runtimeState, { entityId: trigger.entityId, - action: trigger.action, state: raw?.state ?? null, attributes: raw?.attributes || {}, lastChanged: raw?.last_changed || null, lastUpdated: raw?.last_updated || null, - firedAt: now, }); - publishEvent({ - source: 'homeAssistant', - type: HA_BUTTON_EVENT_TYPE, - payload: { - buttonId: trigger.action, - entityId: trigger.entityId, - action: trigger.action, - state: raw?.state ?? null, - attributes: raw?.attributes || {}, - lastChanged: raw?.last_changed || null, - lastUpdated: raw?.last_updated || null, - firedAt: now, - ...(trigger.payload || {}), - }, + }); +} + +function handleHomeAssistantEvent(event = {}) { + if (!triggerConfig.length) return; + const eventType = String(event?.event_type || '').trim(); + if (!eventType) return; + triggerConfig.forEach((trigger) => { + if (trigger.source !== 'event') return; + if (trigger.eventType !== eventType) return; + if (trigger.eventData && !objectContainsSubset(event?.data || {}, trigger.eventData)) return; + const runtimeState = triggerRuntime.get(trigger.runtimeKey) || { + lastFiredAt: 0, + lastState: null, + lastChanged: null, + lastUpdated: null, + }; + dispatchButtonAction(trigger, runtimeState, { + eventType, + eventData: event?.data || {}, + timeFired: event?.time_fired || null, }); }); } function teardownConnection() { + if (eventUnsubscribers.length) { + eventUnsubscribers.forEach((unsubscribe) => { + Promise.resolve() + .then(() => (typeof unsubscribe === 'function' ? unsubscribe() : null)) + .catch((err) => logger.warn('Failed to unsubscribe Home Assistant event stream', err.message)); + }); + } + eventUnsubscribers = []; if (unsubscribeEntities) { try { unsubscribeEntities(); @@ -319,6 +373,24 @@ async function connect() { emitStatus(); logger.info('Connected to Home Assistant'); unsubscribeEntities = subscribeEntities(connection, handleEntitySnapshot); + const eventTypes = Array.from( + new Set( + triggerConfig + .filter((trigger) => trigger.source === 'event' && trigger.eventType) + .map((trigger) => trigger.eventType), + ), + ); + for (const eventType of eventTypes) { + try { + const unsubscribe = await connection.subscribeEvents(handleHomeAssistantEvent, eventType); + eventUnsubscribers.push(unsubscribe); + } catch (err) { + logger.warn('Failed to subscribe Home Assistant event stream', { eventType, error: err.message }); + } + } + if (eventTypes.length) { + logger.info('Subscribed Home Assistant event streams', { count: eventTypes.length, eventTypes }); + } connection.addEventListener('disconnected', () => { logger.warn('Home Assistant connection lost'); teardownConnection();