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
+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();
+32
View File
@@ -877,6 +877,29 @@ const frigateCardToAdvancedCameraCardStyleTransform = (data: unknown): unknown =
return newStyleOverrides;
};
// Legacy `triggers.events: string[]` (Frigate engine media-availability filter)
// was renamed to `triggers.media_events` to free up `triggers.events` for the
// new HA-bus-event trigger list (object shape). Distinguish old from new by
// element type: an all-string array is legacy; any non-string element marks
// the new shape and must not be touched. If `media_events` already exists we
// refuse to overwrite it -- but we still drop the legacy `events` (otherwise
// it would fail the new schema, which expects objects).
const triggersEventsToMediaEventsTransform = (triggers: unknown): unknown => {
if (typeof triggers !== 'object' || !triggers) {
return undefined;
}
const events = triggers['events'];
if (!Array.isArray(events) || events.some((x) => typeof x !== 'string')) {
return undefined;
}
const result = { ...triggers };
delete result['events'];
if (!('media_events' in result)) {
result['media_events'] = events;
}
return result;
};
const UPGRADES = [
// v5.2.0 -> v6.0.0
(data: unknown): boolean => {
@@ -1090,4 +1113,13 @@ const UPGRADES = [
typeof data === 'object' && data ? (data as RawAdvancedCameraCardConfig) : {},
);
},
// Legacy `triggers.events: string[]` → `triggers.media_events`. Targets the
// two known places a camera config lives: `cameras_global` and `cameras[]`.
// Mirrors the PTZ rename migration above.
upgradeWithOverrides('cameras_global.triggers', triggersEventsToMediaEventsTransform),
upgradeArrayOfObjects(
CONF_CAMERAS,
upgradeWithOverrides('triggers', triggersEventsToMediaEventsTransform),
),
];
+15 -6
View File
@@ -8,7 +8,7 @@ import { imageBaseConfigDefault, imageBaseConfigSchema } from './common/image';
import { proxyBaseConfigDefault, proxyBaseConfigSchema } from './common/proxy';
import { severitySchema } from './common/severity';
const CAMERA_TRIGGER_EVENT_TYPES = [
const CAMERA_TRIGGER_MEDIA_EVENT_TYPES = [
// An event whether or not it has any media yet associated with it.
'events',
@@ -16,7 +16,8 @@ const CAMERA_TRIGGER_EVENT_TYPES = [
'clips',
'snapshots',
] as const;
export type CameraTriggerEventType = (typeof CAMERA_TRIGGER_EVENT_TYPES)[number];
export type CameraTriggerMediaEventType =
(typeof CAMERA_TRIGGER_MEDIA_EVENT_TYPES)[number];
// *************************************************************************
// Live Provider Configuration
@@ -149,8 +150,9 @@ export const cameraConfigDefault = {
motion: false,
occupancy: false,
doorbell: false,
events: [],
media_events: [],
entities: [],
events: [],
reviews: {
severities: ['high' as const],
description: true,
@@ -217,6 +219,12 @@ const cameraMediaConfigSchema = z.object({
.default(cameraMediaConfigDefault.reviewed),
});
const triggerEventSchema = z.object({
event_type: z.string().min(1),
event_data: z.record(z.string(), z.unknown()).optional(),
});
export type TriggerEvent = z.infer<typeof triggerEventSchema>;
export const cameraConfigSchema = z
.looseObject({
camera_entity: z.string().optional(),
@@ -251,10 +259,11 @@ export const cameraConfigSchema = z
occupancy: z.boolean().default(cameraConfigDefault.triggers.occupancy),
doorbell: z.boolean().default(cameraConfigDefault.triggers.doorbell),
entities: z.string().array().default(cameraConfigDefault.triggers.entities),
events: z
.enum(CAMERA_TRIGGER_EVENT_TYPES)
events: triggerEventSchema.array().default(cameraConfigDefault.triggers.events),
media_events: z
.enum(CAMERA_TRIGGER_MEDIA_EVENT_TYPES)
.array()
.default(cameraConfigDefault.triggers.events),
.default(cameraConfigDefault.triggers.media_events),
reviews: z
.object({
severities: severitySchema
+2 -4
View File
@@ -72,7 +72,7 @@ export const viewConfigDefault = {
},
untrigger_delay_seconds: 0,
untrigger_force_seconds: 0,
signal_hold_seconds: 30,
event_hold_seconds: 30,
},
keyboard_shortcuts: keyboardShortcutsDefault,
issues: {
@@ -110,9 +110,7 @@ export const triggersSchema = z.object({
untrigger_force_seconds: z
.number()
.default(viewConfigDefault.triggers.untrigger_force_seconds),
signal_hold_seconds: z
.number()
.default(viewConfigDefault.triggers.signal_hold_seconds),
event_hold_seconds: z.number().default(viewConfigDefault.triggers.event_hold_seconds),
});
export type TriggersOptions = z.infer<typeof triggersSchema>;
+4 -2
View File
@@ -117,6 +117,8 @@ export const CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES =
`${CONF_CAMERAS}.#.triggers.entities` as const;
export const CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS =
`${CONF_CAMERAS}.#.triggers.events` as const;
export const CONF_CAMERAS_ARRAY_TRIGGERS_MEDIA_EVENTS =
`${CONF_CAMERAS}.#.triggers.media_events` as const;
export const CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_SEVERITIES =
`${CONF_CAMERAS}.#.triggers.reviews.severities` as const;
export const CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_DESCRIPTION =
@@ -197,8 +199,8 @@ export const CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS =
`${CONF_VIEW_TRIGGERS}.untrigger_delay_seconds` as const;
export const CONF_VIEW_TRIGGERS_UNTRIGGER_FORCE_SECONDS =
`${CONF_VIEW_TRIGGERS}.untrigger_force_seconds` as const;
export const CONF_VIEW_TRIGGERS_SIGNAL_HOLD_SECONDS =
`${CONF_VIEW_TRIGGERS}.signal_hold_seconds` as const;
export const CONF_VIEW_TRIGGERS_EVENT_HOLD_SECONDS =
`${CONF_VIEW_TRIGGERS}.event_hold_seconds` as const;
export const CONF_VIEW_TRIGGERS_ACTIONS = `${CONF_VIEW_TRIGGERS}.actions` as const;
export const CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE =
`${CONF_VIEW_TRIGGERS_ACTIONS}.interaction_mode` as const;
+132 -13
View File
@@ -93,6 +93,7 @@ import {
CONF_CAMERAS_ARRAY_TRIGGERS_DOORBELL,
CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES,
CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS,
CONF_CAMERAS_ARRAY_TRIGGERS_MEDIA_EVENTS,
CONF_CAMERAS_ARRAY_TRIGGERS_MOTION,
CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY,
CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_DESCRIPTION,
@@ -279,7 +280,7 @@ import {
CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER,
CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS,
CONF_VIEW_TRIGGERS_SIGNAL_HOLD_SECONDS,
CONF_VIEW_TRIGGERS_EVENT_HOLD_SECONDS,
CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS,
CONF_VIEW_TRIGGERS_UNTRIGGER_FORCE_SECONDS,
DOCS_URL,
@@ -313,6 +314,8 @@ const MENU_CAMERAS_MOTIONEYE = 'cameras.motioneye';
const MENU_CAMERAS_PROXY = 'cameras.proxy';
const MENU_CAMERAS_REOLINK = 'cameras.reolink';
const MENU_CAMERAS_TRIGGERS = 'cameras.triggers';
const MENU_CAMERAS_TRIGGERS_EVENT = 'cameras.triggers.event';
const MENU_CAMERAS_TRIGGERS_EVENTS = 'cameras.triggers.events';
const MENU_CAMERAS_TRIGGERS_REVIEWS = 'cameras.triggers.reviews';
const MENU_CAMERAS_WEBRTC_CARD = 'cameras.webrtc_card';
const MENU_CAMERAS_MEDIA = 'cameras.media';
@@ -1040,19 +1043,19 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
},
];
private _triggersEvents: EditorSelectOption[] = [
private _triggersMediaEvents: EditorSelectOption[] = [
{ value: '', label: '' },
{
value: 'events',
label: localize('config.cameras.triggers.events.events'),
label: localize('config.cameras.triggers.media_events.events'),
},
{
value: 'clips',
label: localize('config.cameras.triggers.events.clips'),
label: localize('config.cameras.triggers.media_events.clips'),
},
{
value: 'snapshots',
label: localize('config.cameras.triggers.events.snapshots'),
label: localize('config.cameras.triggers.media_events.snapshots'),
},
];
@@ -1523,6 +1526,16 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
);
}
private _getEditorTriggerEventTitle(
eventIndex: number,
eventConfig: RawAdvancedCameraCardConfig,
): string {
return (
(typeof eventConfig?.event_type === 'string' && eventConfig.event_type) ||
localize('common.event') + ' #' + eventIndex
);
}
private _renderViewDefaultResetMenu(): TemplateResult {
return this._putInSubmenu(
MENU_VIEW_DEFAULT_RESET,
@@ -1599,8 +1612,8 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
${this._renderNumberInput(CONF_VIEW_TRIGGERS_UNTRIGGER_FORCE_SECONDS, {
default: this._defaults.view.triggers.untrigger_force_seconds,
})}
${this._renderNumberInput(CONF_VIEW_TRIGGERS_SIGNAL_HOLD_SECONDS, {
default: this._defaults.view.triggers.signal_hold_seconds,
${this._renderNumberInput(CONF_VIEW_TRIGGERS_EVENT_HOLD_SECONDS, {
default: this._defaults.view.triggers.event_hold_seconds,
})}
${this._putInSubmenu(
MENU_VIEW_TRIGGERS_ACTIONS,
@@ -2340,8 +2353,8 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
.label=${localize('editor.move_down')}
.disabled=${add ||
!this._config ||
!Array.isArray(this._config.cameras) ||
index >= this._config.cameras.length - 1}
!Array.isArray(array) ||
index >= array.length - 1}
@click=${() =>
!add &&
this._modifyConfig((config: RawAdvancedCameraCardConfig): boolean => {
@@ -2360,7 +2373,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
</ha-icon-button>
<ha-icon-button
.label=${localize('editor.delete')}
.disabled=${add}
.disabled=${!!add}
@click=${() => {
this._modifyConfig((config: RawAdvancedCameraCardConfig): boolean => {
const array = getConfigValue(config, configPathArray);
@@ -2463,6 +2476,83 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
</div>`;
}
private _renderTriggerEvent(
cameraIndex: number,
events: RawAdvancedCameraCardConfigArray,
eventIndex: number,
addNewEvent?: boolean,
): TemplateResult | void {
const eventsPath = getArrayConfigPath(
CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS,
cameraIndex,
);
const submenuClasses = {
submenu: true,
selected: this._expandedMenus[MENU_CAMERAS_TRIGGERS_EVENT] === eventIndex,
};
const title = this._getEditorTriggerEventTitle(eventIndex, events[eventIndex] ?? {});
const eventTypePath = `${eventsPath}.[${eventIndex}].event_type`;
return html` <div class="${classMap(submenuClasses)}">
<div
class="submenu-header"
@click=${this._toggleMenu}
.domain=${MENU_CAMERAS_TRIGGERS_EVENT}
.key=${eventIndex}
>
<advanced-camera-card-icon
.icon=${{ icon: addNewEvent ? 'mdi:plus' : 'mdi:flash' }}
></advanced-camera-card-icon>
<span>
${addNewEvent
? html` <span class="new">
[${localize('config.cameras.triggers.events.add_new_event')}...]
</span>`
: html`<span>${title}</span>`}
</span>
</div>
${this._expandedMenus[MENU_CAMERAS_TRIGGERS_EVENT] === eventIndex
? html` <div class="values">
${this._renderArrayManagementControls(
eventsPath,
eventIndex,
MENU_CAMERAS_TRIGGERS_EVENT,
addNewEvent,
)}
${this._renderStringInput(eventTypePath, {
label: localize('config.cameras.triggers.events.event_type'),
})}
${this._renderObjectSelector(`${eventsPath}.[${eventIndex}].event_data`, {
label: localize('config.cameras.triggers.events.event_data'),
})}
</div>`
: ''}
</div>`;
}
private _renderTriggerEvents(cameraIndex: number): TemplateResult | void {
if (!this._config) {
return;
}
const events =
(getConfigValue(
this._config,
getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS, cameraIndex),
) as RawAdvancedCameraCardConfigArray | undefined) ?? [];
return this._putInSubmenu(
MENU_CAMERAS_TRIGGERS_EVENTS,
cameraIndex,
'config.cameras.triggers.events.editor_label',
'mdi:home-assistant',
html`
${events.map((_, index) => this._renderTriggerEvent(cameraIndex, events, index))}
${this._renderTriggerEvent(cameraIndex, events, events.length, true)}
`,
);
}
/**
* Render a camera section.
* @param cameras The full array of cameras.
@@ -2810,11 +2900,16 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
},
)}
${this._renderOptionSelector(
getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS, cameraIndex),
this._triggersEvents,
getArrayConfigPath(
CONF_CAMERAS_ARRAY_TRIGGERS_MEDIA_EVENTS,
cameraIndex,
),
this._triggersMediaEvents,
{
multiple: true,
label: localize('config.cameras.triggers.events.editor_label'),
label: localize(
'config.cameras.triggers.media_events.editor_label',
),
},
)}
${this._putInSubmenu(
@@ -2843,6 +2938,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
)}
`,
)}
${this._renderTriggerEvents(cameraIndex)}
`,
)}
${this._putInSubmenu(
@@ -3083,6 +3179,29 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
`;
}
private _renderObjectSelector(
configPath: string,
params?: {
label?: string;
},
): TemplateResult | void {
if (!this._config) {
return;
}
return html`
<ha-selector
.hass=${this.hass}
.selector=${{ object: {} }}
.label=${params?.label ?? this._getLabel(configPath)}
.value=${getConfigValue(this._config, configPath)}
.required=${false}
@value-changed=${(ev) => this._valueChangedHandler(configPath, ev)}
>
</ha-selector>
`;
}
/**
* Render a boolean selector.
* @param configPath The configuration path to set/read.
+11
View File
@@ -0,0 +1,11 @@
import { isMatch } from 'lodash-es';
// Deep subset match between an HA bus event's payload `data` and a user-
// configured filter. Mirrors HA automation `event_data` semantics: every key in
// `filter` must exist in `data` and recursively match; extra keys in `data` are
// ignored. The `unknown` guard lives here (not at the caller) because HA event
// payloads arrive untyped from the WebSocket bus.
export const matchesEventData = (
filter: Record<string, unknown>,
data: unknown,
): boolean => typeof data === 'object' && data !== null && isMatch(data, filter);
+3 -3
View File
@@ -21,7 +21,7 @@ const isUsableOldState = (state: string | undefined): boolean =>
* produce a single `'new'` or `'end'`.
*
* HA `event.*` entities are different: each fire just updates `state` to a new
* ISO timestamp, with no continuous on/off. Those map to `signal` -- the
* ISO timestamp, with no continuous on/off. Those map to `momentary` -- the
* instantaneous-event discriminator. Transitions are skipped when the old state
* is undefined (entity not previously observed) or `unavailable` (entity
* reconnecting -- new state could be restored, not fresh), or when the new
@@ -29,11 +29,11 @@ const isUsableOldState = (state: string | undefined): boolean =>
*/
export const getTriggerEventType = (
difference: HassStateDifference,
): 'new' | 'end' | 'signal' | null => {
): 'new' | 'end' | 'momentary' | null => {
if (computeDomain(difference.entityID) === 'event') {
return isUsableOldState(difference.oldState?.state) &&
isUsableNewState(difference.newState.state)
? 'signal'
? 'momentary'
: null;
}
return isTriggeredState(difference.newState.state) ? 'new' : 'end';
+2
View File
@@ -243,6 +243,8 @@ export interface HassStateDifference {
newState: HassEntity;
}
export type SubscriptionUnsubscribe = () => Promise<void>;
// *************************************************************************
// Home Assistant API types.
// *************************************************************************
+10 -3
View File
@@ -6,6 +6,7 @@
"common": {
"advanced_camera_card": "Advanced Camera Card",
"advanced_camera_card_description": "An Advanced Camera Card",
"event": "Event",
"folder": "Folder",
"in_progress": "In progress...",
"no_media": "No media to display",
@@ -188,12 +189,18 @@
"editor_label": "Trigger options",
"entities": "Trigger from other entities",
"events": {
"add_new_event": "Add new event trigger",
"editor_label": "Home Assistant Events",
"event_data": "Event data filter",
"event_type": "Event type"
},
"doorbell": "Trigger by auto-detecting doorbell event entities",
"media_events": {
"clips": "Events with new clips",
"editor_label": "Trigger Events",
"editor_label": "Trigger Media Events",
"events": "All events",
"snapshots": "Events with new snapshots"
},
"doorbell": "Trigger by auto-detecting doorbell event entities",
"motion": "Trigger by auto-detecting the motion sensor",
"occupancy": "Trigger by auto-detecting the occupancy sensor",
"reviews": {
@@ -680,7 +687,7 @@
"editor_label": "Trigger behavior",
"filter_selected_camera": "Only trigger on selected camera",
"show_trigger_status": "Show pulsing border when triggered",
"signal_hold_seconds": "Seconds to hold a momentary (signal) trigger visible (e.g. a doorbell press) before the post-end untrigger delay",
"event_hold_seconds": "Seconds to hold a momentary trigger (e.g. a doorbell press, an HA bus event) visible before the post-end untrigger delay",
"untrigger_delay_seconds": "Seconds delay after trigger state change before untrigger",
"untrigger_force_seconds": "Seconds before forced untrigger"
},