feat: Add event-based automation triggers (#2537)

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:13 -07:00
committed by dermotduffy
parent b701366762
commit a31816c168
109 changed files with 4607 additions and 1623 deletions
+62 -41
View File
@@ -43,17 +43,33 @@ export const isBirdseye = (cameraConfig: CameraConfig): boolean => {
};
export class FrigateCamera extends Camera {
// Short-circuits subscription when destroy() was invoked while base
// initialization was still awaiting. Set BEFORE awaiting `super.destroy()` so
// an in-flight initialize() sees the flip immediately.
private _destroyed = false;
public async initialize(options: FrigateCameraInitializationOptions): Promise<Camera> {
await super.initialize(options);
// A destroy() during the await above means the camera is being torn down;
// it must not register live subscriptions afterward.
if (this._destroyed) {
return this;
}
if (this._capabilities?.has('trigger')) {
await this._subscribeToEvents(options.hass, options.frigateEventWatcher);
await this._subscribeToReviews(options.hass, options.frigateReviewWatcher);
this._subscribeToEvents(options.frigateEventWatcher);
this._subscribeToReviews(options.frigateReviewWatcher);
}
return this;
}
public override async destroy(): Promise<void> {
this._destroyed = true;
await super.destroy();
}
public async executePTZAction(
executor: ActionsExecutor,
action: PTZAction,
@@ -104,9 +120,7 @@ export class FrigateCamera extends Camera {
return true;
}
protected override async _initialize(
options: FrigateCameraInitializationOptions,
): Promise<void> {
protected override async _initialize(hass: HomeAssistant): Promise<void> {
const config = this.getConfig();
const hasCameraName = !!config.frigate?.camera_name;
const cameraEntity = getCameraEntityFromConfig(config);
@@ -126,7 +140,7 @@ export class FrigateCamera extends Camera {
}
if (!this._config.frigate.client_id) {
const stateEntity = cameraEntity ? options.hass.states[cameraEntity] : undefined;
const stateEntity = cameraEntity ? hass.states[cameraEntity] : undefined;
const clientID = stateEntity?.attributes?.client_id;
if (typeof clientID === 'string' && clientID) {
this._config.frigate.client_id = clientID;
@@ -137,13 +151,15 @@ export class FrigateCamera extends Camera {
}
protected override async _getTriggerEntities(
hass: HomeAssistant,
options: FrigateCameraInitializationOptions,
): Promise<void> {
await this._getFrigateMotionAndOccupancyEntities(options);
await super._getTriggerEntities(options);
await this._getFrigateMotionAndOccupancyEntities(hass, options);
await super._getTriggerEntities(hass, options);
}
private async _getFrigateMotionAndOccupancyEntities(
hass: HomeAssistant,
options: FrigateCameraInitializationOptions,
): Promise<void> {
const config = this.getConfig();
@@ -162,7 +178,7 @@ export class FrigateCamera extends Camera {
// searching via unique_id ensures this still works if the user renames
// the entity_id.
const binarySensorEntities = await options.entityRegistryManager.getMatchingEntities(
options.hass,
hass,
(ent) =>
ent.config_entry_id === this._entity?.config_entry_id &&
!ent.disabled_by &&
@@ -189,12 +205,13 @@ export class FrigateCamera extends Camera {
}
protected async _getRawCapabilities(
hass: HomeAssistant,
options: FrigateCameraInitializationOptions,
): Promise<CapabilitiesRaw> {
const base = await super._getRawCapabilities(options);
const base = await super._getRawCapabilities(hass, options);
const config = this.getConfig();
const frigatePTZ = await this._getPTZCapabilities(options.hass, config);
const frigatePTZ = await this._getPTZCapabilities(hass, config);
const configPTZ = getPTZCapabilitiesFromCameraConfig(config);
const combinedPTZ: PTZCapabilities | null =
configPTZ || frigatePTZ ? { ...frigatePTZ, ...configPTZ } : null;
@@ -451,10 +468,9 @@ export class FrigateCamera extends Camera {
return null;
}
private async _subscribeToEvents(
hass: HomeAssistant,
private _subscribeToEvents(
frigateEventWatcher: FrigateWatcherSubscriptionInterface<FrigateEventChange>,
): Promise<void> {
): void {
const config = this.getConfig();
if (
!config.triggers.media_events.length ||
@@ -473,10 +489,8 @@ export class FrigateCamera extends Camera {
event.after.camera === config.frigate.camera_name,
};
await this._setupSubscription(
() => frigateEventWatcher.subscribe(hass, request),
() => frigateEventWatcher.unsubscribe(request),
);
frigateEventWatcher.subscribe(request);
this._onDestroy(() => frigateEventWatcher.unsubscribe(request));
}
private _frigateEventHandler = (ev: FrigateEventChange): void => {
@@ -494,23 +508,33 @@ export class FrigateCamera extends Camera {
return;
}
if (
(config.frigate.zones?.length &&
!config.frigate.zones.some((zone) => ev.after.current_zones.includes(zone))) ||
(config.frigate.labels?.length && !config.frigate.labels.includes(ev.after.label))
) {
return;
}
const mediaEventsToTriggerOn = config.triggers.media_events;
if (
!(
mediaEventsToTriggerOn.includes('events') ||
(mediaEventsToTriggerOn.includes('snapshots') && snapshotChange) ||
(mediaEventsToTriggerOn.includes('clips') && clipChange)
)
) {
return;
// The zone/label/media checks decide when to START a trigger, so they only
// apply to 'new'/'update'. An 'end' always passes through: it ends whatever
// trigger an earlier event with the same id started, and by 'end' the
// object may have left the zone or the media flag may differ -- the trigger
// must still clear. (The trigger manager ignores an 'end' for an id that
// never triggered, so a pass-through 'end' is harmless.)
if (ev.type !== 'end') {
if (
(config.frigate.zones?.length &&
!config.frigate.zones.some((zone) => ev.after.current_zones.includes(zone))) ||
(config.frigate.labels?.length &&
!config.frigate.labels.includes(ev.after.label))
) {
return;
}
if (
!(
mediaEventsToTriggerOn.includes('events') ||
(mediaEventsToTriggerOn.includes('snapshots') && snapshotChange) ||
(mediaEventsToTriggerOn.includes('clips') && clipChange)
)
) {
return;
}
}
this._eventCallback?.({
@@ -525,10 +549,9 @@ export class FrigateCamera extends Camera {
});
};
private async _subscribeToReviews(
hass: HomeAssistant,
private _subscribeToReviews(
frigateReviewWatcher: FrigateWatcherSubscriptionInterface<FrigateReviewChange>,
): Promise<void> {
): void {
const config = this.getConfig();
const reviewConfig = config.triggers.reviews;
@@ -550,10 +573,8 @@ export class FrigateCamera extends Camera {
review.after.camera === config.frigate.camera_name,
};
await this._setupSubscription(
() => frigateReviewWatcher.subscribe(hass, request),
() => frigateReviewWatcher.unsubscribe(request),
);
frigateReviewWatcher.subscribe(request);
this._onDestroy(() => frigateReviewWatcher.unsubscribe(request));
}
private _frigateReviewHandler = (review: FrigateReviewChange): void => {
+7 -14
View File
@@ -1,7 +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 { HASSManagerReadonlyInterface } from '../../card-controller/hass/types';
import { CameraConfig } from '../../config/schema/cameras';
import { getEntityTitle } from '../../ha/get-entity-title';
import { EntityRegistryManager } from '../../ha/registry/entity/types';
@@ -136,16 +135,15 @@ export class FrigateCameraManagerEngine
constructor(
entityRegistryManager: EntityRegistryManager,
stateWatcher: StateWatcherSubscriptionInterface,
eventWatcher: EventWatcherSubscriptionInterface,
hassManager: HASSManagerReadonlyInterface,
recordingSegmentsCache: RecordingSegmentsCache,
requestCache: CameraManagerRequestCache,
eventCallback?: CameraEventCallback,
) {
super(stateWatcher, eventWatcher, entityRegistryManager, eventCallback);
super(hassManager, entityRegistryManager, eventCallback);
this._entityRegistryManager = entityRegistryManager;
this._frigateEventWatcher = new FrigateEventWatcher();
this._frigateReviewWatcher = new FrigateReviewWatcher();
this._frigateEventWatcher = new FrigateEventWatcher(hassManager);
this._frigateReviewWatcher = new FrigateReviewWatcher(hassManager);
this._recordingSegmentsCache = recordingSegmentsCache;
this._requestCache = requestCache;
}
@@ -154,18 +152,13 @@ export class FrigateCameraManagerEngine
return Engine.Frigate;
}
public async createCamera(
hass: HomeAssistant,
cameraConfig: CameraConfig,
): Promise<Camera> {
public async createCamera(cameraConfig: CameraConfig): Promise<Camera> {
const camera = new FrigateCamera(cameraConfig, this, {
eventCallback: this._eventCallback,
});
return await camera.initialize({
hass,
hassManager: this._hassManager,
entityRegistryManager: this._entityRegistryManager,
stateWatcher: this._stateWatcher,
eventWatcher: this._eventWatcher,
frigateEventWatcher: this._frigateEventWatcher,
frigateReviewWatcher: this._frigateReviewWatcher,
});
+29 -24
View File
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { HomeAssistant } from '../../ha/types';
import { KeyedSubscriptionManager } from '../../utils/concurrency/keyed-subscription-manager';
import { HASSConnectionSubscriptionManager } from '../../ha/connection/subscription-manager';
import { HASSSource } from '../../ha/source';
import {
FrigateEventChange,
FrigateReviewChange,
@@ -17,43 +17,48 @@ export interface FrigateWatcherRequest<T> {
// Generic subscription interface
export interface FrigateWatcherSubscriptionInterface<T> {
subscribe(hass: HomeAssistant, request: FrigateWatcherRequest<T>): Promise<void>;
unsubscribe(request: FrigateWatcherRequest<T>): Promise<void>;
subscribe(request: FrigateWatcherRequest<T>): void;
unsubscribe(request: FrigateWatcherRequest<T>): void;
}
/**
* Base class for Frigate WebSocket watchers. Counted per `instanceID`: the
* first subscriber for an instance opens the WS subscription, the last to
* unsubscribe tears it down. Each message is parsed, schema-validated, and
* fanned out to every registered request whose `instanceID` matches and whose
* `matcher` accepts the payload.
* Base class for Frigate WebSocket watchers. Thin wrapper over
* `HASSConnectionSubscriptionManager`: keys by `instanceID`, parses and
* schema-validates each message, then fans out to every registered request
* whose `instanceID` matches and whose optional `matcher` accepts the payload.
*/
abstract class FrigateWatcher<T> implements FrigateWatcherSubscriptionInterface<T> {
protected abstract _type: string;
protected abstract _schema: z.ZodType<T>;
private _subscriptions = new KeyedSubscriptionManager<
string,
FrigateWatcherRequest<T>
>((request) => request.instanceID);
private _manager: HASSConnectionSubscriptionManager<string, FrigateWatcherRequest<T>>;
public async subscribe(
hass: HomeAssistant,
request: FrigateWatcherRequest<T>,
): Promise<void> {
await this._subscriptions.subscribe(request, () =>
hass.connection.subscribeMessage<string>(
(data) => this._receiveHandler(request.instanceID, data),
constructor(source: HASSSource) {
this._manager = new HASSConnectionSubscriptionManager(
(request) => request.instanceID,
source,
);
}
public subscribe(request: FrigateWatcherRequest<T>): void {
this._manager.subscribe(request, (connection, liveness) =>
connection.subscribeMessage<string>(
(data) => {
if (!liveness.isConnected()) {
return;
}
this._receive(request.instanceID, data);
},
{ type: this._type, instance_id: request.instanceID },
),
);
}
public async unsubscribe(request: FrigateWatcherRequest<T>): Promise<void> {
await this._subscriptions.unsubscribe(request);
public unsubscribe(request: FrigateWatcherRequest<T>): void {
this._manager.unsubscribe(request);
}
protected _receiveHandler(instanceID: string, data: string): void {
private _receive(instanceID: string, data: string): void {
let json: unknown;
try {
json = JSON.parse(data);
@@ -69,7 +74,7 @@ abstract class FrigateWatcher<T> implements FrigateWatcherSubscriptionInterface<
return;
}
for (const request of this._subscriptions.getRequestsForKey(instanceID)) {
for (const request of this._manager.getRequestsForKey(instanceID)) {
if (!request.matcher || request.matcher(parseResult.data)) {
request.callback(parseResult.data);
}