From 773cee95a52a71e0fd61de008d2e26093f90caa9 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 24 May 2026 18:59:42 -0700 Subject: [PATCH] feat: Add automatic doorbell detection (#2507) --- docs/configuration/cameras/README.md | 3 +- docs/configuration/view.md | 22 +- docs/examples.md | 25 +-- .../browse-media/engine-browse-media.ts | 2 +- src/camera-manager/camera.ts | 87 +++++++- src/camera-manager/engine-factory.ts | 1 + src/camera-manager/entity-camera.ts | 35 ++- src/camera-manager/frigate/camera.ts | 103 ++++----- src/camera-manager/frigate/engine-frigate.ts | 4 +- src/camera-manager/generic/engine-generic.ts | 5 + src/camera-manager/reolink/camera.ts | 1 + src/camera-manager/tplink/camera.ts | 8 +- src/camera-manager/tplink/engine-tplink.ts | 4 +- src/card-controller/triggers-manager.ts | 36 +++- src/config/schema/cameras.ts | 2 + src/config/schema/view.ts | 4 + src/const.ts | 4 + src/editor.ts | 12 ++ src/localize/languages/en.json | 2 + tests/camera-manager/camera.test.ts | 199 ++++++++++++++++++ tests/camera-manager/frigate/camera.test.ts | 24 +++ .../card-controller/triggers-manager.test.ts | 64 ++++++ tests/config/types.test.ts | 2 + tests/test-utils.ts | 1 + 24 files changed, 527 insertions(+), 123 deletions(-) diff --git a/docs/configuration/cameras/README.md b/docs/configuration/cameras/README.md index d9820f8e..419596b6 100644 --- a/docs/configuration/cameras/README.md +++ b/docs/configuration/cameras/README.md @@ -383,7 +383,8 @@ cameras: | Option | Default | Description | | ----------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `entities` | | Whether to not to trigger the camera when the state of any Home Assistant entity becomes active (i.e. state becomes `on` or `open`). This works for Frigate or non-Frigate cameras. | +| `doorbell` | `false` | Whether to trigger the camera by automatically detecting an [HA `event.*` entity](https://www.home-assistant.io/integrations/event/#device-class) with `device_class: doorbell` on the same HA device as the camera entity. Requires `camera_entity` to be set. | +| `entities` | | Whether to not to trigger the camera when the state of any Home Assistant entity becomes active (i.e. state becomes `on` or `open`). | | `events` | `[]` | Whether to trigger the camera when `events` occur (whether or not media is available) or whenever updated `clips` or `snapshots` are detected. Detection support varies by camera [engine](engine.md). | | `motion` | `false` | Whether to not to trigger the camera by automatically detecting and using the motion `binary_sensor` for this camera. This autodetection only works for Frigate cameras, and only when the motion `binary_sensor` entity has been enabled in Home Assistant. | | `occupancy` | `false` | Whether to not to trigger the camera by automatically detecting and using the occupancy `binary_sensor` for this camera and its configured zones and labels. This autodetection only works for Frigate cameras, and only when the occupancy `binary_sensor` entity has been enabled in Home Assistant. If this camera has configured zones, only occupancy sensors for those zones are used -- if the overall _camera_ occupancy sensor is also required, it can be manually added to `entities`. If this camera has configured labels, only occupancy sensors for those labels are used. | diff --git a/docs/configuration/view.md b/docs/configuration/view.md index 5d8579f6..fd3ddb96 100644 --- a/docs/configuration/view.md +++ b/docs/configuration/view.md @@ -148,13 +148,14 @@ human interaction with the card; this behavior can be configured via the > action is taken immediately. If multiple cameras are triggered at startup, they > are all marked as triggered, but the action is only taken for the first one. -| Option | Default | Description | -| ------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `actions` | | The actions to take when a camera is triggered. See [Trigger action configuration](#trigger-action-configuration). | -| `filter_selected_camera` | `true` | If set to `true` will only trigger on the currently selected camera. | -| `show_trigger_status` | `false` | Whether or not the `live` view should show a visual indication that it is triggered (a pulsing border around the camera edge). | -| `untrigger_delay_seconds` | `0` | The number of seconds to continue to consider the camera triggered after the source ends, before taking the configured `untrigger` action. For instantaneous trigger sources (e.g. HA `event.*` entities, or a doorbell button that only briefly pulses on) this is effectively the entire visible "active" duration — the source itself provides no on-period of its own. | -| `untrigger_force_seconds` | `0` | The number of seconds after a camera first triggers before force untriggering that camera. Set to `0` to disable. | +| Option | Default | Description | +| ------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `actions` | | The actions to take when a camera is triggered. See [Trigger action configuration](#trigger-action-configuration). | +| `filter_selected_camera` | `true` | If set to `true` will only trigger on the currently selected camera. | +| `show_trigger_status` | `false` | Whether or not the `live` view should show a visual indication that it is triggered (a pulsing border around the camera edge). | +| `signal_hold_seconds` | `30` | The synthesized on-period for momentary trigger sources that have no native on/off state (e.g. HA `event.*` entities or anything that fires as a single signal). For a doorbell press paired with `trigger: call`, this is effectively the ring window during which the call can be answered. Added _on top of_ `untrigger_delay_seconds`. Ignored for stateful sources (`binary_sensor`, `switch`, etc.). | +| `untrigger_delay_seconds` | `0` | The number of seconds to continue to consider the camera triggered after the source ends, before taking the configured `untrigger` action. | +| `untrigger_force_seconds` | `0` | The number of seconds after a camera first triggers before force untriggering that camera. Set to `0` to disable. | > [!WARNING] If `untrigger_force_seconds` is used to untrigger a camera, the > state will need to 'reset' (e.g. an entity would need to change state to @@ -162,10 +163,9 @@ human interaction with the card; this behavior can be configured via the > [!TIP] When pairing `trigger: call` with `untrigger: call` (the > "ring-then-end-if-unanswered" pattern), the ring lasts until the source ends -> plus `untrigger_delay_seconds`. With an instantaneous trigger source (event -> entity, brief switch pulse) and the default of `0`, the call would start and -> end in the same tick. In these cases, set a positive value (e.g. `30`) for a -> meaningful ring duration. +> plus `untrigger_delay_seconds`. For momentary sources (HA `event.*` entities, +> a doorbell press), the source ends instantly so the ring window is +> `signal_hold_seconds` (default `30`s) plus `untrigger_delay_seconds`. ### Trigger action configuration diff --git a/docs/examples.md b/docs/examples.md index dfe9f79f..293853f8 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -363,9 +363,18 @@ elements: This example uses [`view.triggers.actions.trigger: call`](configuration/view.md?id=triggers) to turn a dashboard into a phone-like ringer when somebody presses the doorbell. The intended deployment is a wall-mounted tablet sitting on the dashboard. -The trigger entity is an [HA `event.*` entity](https://www.home-assistant.io/integrations/event/#device-class) with `device_class: doorbell` — the officially supported way modern integrations (ONVIF, UniFi Protect, Reolink, MQTT, etc.) expose a doorbell press. A press fires the entity instantaneously; the card treats it as a momentary signal and rings for `untrigger_delay_seconds`. A `switch.*` or `binary_sensor.*` entity that goes on/off with each press works will also work fine. +Setting `triggers.doorbell: true` opts the camera into auto-discovery of [HA +`event.*` +entities](https://www.home-assistant.io/integrations/event/#device-class) with +`device_class: doorbell` on the camera's device — the officially supported way +modern integrations (Ring, UniFi Protect, Nest, DoorBird, Reolink, etc.) expose +a doorbell press -- no need to list the entity explicitly under +`triggers.entities`. If your doorbell exposes a `binary_sensor.*` or `switch.*` +instead, list it under `triggers.entities` manually. -The Frigate camera's stock event triggers (`occupancy`, `motion`, `events`) are explicitly turned off so casual motion doesn't make the card ring — only an actual doorbell press does. +A doorbell press is instantaneous, so the card synthesises a ring window from [`view.triggers.signal_hold_seconds`](configuration/view.md?id=triggers) (default `30`s) — long enough for a typical phone-style answer window. `untrigger_delay_seconds` then lingers past that, same as for any stateful trigger. + +`triggers.motion`, `triggers.occupancy`, and `triggers.events` are off by default — only the explicit doorbell press triggers the call, so casual motion won't make the card ring. ```yaml type: custom:advanced-camera-card @@ -376,23 +385,15 @@ cameras: modes: - webrtc triggers: - occupancy: false - motion: false - events: [] - entities: - - event.front_door_doorbell + doorbell: true view: default: live triggers: show_trigger_status: true - # How long the chime keeps ringing after a press before the call is - # auto-ended (if still unanswered). A doorbell press is instantaneous, - # so this value is effectively the ring duration. - untrigger_delay_seconds: 30 actions: # Call when triggered. trigger: call - # When the trigger naturally ends (after untrigger_delay_seconds): end + # When the trigger naturally ends (after signal_hold_seconds): end # the call if it's still ringing. An answered call survives this and # must be ended manually. untrigger: call diff --git a/src/camera-manager/browse-media/engine-browse-media.ts b/src/camera-manager/browse-media/engine-browse-media.ts index c869ed6b..4aab5ad8 100644 --- a/src/camera-manager/browse-media/engine-browse-media.ts +++ b/src/camera-manager/browse-media/engine-browse-media.ts @@ -42,7 +42,7 @@ export class BrowseMediaCameraManagerEngine requestCache: CameraManagerRequestCache, eventCallback?: CameraEventCallback, ) { - super(stateWatcher, eventCallback); + super(stateWatcher, entityRegistryManager, eventCallback); this._entityRegistryManager = entityRegistryManager; this._browseMediaWalker = browseMediaManager; this._resolvedMediaCache = resolvedMediaCache; diff --git a/src/camera-manager/camera.ts b/src/camera-manager/camera.ts index ba30378f..a34294b8 100644 --- a/src/camera-manager/camera.ts +++ b/src/camera-manager/camera.ts @@ -1,9 +1,12 @@ +import { uniq } from 'lodash-es'; import { ActionsExecutor } from '../card-controller/actions/types'; import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher'; import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz'; import { CameraConfig } from '../config/schema/cameras'; import { EnabledProxyConfig, resolveProxyConfig } from '../config/schema/common/proxy'; +import { computeDomain } from '../ha/compute-domain'; import { getTriggerEventType } from '../ha/get-trigger-event-type'; +import { Entity, EntityRegistryManager } from '../ha/registry/entity/types'; import { HassStateDifference, HomeAssistant } from '../ha/types'; import { localize } from '../localize/localize'; import { CapabilitiesRaw, CapabilityKey, Endpoint } from '../types'; @@ -17,6 +20,7 @@ import { CameraEventCallback, CameraProxyConfig, } from './types'; +import { getCameraEntityFromConfig } from './utils/camera-entity-from-config'; import { getGo2RTCMetadataEndpoint, getGo2RTCStreamEndpoint, @@ -37,6 +41,7 @@ export interface CameraInitializationOptions { hass: HomeAssistant; stateWatcher: StateWatcherSubscriptionInterface; capabilityOptions?: CapabilityOptions; + entityRegistryManager?: EntityRegistryManager; } type DestroyCallback = () => void | Promise; @@ -47,6 +52,7 @@ export class Camera { protected _capabilities?: Capabilities; protected _eventCallback?: CameraEventCallback; protected _destroyCallbacks: DestroyCallback[] = []; + protected _entity: Entity | null = null; constructor( config: CameraConfig, @@ -62,17 +68,86 @@ export class Camera { this._capabilities = options?.capabilities; } + public getEntity(): Entity | null { + return this._entity; + } + async initialize(options: CameraInitializationOptions): Promise { + this._entity = await this._resolveEntity(options); await this._initialize(options); + this._capabilities = options.capabilityOptions?.capabilities ?? this._capabilities ?? (await this._buildCapabilities(options)); - this._subscribeBasedOnCapabilities(options.stateWatcher); - this._onDestroy(() => options.stateWatcher.unsubscribe(this._stateChangeHandler)); + + if (this._capabilities.has('trigger')) { + await this._getTriggerEntities(options); + this._config.triggers.entities = uniq(this._config.triggers.entities); + + options.stateWatcher.subscribe( + this._stateChangeHandler, + this._config.triggers.entities, + ); + this._onDestroy(() => options.stateWatcher.unsubscribe(this._stateChangeHandler)); + } + return this; } + private async _resolveEntity( + options: CameraInitializationOptions, + ): Promise { + const cameraEntityID = getCameraEntityFromConfig(this._config); + if (!cameraEntityID || !options.entityRegistryManager) { + return null; + } + return await options.entityRegistryManager.getEntity(options.hass, cameraEntityID); + } + + /** + * Get trigger entities (specified or auto-detected). Subclasses may override + * to add engine-specific discovery; call `super` to keep the base discoveries. + */ + protected async _getTriggerEntities( + options: CameraInitializationOptions, + ): Promise { + await this._getDoorbellEntities(options); + } + + private async _getDoorbellEntities( + options: CameraInitializationOptions, + ): Promise { + if ( + !this._config.triggers.doorbell || + !this._entity?.device_id || + !options.entityRegistryManager + ) { + return; + } + const deviceID = this._entity.device_id; + + // `device_class` lives on state attributes (not the registry entry), so + // narrow by `device_id` + domain first and filter by device_class against + // `hass.states` second. + const candidates = await options.entityRegistryManager.getMatchingEntities( + options.hass, + (ent) => + ent.device_id === deviceID && + !ent.disabled_by && + computeDomain(ent.entity_id) === 'event', + ); + + const doorbells = candidates + .filter( + (ent) => + options.hass.states[ent.entity_id]?.attributes?.device_class === 'doorbell', + ) + .map((ent) => ent.entity_id); + + this._config.triggers.entities.push(...doorbells); + } + /** * Subclass initialization hook. Override for async initialization work. */ @@ -276,12 +351,4 @@ export class Camera { protected _onDestroy(callback: DestroyCallback): void { this._destroyCallbacks.push(callback); } - - private _subscribeBasedOnCapabilities( - stateWatcher: StateWatcherSubscriptionInterface, - ): void { - if (this._capabilities?.has('trigger')) { - stateWatcher.subscribe(this._stateChangeHandler, this._config.triggers.entities); - } - } } diff --git a/src/camera-manager/engine-factory.ts b/src/camera-manager/engine-factory.ts index a21406d0..218a9987 100644 --- a/src/camera-manager/engine-factory.ts +++ b/src/camera-manager/engine-factory.ts @@ -39,6 +39,7 @@ export class CameraManagerEngineFactory { const { GenericCameraManagerEngine } = await import('./generic/engine-generic'); cameraManagerEngine = new GenericCameraManagerEngine( options.stateWatcher, + this._entityRegistryManager, options.eventCallback, ); break; diff --git a/src/camera-manager/entity-camera.ts b/src/camera-manager/entity-camera.ts index 8c4ed521..6e904f23 100644 --- a/src/camera-manager/entity-camera.ts +++ b/src/camera-manager/entity-camera.ts @@ -1,30 +1,19 @@ -import { Entity, EntityRegistryManager } from '../ha/registry/entity/types'; import { Camera, CameraInitializationOptions } from './camera'; import { CameraNoEntityError } from './error'; -import { getCameraEntityFromConfig } from './utils/camera-entity-from-config'; - -export interface EntityCameraInitializationOptions extends CameraInitializationOptions { - entityRegistryManager: EntityRegistryManager; -} +/** + * Camera variant that requires a `camera_entity` to be present in the HA + * entity registry. Base `Camera` resolves `_entity` opportunistically; this + * subclass turns absence into an error for engines that cannot function + * without it (motionEye, Reolink, TPLink). + */ export class EntityCamera extends Camera { - protected _entity: Entity | null = null; - - public async initialize(options: EntityCameraInitializationOptions): Promise { - const config = this.getConfig(); - const cameraEntityID = getCameraEntityFromConfig(config); - const entity = cameraEntityID - ? await options.entityRegistryManager.getEntity(options.hass, cameraEntityID) - : null; - - if (!entity || !cameraEntityID) { - throw new CameraNoEntityError(config); + protected override async _initialize( + options: CameraInitializationOptions, + ): Promise { + if (!this._entity) { + throw new CameraNoEntityError(this.getConfig()); } - this._entity = entity; - return await super.initialize(options); - } - - public getEntity(): Entity | null { - return this._entity; + await super._initialize(options); } } diff --git a/src/camera-manager/frigate/camera.ts b/src/camera-manager/frigate/camera.ts index 80b641db..2f0745bc 100644 --- a/src/camera-manager/frigate/camera.ts +++ b/src/camera-manager/frigate/camera.ts @@ -1,5 +1,4 @@ import { format } from 'date-fns'; -import { uniq } from 'lodash-es'; import { ActionsExecutor } from '../../card-controller/actions/types'; import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz'; import { CameraConfig } from '../../config/schema/cameras'; @@ -45,7 +44,6 @@ export const isBirdseye = (cameraConfig: CameraConfig): boolean => { export class FrigateCamera extends Camera { public async initialize(options: FrigateCameraInitializationOptions): Promise { - await this._initializeConfig(options.hass, options.entityRegistryManager); await super.initialize(options); if (this._capabilities?.has('trigger')) { @@ -106,35 +104,29 @@ export class FrigateCamera extends Camera { return true; } - private async _initializeConfig( - hass: HomeAssistant, - entityRegistryManager: EntityRegistryManager, + protected override async _initialize( + options: FrigateCameraInitializationOptions, ): Promise { const config = this.getConfig(); const hasCameraName = !!config.frigate?.camera_name; - const hasAutoTriggers = config.triggers.motion || config.triggers.occupancy; - - let entity: Entity | null = null; const cameraEntity = getCameraEntityFromConfig(config); - // Entity information is required if the Frigate camera name is missing, or - // if the entity requires automatic resolution of motion/occupancy sensors. - if (cameraEntity && (!hasCameraName || hasAutoTriggers)) { - entity = await entityRegistryManager.getEntity(hass, cameraEntity); - if (!entity) { - throw new CameraNoEntityError(config); - } + // Frigate needs the entity to derive `camera_name` when one isn't set. The + // entity is resolved by base Camera; throw here only when its absence + // breaks Frigate setup. + if (cameraEntity && !hasCameraName && !this._entity) { + throw new CameraNoEntityError(config); } - if (entity && !hasCameraName) { - const resolvedName = this._getFrigateCameraNameFromEntity(entity); + if (this._entity && !hasCameraName) { + const resolvedName = this._getFrigateCameraNameFromEntity(this._entity); if (resolvedName) { this._config.frigate.camera_name = resolvedName; } } if (!this._config.frigate.client_id) { - const stateEntity = cameraEntity ? hass.states[cameraEntity] : undefined; + const stateEntity = cameraEntity ? options.hass.states[cameraEntity] : undefined; const clientID = stateEntity?.attributes?.client_id; if (typeof clientID === 'string' && clientID) { this._config.frigate.client_id = clientID; @@ -142,40 +134,57 @@ export class FrigateCamera extends Camera { this._config.frigate.client_id = 'frigate'; } } + } - if (hasAutoTriggers) { - // Try to find the correct entities for the motion & occupancy sensors. - // We know they are binary_sensors, and that they'll have the same - // config entry ID as the camera. Searching via unique_id ensures this - // search still works if the user renames the entity_id. - const binarySensorEntities = await entityRegistryManager.getMatchingEntities( - hass, - (ent) => - ent.config_entry_id === entity?.config_entry_id && - !ent.disabled_by && - ent.entity_id.startsWith('binary_sensor.'), - ); + protected override async _getTriggerEntities( + options: FrigateCameraInitializationOptions, + ): Promise { + await this._getFrigateMotionAndOccupancyEntities(options); + await super._getTriggerEntities(options); + } - if (config.triggers.motion) { - const motionEntity = this._getMotionSensor(config, [ - ...binarySensorEntities.values(), - ]); - if (motionEntity) { - config.triggers.entities.push(motionEntity); - } + private async _getFrigateMotionAndOccupancyEntities( + options: FrigateCameraInitializationOptions, + ): Promise { + const config = this.getConfig(); + if (!config.triggers.motion && !config.triggers.occupancy) { + return; + } + + // Motion/occupancy auto-discovery requires the camera entity to derive + // the matching binary_sensor unique_ids. + if (getCameraEntityFromConfig(config) && !this._entity) { + throw new CameraNoEntityError(config); + } + + // Find the correct entities for the motion & occupancy sensors. They + // are binary_sensors with the same config entry ID as the camera; + // searching via unique_id ensures this still works if the user renames + // the entity_id. + const binarySensorEntities = await options.entityRegistryManager.getMatchingEntities( + options.hass, + (ent) => + ent.config_entry_id === this._entity?.config_entry_id && + !ent.disabled_by && + ent.entity_id.startsWith('binary_sensor.'), + ); + + if (config.triggers.motion) { + const motionEntity = this._getMotionSensor(config, [ + ...binarySensorEntities.values(), + ]); + if (motionEntity) { + config.triggers.entities.push(motionEntity); } + } - if (config.triggers.occupancy) { - const occupancyEntities = this._getOccupancySensor(config, [ - ...binarySensorEntities.values(), - ]); - if (occupancyEntities) { - config.triggers.entities.push(...occupancyEntities); - } + if (config.triggers.occupancy) { + const occupancyEntities = this._getOccupancySensor(config, [ + ...binarySensorEntities.values(), + ]); + if (occupancyEntities) { + config.triggers.entities.push(...occupancyEntities); } - - // De-duplicate triggering entities. - config.triggers.entities = uniq(config.triggers.entities); } } diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index acedbc62..baac1a82 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -120,7 +120,7 @@ export class FrigateCameraManagerEngine extends GenericCameraManagerEngine implements CameraManagerEngine { - private _entityRegistryManager: EntityRegistryManager; + protected override _entityRegistryManager: EntityRegistryManager; private _frigateEventWatcher: FrigateEventWatcher; private _frigateReviewWatcher: FrigateReviewWatcher; private _recordingSegmentsCache: RecordingSegmentsCache; @@ -140,7 +140,7 @@ export class FrigateCameraManagerEngine requestCache: CameraManagerRequestCache, eventCallback?: CameraEventCallback, ) { - super(stateWatcher, eventCallback); + super(stateWatcher, entityRegistryManager, eventCallback); this._entityRegistryManager = entityRegistryManager; this._frigateEventWatcher = new FrigateEventWatcher(); this._frigateReviewWatcher = new FrigateReviewWatcher(); diff --git a/src/camera-manager/generic/engine-generic.ts b/src/camera-manager/generic/engine-generic.ts index c8e38a39..48f18c7b 100644 --- a/src/camera-manager/generic/engine-generic.ts +++ b/src/camera-manager/generic/engine-generic.ts @@ -3,6 +3,7 @@ import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher'; import { CameraConfig } from '../../config/schema/cameras'; import { getEntityTitle } from '../../ha/get-entity-title'; +import { EntityRegistryManager } from '../../ha/registry/entity/types'; import { HomeAssistant } from '../../ha/types'; import { Endpoint } from '../../types'; import { ViewMedia } from '../../view/item'; @@ -40,12 +41,15 @@ import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; export class GenericCameraManagerEngine implements CameraManagerEngine { protected _eventCallback?: CameraEventCallback; protected _stateWatcher: StateWatcherSubscriptionInterface; + protected _entityRegistryManager?: EntityRegistryManager; constructor( stateWatcher: StateWatcherSubscriptionInterface, + entityRegistryManager?: EntityRegistryManager, eventCallback?: CameraEventCallback, ) { this._stateWatcher = stateWatcher; + this._entityRegistryManager = entityRegistryManager; this._eventCallback = eventCallback; } @@ -62,6 +66,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { }).initialize({ hass, stateWatcher: this._stateWatcher, + entityRegistryManager: this._entityRegistryManager, capabilityOptions: { raw: { ptz: getPTZCapabilitiesFromCameraConfig(cameraConfig) ?? undefined, diff --git a/src/camera-manager/reolink/camera.ts b/src/camera-manager/reolink/camera.ts index 5fb9a540..ba7f0e93 100644 --- a/src/camera-manager/reolink/camera.ts +++ b/src/camera-manager/reolink/camera.ts @@ -174,6 +174,7 @@ export class ReolinkCamera extends EntityCamera { protected async _initialize( options: ReolinkCameraInitializationOptions, ): Promise { + await super._initialize(options); await this._initializeChannel(options.hass, options.deviceRegistryManager); this._ptzEntities = await this._getPTZEntities( options.hass, diff --git a/src/camera-manager/tplink/camera.ts b/src/camera-manager/tplink/camera.ts index 468b5ee3..24e258e9 100644 --- a/src/camera-manager/tplink/camera.ts +++ b/src/camera-manager/tplink/camera.ts @@ -3,10 +3,13 @@ import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/pt import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types'; import { HomeAssistant } from '../../ha/types'; import { CapabilitiesRaw, PTZCapabilities, PTZMovementType } from '../../types'; -import { EntityCamera, EntityCameraInitializationOptions } from '../entity-camera'; +import { CameraInitializationOptions } from '../camera'; +import { EntityCamera } from '../entity-camera'; import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; -type TPLinkCameraInitializationOptions = EntityCameraInitializationOptions; +interface TPLinkCameraInitializationOptions extends CameraInitializationOptions { + entityRegistryManager: EntityRegistryManager; +} interface PTZEntities { left?: string; @@ -22,6 +25,7 @@ export class TPLinkCamera extends EntityCamera { protected async _initialize( options: TPLinkCameraInitializationOptions, ): Promise { + await super._initialize(options); this._ptzEntities = await this._getPTZEntities( options.hass, options.entityRegistryManager, diff --git a/src/camera-manager/tplink/engine-tplink.ts b/src/camera-manager/tplink/engine-tplink.ts index 3b7e59ed..62340c4f 100644 --- a/src/camera-manager/tplink/engine-tplink.ts +++ b/src/camera-manager/tplink/engine-tplink.ts @@ -8,14 +8,12 @@ import { CameraEventCallback, CameraManagerCameraMetadata, Engine } from '../typ import { TPLinkCamera } from './camera'; export class TPLinkCameraManagerEngine extends GenericCameraManagerEngine { - private _entityRegistryManager: EntityRegistryManager; - constructor( entityRegistryManager: EntityRegistryManager, stateWatcher: StateWatcherSubscriptionInterface, eventCallback?: CameraEventCallback, ) { - super(stateWatcher, eventCallback); + super(stateWatcher, entityRegistryManager, eventCallback); this._entityRegistryManager = entityRegistryManager; } diff --git a/src/card-controller/triggers-manager.ts b/src/card-controller/triggers-manager.ts index 846ab047..198fb933 100644 --- a/src/card-controller/triggers-manager.ts +++ b/src/card-controller/triggers-manager.ts @@ -111,12 +111,14 @@ export class TriggersManager { } if (ev.type === 'signal') { - // A signal is momentary -- handled as a matched new+end so the existing - // untrigger_delay_seconds machinery gives it visible duration, and so - // concurrent continuous sources still gate untriggering correctly. const handled = await this.handleCameraEvent({ ...ev, type: 'new' }, options); if (handled) { - await this.handleCameraEvent({ ...ev, type: 'end' }); + // 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 + // post-source-end linger (`untrigger_delay_seconds`). + await this._handleEndEvent(ev, { signal: true }); } return handled; } @@ -160,13 +162,16 @@ export class TriggersManager { return true; } - private async _handleEndEvent(ev: CameraEvent): Promise { + private async _handleEndEvent( + ev: CameraEvent, + options?: { signal?: boolean }, + ): Promise { this._deleteIgnoredEventID(ev.cameraID, ev.id); const state = this._states.get(ev.cameraID); state?.sources.delete(ev.id); if (!state?.sources.size) { - await this._startUntrigger(ev.cameraID); + await this._startUntrigger(ev.cameraID, options); } return true; } @@ -306,7 +311,10 @@ export class TriggersManager { this._api.getCardElementManager().update(); } - private async _startUntrigger(cameraID: string): Promise { + private async _startUntrigger( + cameraID: string, + options?: { signal?: boolean }, + ): Promise { this._deleteUntriggerDelayTimer(cameraID); this._deleteForceUntriggerTimer(cameraID); @@ -315,12 +323,18 @@ export class TriggersManager { return; } - const config = this._api.getConfigManager().getConfig(); - const untriggerDelaySeconds = config?.view?.triggers.untrigger_delay_seconds ?? 0; + 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; + const effectiveDelaySeconds = + untriggerDelaySeconds + (options?.signal ? signalHoldSeconds : 0); - if (untriggerDelaySeconds > 0) { + if (effectiveDelaySeconds > 0) { state.untriggerDelayTimer = new Timer(); - state.untriggerDelayTimer.start(untriggerDelaySeconds, async () => { + state.untriggerDelayTimer.start(effectiveDelaySeconds, async () => { await this._untriggerAction(cameraID); }); } else { diff --git a/src/config/schema/cameras.ts b/src/config/schema/cameras.ts index 5916c1bb..752e6b4d 100644 --- a/src/config/schema/cameras.ts +++ b/src/config/schema/cameras.ts @@ -148,6 +148,7 @@ export const cameraConfigDefault = { triggers: { motion: false, occupancy: false, + doorbell: false, events: [], entities: [], reviews: { @@ -248,6 +249,7 @@ export const cameraConfigSchema = z .object({ motion: z.boolean().default(cameraConfigDefault.triggers.motion), 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) diff --git a/src/config/schema/view.ts b/src/config/schema/view.ts index 87e26c8a..aeee1831 100644 --- a/src/config/schema/view.ts +++ b/src/config/schema/view.ts @@ -72,6 +72,7 @@ export const viewConfigDefault = { }, untrigger_delay_seconds: 0, untrigger_force_seconds: 0, + signal_hold_seconds: 30, }, keyboard_shortcuts: keyboardShortcutsDefault, issues: { @@ -109,6 +110,9 @@ 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), }); export type TriggersOptions = z.infer; diff --git a/src/const.ts b/src/const.ts index 739fc74f..baa70c51 100644 --- a/src/const.ts +++ b/src/const.ts @@ -111,6 +111,8 @@ export const CONF_CAMERAS_ARRAY_TRIGGERS_MOTION = `${CONF_CAMERAS}.#.triggers.motion` as const; export const CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY = `${CONF_CAMERAS}.#.triggers.occupancy` as const; +export const CONF_CAMERAS_ARRAY_TRIGGERS_DOORBELL = + `${CONF_CAMERAS}.#.triggers.doorbell` as const; export const CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES = `${CONF_CAMERAS}.#.triggers.entities` as const; export const CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS = @@ -195,6 +197,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_ACTIONS = `${CONF_VIEW_TRIGGERS}.actions` as const; export const CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE = `${CONF_VIEW_TRIGGERS_ACTIONS}.interaction_mode` as const; diff --git a/src/editor.ts b/src/editor.ts index 8d31b0b2..ac93e50c 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -90,6 +90,7 @@ import { CONF_CAMERAS_ARRAY_REOLINK_MEDIA_RESOLUTION, CONF_CAMERAS_ARRAY_REOLINK_URL, CONF_CAMERAS_ARRAY_TITLE, + CONF_CAMERAS_ARRAY_TRIGGERS_DOORBELL, CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES, CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS, CONF_CAMERAS_ARRAY_TRIGGERS_MOTION, @@ -278,6 +279,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_UNTRIGGER_DELAY_SECONDS, CONF_VIEW_TRIGGERS_UNTRIGGER_FORCE_SECONDS, DOCS_URL, @@ -1596,6 +1598,9 @@ 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._putInSubmenu( MENU_VIEW_TRIGGERS_ACTIONS, true, @@ -2786,6 +2791,13 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGERS_MOTION, cameraIndex), this._defaults.cameras.triggers.motion, )} + ${this._renderSwitch( + getArrayConfigPath( + CONF_CAMERAS_ARRAY_TRIGGERS_DOORBELL, + cameraIndex, + ), + this._defaults.cameras.triggers.doorbell, + )} ${this._renderOptionSelector( getArrayConfigPath( CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES, diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 2fb326f0..df2d20ab 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -193,6 +193,7 @@ "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": { @@ -678,6 +679,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", "untrigger_delay_seconds": "Seconds delay after trigger state change before untrigger", "untrigger_force_seconds": "Seconds before forced untrigger" }, diff --git a/tests/camera-manager/camera.test.ts b/tests/camera-manager/camera.test.ts index 07c57d9a..b42aa4c3 100644 --- a/tests/camera-manager/camera.test.ts +++ b/tests/camera-manager/camera.test.ts @@ -5,12 +5,14 @@ import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/eng import { CameraProxyConfig } from '../../src/camera-manager/types.js'; import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js'; import { liveProviderSupports2WayAudio } from '../../src/utils/live-provider.js'; +import { EntityRegistryManagerMock } from '../ha/registry/entity/mock.js'; import { callStateWatcherCallback, createCameraConfig, createCapabilities, createHASS, createInitializedCamera, + createRegistryEntity, createStateEntity, } from '../test-utils.js'; @@ -421,6 +423,203 @@ describe('Camera', () => { expect(liveProviderSupports2WayAudio).toHaveBeenCalled(); expect(camera.getCapabilities()?.has('2-way-audio')).toBe(true); }); + + describe('entity resolution', () => { + it('should resolve entity when camera_entity and registry manager are provided', async () => { + const cameraEntity = createRegistryEntity({ + entity_id: 'camera.front_door', + device_id: 'device_1', + }); + const camera = new Camera( + createCameraConfig({ camera_entity: 'camera.front_door' }), + new GenericCameraManagerEngine(mock()), + ); + + await camera.initialize({ + hass: createHASS(), + stateWatcher: mock(), + entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]), + }); + + expect(camera.getEntity()).toEqual(cameraEntity); + }); + + it('should leave entity null when camera_entity is unset', async () => { + const camera = new Camera( + createCameraConfig(), + new GenericCameraManagerEngine(mock()), + ); + + await camera.initialize({ + hass: createHASS(), + stateWatcher: mock(), + entityRegistryManager: new EntityRegistryManagerMock(), + }); + + expect(camera.getEntity()).toBeNull(); + }); + + it('should leave entity null when entityRegistryManager is not provided', async () => { + const camera = new Camera( + createCameraConfig({ camera_entity: 'camera.front_door' }), + new GenericCameraManagerEngine(mock()), + ); + + await camera.initialize({ + hass: createHASS(), + stateWatcher: mock(), + }); + + expect(camera.getEntity()).toBeNull(); + }); + }); + + describe('trigger discovery', () => { + describe('doorbell', () => { + const initializeDoorbellCamera = async (options?: { + doorbell?: boolean; + deviceID?: string | null; + triggerCapability?: boolean; + userEntities?: string[]; + omitRegistryManager?: boolean; + registryEntities?: ReturnType[]; + stateEntities?: Parameters[0]; + }): Promise<{ + camera: Camera; + stateWatcher: StateWatcherSubscriptionInterface; + }> => { + const cameraEntity = createRegistryEntity({ + entity_id: 'camera.front_door', + device_id: options?.deviceID === undefined ? 'device_1' : options.deviceID, + }); + const doorbellEntity = createRegistryEntity({ + entity_id: 'event.front_door_doorbell', + device_id: 'device_1', + }); + const camera = new Camera( + createCameraConfig({ + camera_entity: 'camera.front_door', + triggers: { + doorbell: options?.doorbell ?? true, + ...(options?.userEntities && { entities: options.userEntities }), + }, + }), + new GenericCameraManagerEngine(mock()), + ); + const stateWatcher = mock(); + const hass = createHASS( + options?.stateEntities ?? { + 'event.front_door_doorbell': createStateEntity({ + entity_id: 'event.front_door_doorbell', + attributes: { device_class: 'doorbell' }, + }), + }, + ); + await camera.initialize({ + hass, + stateWatcher, + ...(!options?.omitRegistryManager && { + entityRegistryManager: new EntityRegistryManagerMock( + options?.registryEntities ?? [cameraEntity, doorbellEntity], + ), + }), + capabilityOptions: { + capabilities: createCapabilities({ + trigger: options?.triggerCapability ?? true, + }), + }, + }); + return { camera, stateWatcher }; + }; + + it('should auto-include doorbell event entity from camera device', async () => { + const { camera } = await initializeDoorbellCamera(); + expect(camera.getConfig().triggers.entities).toEqual([ + 'event.front_door_doorbell', + ]); + }); + + it('should skip discovery when triggers.doorbell is false', async () => { + const { camera } = await initializeDoorbellCamera({ doorbell: false }); + expect(camera.getConfig().triggers.entities).toEqual([]); + }); + + it('should skip discovery when camera entity has no device_id', async () => { + const { camera } = await initializeDoorbellCamera({ deviceID: null }); + expect(camera.getConfig().triggers.entities).toEqual([]); + }); + + it('should skip discovery when trigger capability is disabled', async () => { + const { camera } = await initializeDoorbellCamera({ + triggerCapability: false, + }); + expect(camera.getConfig().triggers.entities).toEqual([]); + }); + + it('should skip discovery when entityRegistryManager is not provided', async () => { + const { camera } = await initializeDoorbellCamera({ + omitRegistryManager: true, + }); + expect(camera.getConfig().triggers.entities).toEqual([]); + }); + + it('should de-duplicate against user-supplied entities', async () => { + const { camera } = await initializeDoorbellCamera({ + userEntities: ['event.front_door_doorbell', 'binary_sensor.driveway'], + }); + expect(camera.getConfig().triggers.entities).toEqual([ + 'event.front_door_doorbell', + 'binary_sensor.driveway', + ]); + }); + + it('should skip disabled event entities', async () => { + const { camera } = await initializeDoorbellCamera({ + registryEntities: [ + createRegistryEntity({ + entity_id: 'camera.front_door', + device_id: 'device_1', + }), + createRegistryEntity({ + entity_id: 'event.front_door_doorbell', + device_id: 'device_1', + disabled_by: 'user', + }), + ], + }); + expect(camera.getConfig().triggers.entities).toEqual([]); + }); + + it('should ignore non-doorbell event entities on the device', async () => { + const { camera } = await initializeDoorbellCamera({ + registryEntities: [ + createRegistryEntity({ + entity_id: 'camera.front_door', + device_id: 'device_1', + }), + createRegistryEntity({ + entity_id: 'event.front_door_button', + device_id: 'device_1', + }), + ], + stateEntities: { + 'event.front_door_button': createStateEntity({ + entity_id: 'event.front_door_button', + attributes: { device_class: 'button' }, + }), + }, + }); + expect(camera.getConfig().triggers.entities).toEqual([]); + }); + + it('should subscribe to discovered doorbell entities for state changes', async () => { + const { stateWatcher } = await initializeDoorbellCamera(); + expect(stateWatcher.subscribe).toBeCalledWith(expect.any(Function), [ + 'event.front_door_doorbell', + ]); + }); + }); + }); }); describe('should handle trigger state changes', () => { diff --git a/tests/camera-manager/frigate/camera.test.ts b/tests/camera-manager/frigate/camera.test.ts index 810c5d6b..4d9bd602 100644 --- a/tests/camera-manager/frigate/camera.test.ts +++ b/tests/camera-manager/frigate/camera.test.ts @@ -2012,6 +2012,30 @@ describe('FrigateCamera', () => { expect(camera.getConfig().triggers.entities).toEqual([]); }); + + it('should throw when camera_entity is configured but registry has no match', async () => { + const camera = new FrigateCamera( + createCameraConfig({ + camera_entity: 'camera.front_door', + frigate: { + camera_name: 'front_door', + }, + triggers: { + motion: true, + }, + }), + mock(), + ); + await expect( + camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock(), + stateWatcher: mock(), + frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), + }), + ).rejects.toThrowError(/Could not find camera entity/); + }); }); describe('should detect occupancy sensor', () => { diff --git a/tests/card-controller/triggers-manager.test.ts b/tests/card-controller/triggers-manager.test.ts index fedc459d..322a3d34 100644 --- a/tests/card-controller/triggers-manager.test.ts +++ b/tests/card-controller/triggers-manager.test.ts @@ -25,6 +25,7 @@ vi.mock('lodash-es', async () => ({ const baseTriggersConfig: TriggersOptions = { untrigger_delay_seconds: 10, untrigger_force_seconds: 0, + signal_hold_seconds: 0, filter_selected_camera: false, show_trigger_status: false, actions: { @@ -884,6 +885,69 @@ describe('TriggersManager', () => { expect(manager.isTriggered()).toBeTruthy(); expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled(); }); + + it('should add signal_hold_seconds on top of untrigger_delay_seconds for signals', async () => { + const api = createTriggerAPI({ + config: { + untrigger_delay_seconds: 5, + signal_hold_seconds: 30, + actions: { trigger: 'none', untrigger: 'default' }, + }, + }); + const manager = new TriggersManager(api); + + await manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'event.doorbell', + type: 'signal', + }); + + // At 34s the additive 35s window is still active. + vi.setSystemTime(add(start, { seconds: 34 })); + vi.advanceTimersByTime(34_000); + await flushPromises(); + expect(manager.isTriggered()).toBeTruthy(); + + // At 35s the window expires and untrigger fires. + vi.setSystemTime(add(start, { seconds: 35 })); + vi.advanceTimersByTime(1_000); + await flushPromises(); + expect(manager.isTriggered()).toBeFalsy(); + expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled(); + }); + + it('should not apply signal_hold_seconds to non-signal events', async () => { + const api = createTriggerAPI({ + config: { + untrigger_delay_seconds: 5, + signal_hold_seconds: 30, + actions: { trigger: 'none', untrigger: 'default' }, + }, + }); + const manager = new TriggersManager(api); + + await manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'binary_sensor.motion', + type: 'new', + }); + await manager.handleCameraEvent({ + cameraID: 'camera_1', + id: 'binary_sensor.motion', + type: 'end', + }); + + // Only untrigger_delay_seconds (5s) applies to a stateful end event. + vi.setSystemTime(add(start, { seconds: 4 })); + vi.advanceTimersByTime(4_000); + await flushPromises(); + expect(manager.isTriggered()).toBeTruthy(); + + vi.setSystemTime(add(start, { seconds: 5 })); + vi.advanceTimersByTime(1_000); + await flushPromises(); + expect(manager.isTriggered()).toBeFalsy(); + }); }); describe('condition state management', () => { diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index ec382606..c215cdb6 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -54,6 +54,7 @@ describe('config defaults', () => { media_resolution: 'low', }, triggers: { + doorbell: false, entities: [], events: [], motion: false, @@ -549,6 +550,7 @@ describe('config defaults', () => { }, filter_selected_camera: true, show_trigger_status: false, + signal_hold_seconds: 30, untrigger_delay_seconds: 0, untrigger_force_seconds: 0, }, diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 4c5ed859..ace17f73 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -282,6 +282,7 @@ export const createStore = ( cameraProps.engine ?? new GenericCameraManagerEngine( mock(), + mock(), eventCallback, ), { eventCallback, capabilities },