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