feat: Trigger cameras on HA bus events (#2512)
This commit is contained in:
committed by
dermotduffy
parent
406176eebc
commit
0a36358394
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user