feat: Trigger cameras on HA bus events (#2512)
This commit is contained in:
committed by
dermotduffy
parent
406176eebc
commit
0a36358394
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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,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: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
@@ -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';
|
||||
|
||||
@@ -243,6 +243,8 @@ export interface HassStateDifference {
|
||||
newState: HassEntity;
|
||||
}
|
||||
|
||||
export type SubscriptionUnsubscribe = () => Promise<void>;
|
||||
|
||||
// *************************************************************************
|
||||
// Home Assistant API types.
|
||||
// *************************************************************************
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user