mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
add button support
This commit is contained in:
@@ -42,15 +42,19 @@ homeAssistant:
|
|||||||
# type is optional; if omitted it is inferred from the entity id (light/switch)
|
# type is optional; if omitted it is inferred from the entity id (light/switch)
|
||||||
buttons:
|
buttons:
|
||||||
# All Home Assistant button automations live here.
|
# All Home Assistant button automations live here.
|
||||||
- entityId: "sensor.human_alert_button_action"
|
# Option A (state-based entity trigger):
|
||||||
stateEquals: "single"
|
# - 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
|
cooldownMs: 15000
|
||||||
action: "humanAlert"
|
action: "humanAlert"
|
||||||
# Example future button:
|
|
||||||
# - entityId: "sensor.replay_button_action"
|
|
||||||
# stateEquals: "single"
|
|
||||||
# cooldownMs: 10000
|
|
||||||
# action: "replayClip"
|
|
||||||
roomCameras:
|
roomCameras:
|
||||||
- id: "lobby"
|
- id: "lobby"
|
||||||
name: "Lobby Camera"
|
name: "Lobby Camera"
|
||||||
|
|||||||
@@ -19,12 +19,13 @@ const haConfig = config.homeAssistant || {};
|
|||||||
const events = new EventEmitter();
|
const events = new EventEmitter();
|
||||||
const entityConfig = new Map(); // entityId -> { id, name, type }
|
const entityConfig = new Map(); // entityId -> { id, name, type }
|
||||||
const entityState = new Map(); // entityId -> normalized state
|
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 triggerRuntime = new Map(); // triggerId -> { lastFiredAt, lastState, lastChanged, lastUpdated }
|
||||||
const HA_BUTTON_EVENT_TYPE = 'ha.button.action';
|
const HA_BUTTON_EVENT_TYPE = 'ha.button.action';
|
||||||
|
|
||||||
let connection = null;
|
let connection = null;
|
||||||
let unsubscribeEntities = null;
|
let unsubscribeEntities = null;
|
||||||
|
let eventUnsubscribers = [];
|
||||||
let reconnectTimer = null;
|
let reconnectTimer = null;
|
||||||
let connected = false;
|
let connected = false;
|
||||||
|
|
||||||
@@ -84,22 +85,34 @@ function loadEntityConfig() {
|
|||||||
|
|
||||||
function normalizeTriggerEntry(entry, index) {
|
function normalizeTriggerEntry(entry, index) {
|
||||||
if (!entry || typeof entry !== 'object') return null;
|
if (!entry || typeof entry !== 'object') return null;
|
||||||
const entityId = String(entry.entityId || entry.entity_id || '').trim();
|
|
||||||
const action = String(entry.action || '').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 stateEqualsRaw = entry.stateEquals ?? entry.state_equals;
|
||||||
const stateEquals =
|
const stateEquals =
|
||||||
stateEqualsRaw === null || stateEqualsRaw === undefined ? null : String(stateEqualsRaw).trim();
|
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 cooldownMs = Number.isFinite(Number(entry.cooldownMs)) ? Math.max(0, Number(entry.cooldownMs)) : 0;
|
||||||
const allowedModes = Array.isArray(entry.allowedModes)
|
const allowedModes = Array.isArray(entry.allowedModes)
|
||||||
? entry.allowedModes.map((mode) => String(mode || '').trim().toLowerCase()).filter(Boolean)
|
? entry.allowedModes.map((mode) => String(mode || '').trim().toLowerCase()).filter(Boolean)
|
||||||
: null;
|
: null;
|
||||||
return {
|
return {
|
||||||
runtimeKey,
|
runtimeKey,
|
||||||
entityId,
|
source,
|
||||||
|
entityId: source === 'entity' ? entityId : null,
|
||||||
|
eventType: source === 'event' ? eventType : null,
|
||||||
|
eventData: source === 'event' ? eventData : null,
|
||||||
action,
|
action,
|
||||||
stateEquals,
|
stateEquals: source === 'entity' ? stateEquals : null,
|
||||||
payload: entry.payload && typeof entry.payload === 'object' ? entry.payload : {},
|
payload: entry.payload && typeof entry.payload === 'object' ? entry.payload : {},
|
||||||
cooldownMs,
|
cooldownMs,
|
||||||
allowedModes,
|
allowedModes,
|
||||||
@@ -221,11 +234,44 @@ function triggerMatches(trigger, raw, runtimeState) {
|
|||||||
return { matched: true, nextState, nextChanged, nextUpdated };
|
return { matched: true, nextState, nextChanged, nextUpdated };
|
||||||
}
|
}
|
||||||
|
|
||||||
function evaluateTriggers(snapshot = {}) {
|
function objectContainsSubset(actual, expected) {
|
||||||
if (!triggerConfig.length) return;
|
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 now = Date.now();
|
||||||
const mode = String(getMode() || '').toLowerCase();
|
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) => {
|
triggerConfig.forEach((trigger) => {
|
||||||
|
if (trigger.source !== 'entity') return;
|
||||||
const runtimeState = triggerRuntime.get(trigger.runtimeKey) || {
|
const runtimeState = triggerRuntime.get(trigger.runtimeKey) || {
|
||||||
lastFiredAt: 0,
|
lastFiredAt: 0,
|
||||||
lastState: null,
|
lastState: null,
|
||||||
@@ -239,39 +285,47 @@ function evaluateTriggers(snapshot = {}) {
|
|||||||
runtimeState.lastUpdated = evalResult.nextUpdated;
|
runtimeState.lastUpdated = evalResult.nextUpdated;
|
||||||
triggerRuntime.set(trigger.runtimeKey, runtimeState);
|
triggerRuntime.set(trigger.runtimeKey, runtimeState);
|
||||||
if (!evalResult.matched) return;
|
if (!evalResult.matched) return;
|
||||||
if (trigger.allowedModes?.length && !trigger.allowedModes.includes(mode)) return;
|
dispatchButtonAction(trigger, runtimeState, {
|
||||||
if (trigger.cooldownMs > 0 && now - runtimeState.lastFiredAt < trigger.cooldownMs) return;
|
|
||||||
runtimeState.lastFiredAt = now;
|
|
||||||
triggerRuntime.set(trigger.runtimeKey, runtimeState);
|
|
||||||
events.emit('trigger', {
|
|
||||||
buttonId: trigger.action,
|
|
||||||
entityId: trigger.entityId,
|
entityId: trigger.entityId,
|
||||||
action: trigger.action,
|
|
||||||
state: raw?.state ?? null,
|
state: raw?.state ?? null,
|
||||||
attributes: raw?.attributes || {},
|
attributes: raw?.attributes || {},
|
||||||
lastChanged: raw?.last_changed || null,
|
lastChanged: raw?.last_changed || null,
|
||||||
lastUpdated: raw?.last_updated || null,
|
lastUpdated: raw?.last_updated || null,
|
||||||
firedAt: now,
|
|
||||||
});
|
});
|
||||||
publishEvent({
|
});
|
||||||
source: 'homeAssistant',
|
}
|
||||||
type: HA_BUTTON_EVENT_TYPE,
|
|
||||||
payload: {
|
function handleHomeAssistantEvent(event = {}) {
|
||||||
buttonId: trigger.action,
|
if (!triggerConfig.length) return;
|
||||||
entityId: trigger.entityId,
|
const eventType = String(event?.event_type || '').trim();
|
||||||
action: trigger.action,
|
if (!eventType) return;
|
||||||
state: raw?.state ?? null,
|
triggerConfig.forEach((trigger) => {
|
||||||
attributes: raw?.attributes || {},
|
if (trigger.source !== 'event') return;
|
||||||
lastChanged: raw?.last_changed || null,
|
if (trigger.eventType !== eventType) return;
|
||||||
lastUpdated: raw?.last_updated || null,
|
if (trigger.eventData && !objectContainsSubset(event?.data || {}, trigger.eventData)) return;
|
||||||
firedAt: now,
|
const runtimeState = triggerRuntime.get(trigger.runtimeKey) || {
|
||||||
...(trigger.payload || {}),
|
lastFiredAt: 0,
|
||||||
},
|
lastState: null,
|
||||||
|
lastChanged: null,
|
||||||
|
lastUpdated: null,
|
||||||
|
};
|
||||||
|
dispatchButtonAction(trigger, runtimeState, {
|
||||||
|
eventType,
|
||||||
|
eventData: event?.data || {},
|
||||||
|
timeFired: event?.time_fired || null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function teardownConnection() {
|
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) {
|
if (unsubscribeEntities) {
|
||||||
try {
|
try {
|
||||||
unsubscribeEntities();
|
unsubscribeEntities();
|
||||||
@@ -319,6 +373,24 @@ async function connect() {
|
|||||||
emitStatus();
|
emitStatus();
|
||||||
logger.info('Connected to Home Assistant');
|
logger.info('Connected to Home Assistant');
|
||||||
unsubscribeEntities = subscribeEntities(connection, handleEntitySnapshot);
|
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', () => {
|
connection.addEventListener('disconnected', () => {
|
||||||
logger.warn('Home Assistant connection lost');
|
logger.warn('Home Assistant connection lost');
|
||||||
teardownConnection();
|
teardownConnection();
|
||||||
|
|||||||
Reference in New Issue
Block a user