feat: Trigger cameras on HA bus events (#2512)

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:13 -07:00
committed by dermotduffy
parent 406176eebc
commit 0a36358394
49 changed files with 1376 additions and 269 deletions
+74
View File
@@ -0,0 +1,74 @@
import { HassEvent } from 'home-assistant-js-websocket';
import { HomeAssistant, SubscriptionUnsubscribe } from '../../ha/types';
export interface EventSubscriptionRequest {
event_type: string;
callback: (data: unknown) => void;
// Optional payload filter. Receives the event's `data`; if it returns false
// the event is dropped for this request.
matcher?: (data: unknown) => boolean;
}
export interface EventWatcherSubscriptionInterface {
subscribe(hass: HomeAssistant, request: EventSubscriptionRequest): Promise<void>;
unsubscribe(request: EventSubscriptionRequest): Promise<void>;
}
/**
* Subscribes to HA bus events via the WebSocket connection. Refcounted per
* `event_type`: the first subscriber for a type opens the WS subscription, the
* last to unsubscribe tears it down. Each fired event is fanned out to every
* registered request whose `event_type` matches and whose `matcher` accepts the
* payload.
*/
export class EventWatcher implements EventWatcherSubscriptionInterface {
private _requests: EventSubscriptionRequest[] = [];
// Stored as a promise so an unsubscribe that races against an in-flight
// subscribe can await completion before tearing down -- otherwise the unsub
// func is unavailable and the subscription would leak (via hass.connection's
// internal subscription map).
private _unsubscribers = new Map<string, Promise<SubscriptionUnsubscribe>>();
public async subscribe(
hass: HomeAssistant,
request: EventSubscriptionRequest,
): Promise<void> {
const isFirst = !this._hasSubscribers(request.event_type);
this._requests.push(request);
if (isFirst) {
const pendingSubscription = hass.connection.subscribeEvents<HassEvent>(
(event) => this._receiveEvent(event),
request.event_type,
);
this._unsubscribers.set(request.event_type, pendingSubscription);
await pendingSubscription;
}
}
public async unsubscribe(request: EventSubscriptionRequest): Promise<void> {
this._requests = this._requests.filter((r) => r !== request);
if (!this._hasSubscribers(request.event_type)) {
const pendingSubscription = this._unsubscribers.get(request.event_type);
this._unsubscribers.delete(request.event_type);
const unsubscribeCallback = await pendingSubscription;
await unsubscribeCallback?.();
}
}
private _hasSubscribers(eventType: string): boolean {
return this._requests.some((r) => r.event_type === eventType);
}
private _receiveEvent(event: HassEvent): void {
for (const request of this._requests) {
if (
request.event_type === event.event_type &&
(!request.matcher || request.matcher(event.data))
) {
request.callback(event.data);
}
}
}
}
+6
View File
@@ -3,12 +3,14 @@ import { HomeAssistant } from '../../ha/types';
import { log } from '../../utils/debug';
import { InitializationAspect } from '../initialization-manager';
import { CardHASSAPI } from '../types';
import { EventWatcher, EventWatcherSubscriptionInterface } from './event-watcher';
import { StateWatcher, StateWatcherSubscriptionInterface } from './state-watcher';
export class HASSManager {
private _hass: HomeAssistant | null = null;
private _api: CardHASSAPI;
private _stateWatcher: StateWatcher = new StateWatcher();
private _eventWatcher: EventWatcher = new EventWatcher();
constructor(api: CardHASSAPI) {
this._api = api;
@@ -26,6 +28,10 @@ export class HASSManager {
return this._stateWatcher;
}
public getEventWatcher(): EventWatcherSubscriptionInterface {
return this._eventWatcher;
}
public setHASS(hass?: HomeAssistant | null): void {
// When HA transitions from "not ready" to "ready" (WebSocket reconnected
// AND all integrations finished loading), reinitialize cameras and the
+15 -13
View File
@@ -110,15 +110,15 @@ export class TriggersManager {
return this._handleEndEvent(ev);
}
if (ev.type === 'signal') {
if (ev.type === 'momentary') {
const handled = await this.handleCameraEvent({ ...ev, type: 'new' }, options);
if (handled) {
// A signal is momentary -- handled as a matched new+end so concurrent
// continuous sources still gate untriggering correctly. The end leg is
// tagged `{ signal: true }` so `_startUntrigger` adds the synthesized
// signal on-period (`signal_hold_seconds`) on top of the usual
// A momentary event has no start/end -- handled as a matched new+end so
// concurrent continuous sources still gate untriggering correctly. The
// end leg is tagged `{ momentary: true }` so `_startUntrigger` adds the
// synthesized on-period (`event_hold_seconds`) on top of the usual
// post-source-end linger (`untrigger_delay_seconds`).
await this._handleEndEvent(ev, { signal: true });
await this._handleEndEvent(ev, { momentary: true });
}
return handled;
}
@@ -164,7 +164,7 @@ export class TriggersManager {
private async _handleEndEvent(
ev: CameraEvent,
options?: { signal?: boolean },
options?: { momentary?: boolean },
): Promise<boolean> {
this._deleteIgnoredEventID(ev.cameraID, ev.id);
@@ -313,7 +313,7 @@ export class TriggersManager {
private async _startUntrigger(
cameraID: string,
options?: { signal?: boolean },
options?: { momentary?: boolean },
): Promise<void> {
this._deleteUntriggerDelayTimer(cameraID);
this._deleteForceUntriggerTimer(cameraID);
@@ -325,12 +325,14 @@ export class TriggersManager {
const triggersConfig = this._api.getConfigManager().getConfig()?.view?.triggers;
const untriggerDelaySeconds = triggersConfig?.untrigger_delay_seconds ?? 0;
// For signals, add the synthesized on-period (signals have no native
// on/off, so hold them visible for `signal_hold_seconds` before the usual
// post-source-end linger kicks in).
const signalHoldSeconds = triggersConfig?.signal_hold_seconds ?? 0;
// For momentary events, add the synthesized on-period (they have no
// native on/off, so hold them visible before the usual post-source-end
// linger kicks in). The user-facing field is `event_hold_seconds` because
// HA events are the common case; internally this is the hold for any
// momentary source.
const momentaryHoldSeconds = triggersConfig?.event_hold_seconds ?? 0;
const effectiveDelaySeconds =
untriggerDelaySeconds + (options?.signal ? signalHoldSeconds : 0);
untriggerDelaySeconds + (options?.momentary ? momentaryHoldSeconds : 0);
if (effectiveDelaySeconds > 0) {
state.untriggerDelayTimer = new Timer();