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
@@ -1,3 +1,4 @@
import { EventWatcherSubscriptionInterface } from '../../card-controller/hass/event-watcher';
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
import { CameraConfig } from '../../config/schema/cameras';
import { BROWSE_MEDIA_CACHE_SECONDS } from '../../ha/browse-media/types';
@@ -37,12 +38,13 @@ export class BrowseMediaCameraManagerEngine
public constructor(
entityRegistryManager: EntityRegistryManager,
stateWatcher: StateWatcherSubscriptionInterface,
eventWatcher: EventWatcherSubscriptionInterface,
browseMediaManager: BrowseMediaWalker,
resolvedMediaCache: ResolvedMediaCache,
requestCache: CameraManagerRequestCache,
eventCallback?: CameraEventCallback,
) {
super(stateWatcher, entityRegistryManager, eventCallback);
super(stateWatcher, eventWatcher, entityRegistryManager, eventCallback);
this._entityRegistryManager = entityRegistryManager;
this._browseMediaWalker = browseMediaManager;
this._resolvedMediaCache = resolvedMediaCache;
+32 -1
View File
@@ -1,10 +1,15 @@
import { uniq } from 'lodash-es';
import { ActionsExecutor } from '../card-controller/actions/types';
import {
EventSubscriptionRequest,
EventWatcherSubscriptionInterface,
} from '../card-controller/hass/event-watcher';
import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher';
import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz';
import { CameraConfig } from '../config/schema/cameras';
import { CameraConfig, TriggerEvent } from '../config/schema/cameras';
import { EnabledProxyConfig, resolveProxyConfig } from '../config/schema/common/proxy';
import { computeDomain } from '../ha/compute-domain';
import { matchesEventData } from '../ha/event-data-match';
import { getTriggerEventType } from '../ha/get-trigger-event-type';
import { Entity, EntityRegistryManager } from '../ha/registry/entity/types';
import { HassStateDifference, HomeAssistant } from '../ha/types';
@@ -40,6 +45,7 @@ interface CapabilityOptions {
export interface CameraInitializationOptions {
hass: HomeAssistant;
stateWatcher: StateWatcherSubscriptionInterface;
eventWatcher: EventWatcherSubscriptionInterface;
capabilityOptions?: CapabilityOptions;
entityRegistryManager?: EntityRegistryManager;
}
@@ -85,16 +91,41 @@ export class Camera {
await this._getTriggerEntities(options);
this._config.triggers.entities = uniq(this._config.triggers.entities);
// Subscribe to state based triggers.
options.stateWatcher.subscribe(
this._stateChangeHandler,
this._config.triggers.entities,
);
this._onDestroy(() => options.stateWatcher.unsubscribe(this._stateChangeHandler));
// Subscribe to event based triggers.
for (const event of this._config.triggers.events) {
const request = this._buildEventSubscriptionRequest(event);
await options.eventWatcher.subscribe(options.hass, request);
this._onDestroy(() => options.eventWatcher.unsubscribe(request));
}
}
return this;
}
private _buildEventSubscriptionRequest(event: TriggerEvent): EventSubscriptionRequest {
const filter = event.event_data;
return {
event_type: event.event_type,
...(filter && { matcher: (data) => matchesEventData(filter, data) }),
callback: () => this._momentaryEventHandler(event.event_type),
};
}
private _momentaryEventHandler(eventType: string): void {
this._eventCallback?.({
cameraID: this.getID(),
id: `event:${eventType}`,
type: 'momentary',
});
}
private async _resolveEntity(
options: CameraInitializationOptions,
): Promise<Entity | null> {
+7
View File
@@ -1,3 +1,4 @@
import { EventWatcherSubscriptionInterface } from '../card-controller/hass/event-watcher';
import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher';
import { CameraConfig } from '../config/schema/cameras';
import { BrowseMediaWalker } from '../ha/browse-media/walker';
@@ -13,6 +14,7 @@ import { getCameraEntityFromConfig } from './utils/camera-entity-from-config';
interface CameraManagerEngineFactoryOptions {
stateWatcher: StateWatcherSubscriptionInterface;
eventWatcher: EventWatcherSubscriptionInterface;
resolvedMediaCache: ResolvedMediaCache;
eventCallback?: CameraEventCallback;
}
@@ -39,6 +41,7 @@ export class CameraManagerEngineFactory {
const { GenericCameraManagerEngine } = await import('./generic/engine-generic');
cameraManagerEngine = new GenericCameraManagerEngine(
options.stateWatcher,
options.eventWatcher,
this._entityRegistryManager,
options.eventCallback,
);
@@ -48,6 +51,7 @@ export class CameraManagerEngineFactory {
cameraManagerEngine = new FrigateCameraManagerEngine(
this._entityRegistryManager,
options.stateWatcher,
options.eventWatcher,
new RecordingSegmentsCache(),
new CameraManagerRequestCache(),
options.eventCallback,
@@ -60,6 +64,7 @@ export class CameraManagerEngineFactory {
cameraManagerEngine = new MotionEyeCameraManagerEngine(
this._entityRegistryManager,
options.stateWatcher,
options.eventWatcher,
new BrowseMediaWalker(),
options.resolvedMediaCache,
new CameraManagerRequestCache(),
@@ -72,6 +77,7 @@ export class CameraManagerEngineFactory {
this._entityRegistryManager,
this._deviceRegistryManager,
options.stateWatcher,
options.eventWatcher,
new BrowseMediaWalker(),
options.resolvedMediaCache,
new CameraManagerRequestCache(),
@@ -83,6 +89,7 @@ export class CameraManagerEngineFactory {
cameraManagerEngine = new TPLinkCameraManagerEngine(
this._entityRegistryManager,
options.stateWatcher,
options.eventWatcher,
options.eventCallback,
);
break;
+7 -7
View File
@@ -457,7 +457,7 @@ export class FrigateCamera extends Camera {
): Promise<void> {
const config = this.getConfig();
if (
!config.triggers.events.length ||
!config.triggers.media_events.length ||
!config.frigate.camera_name ||
!config.frigate.client_id
) {
@@ -500,12 +500,12 @@ export class FrigateCamera extends Camera {
return;
}
const eventsToTriggerOn = config.triggers.events;
const mediaEventsToTriggerOn = config.triggers.media_events;
if (
!(
eventsToTriggerOn.includes('events') ||
(eventsToTriggerOn.includes('snapshots') && snapshotChange) ||
(eventsToTriggerOn.includes('clips') && clipChange)
mediaEventsToTriggerOn.includes('events') ||
(mediaEventsToTriggerOn.includes('snapshots') && snapshotChange) ||
(mediaEventsToTriggerOn.includes('clips') && clipChange)
)
) {
return;
@@ -518,8 +518,8 @@ export class FrigateCamera extends Camera {
type: ev.type,
// In cases where there are both clip and snapshot media, ensure to only
// trigger on the media type that is allowed by the configuration.
clip: clipChange && eventsToTriggerOn.includes('clips'),
snapshot: snapshotChange && eventsToTriggerOn.includes('snapshots'),
clip: clipChange && mediaEventsToTriggerOn.includes('clips'),
snapshot: snapshotChange && mediaEventsToTriggerOn.includes('snapshots'),
});
};
+4 -1
View File
@@ -1,5 +1,6 @@
import { add, endOfHour, fromUnixTime, startOfHour } from 'date-fns';
import { isEqual, orderBy, throttle, uniqWith } from 'lodash-es';
import { EventWatcherSubscriptionInterface } from '../../card-controller/hass/event-watcher';
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
import { CameraConfig } from '../../config/schema/cameras';
import { getEntityTitle } from '../../ha/get-entity-title';
@@ -136,11 +137,12 @@ export class FrigateCameraManagerEngine
constructor(
entityRegistryManager: EntityRegistryManager,
stateWatcher: StateWatcherSubscriptionInterface,
eventWatcher: EventWatcherSubscriptionInterface,
recordingSegmentsCache: RecordingSegmentsCache,
requestCache: CameraManagerRequestCache,
eventCallback?: CameraEventCallback,
) {
super(stateWatcher, entityRegistryManager, eventCallback);
super(stateWatcher, eventWatcher, entityRegistryManager, eventCallback);
this._entityRegistryManager = entityRegistryManager;
this._frigateEventWatcher = new FrigateEventWatcher();
this._frigateReviewWatcher = new FrigateReviewWatcher();
@@ -163,6 +165,7 @@ export class FrigateCameraManagerEngine
hass,
entityRegistryManager: this._entityRegistryManager,
stateWatcher: this._stateWatcher,
eventWatcher: this._eventWatcher,
frigateEventWatcher: this._frigateEventWatcher,
frigateReviewWatcher: this._frigateReviewWatcher,
});
+1 -3
View File
@@ -1,5 +1,5 @@
import { z } from 'zod';
import { HomeAssistant } from '../../ha/types';
import { HomeAssistant, SubscriptionUnsubscribe } from '../../ha/types';
import {
FrigateEventChange,
FrigateReviewChange,
@@ -20,8 +20,6 @@ export interface FrigateWatcherSubscriptionInterface<T> {
unsubscribe(request: FrigateWatcherRequest<T>): void;
}
type SubscriptionUnsubscribe = () => Promise<void>;
/**
* Base class for Frigate WebSocket watchers.
* Handles subscription management and message routing to callbacks.
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { EventWatcherSubscriptionInterface } from '../../card-controller/hass/event-watcher';
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
import { CameraConfig } from '../../config/schema/cameras';
import { getEntityTitle } from '../../ha/get-entity-title';
@@ -41,14 +42,17 @@ import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
export class GenericCameraManagerEngine implements CameraManagerEngine {
protected _eventCallback?: CameraEventCallback;
protected _stateWatcher: StateWatcherSubscriptionInterface;
protected _eventWatcher: EventWatcherSubscriptionInterface;
protected _entityRegistryManager?: EntityRegistryManager;
constructor(
stateWatcher: StateWatcherSubscriptionInterface,
eventWatcher: EventWatcherSubscriptionInterface,
entityRegistryManager?: EntityRegistryManager,
eventCallback?: CameraEventCallback,
) {
this._stateWatcher = stateWatcher;
this._eventWatcher = eventWatcher;
this._entityRegistryManager = entityRegistryManager;
this._eventCallback = eventCallback;
}
@@ -66,6 +70,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
}).initialize({
hass,
stateWatcher: this._stateWatcher,
eventWatcher: this._eventWatcher,
entityRegistryManager: this._entityRegistryManager,
capabilityOptions: {
raw: {
+1
View File
@@ -205,6 +205,7 @@ export class CameraManager {
(await this._engineFactory.createEngine(engineType, {
eventCallback: (ev) => this._api.getTriggersManager().handleCameraEvent(ev),
stateWatcher: this._api.getHASSManager().getStateWatcher(),
eventWatcher: this._api.getHASSManager().getEventWatcher(),
resolvedMediaCache: this._api.getResolvedMediaCache(),
}))
: null;
@@ -78,6 +78,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
entityRegistryManager: this._entityRegistryManager,
hass,
stateWatcher: this._stateWatcher,
eventWatcher: this._eventWatcher,
});
}
@@ -1,5 +1,6 @@
import { add, endOfDay, parse, startOfDay } from 'date-fns';
import { orderBy } from 'lodash-es';
import { EventWatcherSubscriptionInterface } from '../../card-controller/hass/event-watcher';
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
import { CameraConfig } from '../../config/schema/cameras';
import { getViewMediaFromBrowseMediaArray } from '../../ha/browse-media/browse-media-to-view-media';
@@ -61,6 +62,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
entityRegistryManager: EntityRegistryManager,
deviceRegistryManager: DeviceRegistryManager,
stateWatcher: StateWatcherSubscriptionInterface,
eventWatcher: EventWatcherSubscriptionInterface,
browseMediaManager: BrowseMediaWalker,
resolvedMediaCache: ResolvedMediaCache,
requestCache: CameraManagerRequestCache,
@@ -69,6 +71,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
super(
entityRegistryManager,
stateWatcher,
eventWatcher,
browseMediaManager,
resolvedMediaCache,
requestCache,
@@ -176,6 +179,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
deviceRegistryManager: this._deviceRegistryManager,
hass,
stateWatcher: this._stateWatcher,
eventWatcher: this._eventWatcher,
});
}
+4 -1
View File
@@ -1,3 +1,4 @@
import { EventWatcherSubscriptionInterface } from '../../card-controller/hass/event-watcher';
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
import { CameraConfig } from '../../config/schema/cameras';
import { EntityRegistryManager } from '../../ha/registry/entity/types';
@@ -11,9 +12,10 @@ export class TPLinkCameraManagerEngine extends GenericCameraManagerEngine {
constructor(
entityRegistryManager: EntityRegistryManager,
stateWatcher: StateWatcherSubscriptionInterface,
eventWatcher: EventWatcherSubscriptionInterface,
eventCallback?: CameraEventCallback,
) {
super(stateWatcher, entityRegistryManager, eventCallback);
super(stateWatcher, eventWatcher, entityRegistryManager, eventCallback);
this._entityRegistryManager = entityRegistryManager;
}
@@ -32,6 +34,7 @@ export class TPLinkCameraManagerEngine extends GenericCameraManagerEngine {
entityRegistryManager: this._entityRegistryManager,
hass,
stateWatcher: this._stateWatcher,
eventWatcher: this._eventWatcher,
});
}
+1 -1
View File
@@ -158,7 +158,7 @@ export interface CameraEvent {
| 'update' // An update for an event is available (except GenAI).
| 'end' // An event has ended.
| 'genai' // An AI based update is available.
| 'signal'; // A momentary signal (no start/end, just an instant).
| 'momentary'; // A momentary event (no start/end, just an instant).
// When fidelity is `high`, the engine is assumed to provide exact details of
// what new media is available. Otherwise all media types are assumed to be