From 0a36358394f45b89bc614de35c816c3fb321fcf6 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 25 May 2026 21:03:05 -0700 Subject: [PATCH] feat: Trigger cameras on HA bus events (#2512) --- docs/configuration/cameras/README.md | 47 ++- docs/configuration/view.md | 5 +- docs/examples.md | 33 +- .../browse-media/engine-browse-media.ts | 4 +- src/camera-manager/camera.ts | 33 +- src/camera-manager/engine-factory.ts | 7 + src/camera-manager/frigate/camera.ts | 14 +- src/camera-manager/frigate/engine-frigate.ts | 5 +- src/camera-manager/frigate/watcher.ts | 4 +- src/camera-manager/generic/engine-generic.ts | 5 + src/camera-manager/manager.ts | 1 + .../motioneye/engine-motioneye.ts | 1 + src/camera-manager/reolink/engine-reolink.ts | 4 + src/camera-manager/tplink/engine-tplink.ts | 5 +- src/camera-manager/types.ts | 2 +- src/card-controller/hass/event-watcher.ts | 74 +++++ src/card-controller/hass/hass-manager.ts | 6 + src/card-controller/triggers-manager.ts | 28 +- src/config/management.ts | 32 ++ src/config/schema/cameras.ts | 21 +- src/config/schema/view.ts | 6 +- src/const.ts | 6 +- src/editor.ts | 145 ++++++++- src/ha/event-data-match.ts | 11 + src/ha/get-trigger-event-type.ts | 6 +- src/ha/types.ts | 2 + src/localize/languages/en.json | 13 +- .../browse-media/engine-browse-media.test.ts | 8 +- tests/camera-manager/camera.test.ts | 281 +++++++++++++++--- tests/camera-manager/engine-factory.test.ts | 8 +- tests/camera-manager/frigate/camera.test.ts | 179 +++++++---- .../frigate/engine-frigate.test.ts | 3 + .../generic/engine-generic.test.ts | 8 +- tests/camera-manager/manager.test.ts | 8 +- tests/camera-manager/motioneye/camera.test.ts | 15 +- .../motioneye/engine-motioneye.test.ts | 18 +- tests/camera-manager/reolink/camera.test.ts | 108 ++++--- .../reolink/engine-reolink.test.ts | 10 +- tests/camera-manager/store.test.ts | 3 + tests/camera-manager/tplink/camera.test.ts | 51 ++-- .../tplink/engine-tplink.test.ts | 6 +- .../hass/event-watcher.test.ts | 133 +++++++++ .../card-controller/hass/hass-manager.test.ts | 6 + .../card-controller/triggers-manager.test.ts | 32 +- tests/config/management.test.ts | 148 +++++++++ tests/config/types.test.ts | 3 +- tests/ha/event-data-match.test.ts | 75 +++++ tests/ha/get-trigger-event-type.test.ts | 8 +- tests/test-utils.ts | 14 + 49 files changed, 1376 insertions(+), 269 deletions(-) create mode 100644 src/card-controller/hass/event-watcher.ts create mode 100644 src/ha/event-data-match.ts create mode 100644 tests/card-controller/hass/event-watcher.test.ts create mode 100644 tests/ha/event-data-match.test.ts diff --git a/docs/configuration/cameras/README.md b/docs/configuration/cameras/README.md index 419596b6..a3e1707e 100644 --- a/docs/configuration/cameras/README.md +++ b/docs/configuration/cameras/README.md @@ -381,14 +381,40 @@ cameras: # [...] ``` -| Option | Default | Description | -| ----------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `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. | -| `reviews` | | Configuration for triggering on review items. Currently only supported by Frigate. See [`reviews`](#reviews). | +| Option | Default | Description | +| -------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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` | `[]` | A list of Home Assistant bus events to trigger on, with optional payload filtering. Each entry subscribes to one HA event type and fires the camera trigger every time that event is dispatched (and the optional `event_data` filter matches). See [`events`](#events). | +| `media_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. | +| `reviews` | | Configuration for triggering on review items. Currently only supported by Frigate. See [`reviews`](#reviews). | + +### `events` + +The `events` list subscribes the card to Home Assistant bus events. Each entry maps to a single `event_type` (e.g. `zha_event`, `deconz_event`, or any custom event your automations fire). Because events are momentary (HA fires them once, with no on/off), the card treats each fire as an instantaneous trigger; the visible "active" window is then controlled by [`view.triggers.event_hold_seconds`](../view.md?id=triggers). + +```yaml +cameras: + - camera_entity: camera.front_door + triggers: + events: + # Trigger only on a specific ZHA device firing its 'press' command. + - event_type: zha_event + event_data: + device_ieee: '00:11:22:33:44:55:66:77' + command: press + # No `event_data` filter -- triggers every time this event is fired. + - event_type: home_doorbell_pressed +``` + +| Option | Default | Description | +| ------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `event_type` | | The Home Assistant event type to subscribe to (e.g. `zha_event`, `deconz_event`, or a custom event name fired by one of your automations). Same field name and meaning as in HA automation YAML. | +| `event_data` | | Optional dictionary of key/value pairs the event's payload must contain for this entry to trigger. Matching is a deep subset (every key listed must be present in the event payload and match; extra keys in the event are ignored). Same field name and semantics as in HA automation YAML. Omit entirely to trigger on every fire of this `event_type`. | + +> [!TIP] Shared `event_type` values like `zha_event` and `deconz_event` fire for **every** device on that integration. Without an `event_data` filter the camera would trigger on any Zigbee/deCONZ device press in your home. Use `event_data` to narrow down to the specific device you care about; you can copy values straight out of **Developer tools → Events** in Home Assistant. ### `reviews` @@ -429,6 +455,11 @@ cameras: entities: - binary_sensor.front_door_sensor events: + - event_type: zha_event + event_data: + device_ieee: '00:11:22:33:44:55:66:77' + command: press + media_events: - events - clips - snapshots diff --git a/docs/configuration/view.md b/docs/configuration/view.md index fd3ddb96..6c28b352 100644 --- a/docs/configuration/view.md +++ b/docs/configuration/view.md @@ -153,7 +153,7 @@ human interaction with the card; this behavior can be configured via the | `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.). | +| `event_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. | @@ -165,7 +165,8 @@ human interaction with the card; this behavior can be configured via the > "ring-then-end-if-unanswered" pattern), the ring lasts until the source ends > 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`. +> `event_hold_seconds` (default `30`s) plus `untrigger_delay_seconds` (default +> `0`s). ### Trigger action configuration diff --git a/docs/examples.md b/docs/examples.md index 31d8d130..70bf914e 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -363,7 +363,9 @@ elements: The [`doorbell` profile](configuration/profiles.md?id=doorbell) turns a dashboard into a phone-like ringer when somebody presses the doorbell, by setting [`view.triggers.actions.trigger: call`](configuration/view.md?id=triggers) and auto-discovering [HA `event.*` entities](https://www.home-assistant.io/integrations/event/#device-class) with `device_class: doorbell` on the camera's device (Ring, UniFi Protect, Nest, DoorBird, Reolink, etc.). The intended deployment is a wall-mounted tablet sitting on the dashboard. -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. +A doorbell press is instantaneous, so the card synthesises a ring window from [`view.triggers.event_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.media_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 @@ -381,6 +383,33 @@ profiles: > [!TIP] > If your doorbell exposes a `binary_sensor.*` or `switch.*` instead of an `event.*` entity, list it under [`triggers.entities`](configuration/cameras/README.md?id=triggers) on the camera manually. Auto-discovery only covers `event.*` based doorbell entities. +#### With a Zigbee (ZHA / deCONZ) doorbell button + +Zigbee buttons connected via ZHA or deCONZ typically don't expose a per-device entity — they fire raw HA bus events (`zha_event`, `deconz_event`) shared across every Zigbee device on the integration. The `doorbell` profile's auto-discovery doesn't cover this case; reuse the profile (it still wires up `trigger: call` / `untrigger: call` and the ring window) but opt out of auto-discovery per camera and add [`triggers.events`](configuration/cameras/README.md?id=events) with an `event_data` filter to pick out the right device. + +You can copy the exact `device_ieee` (ZHA) or `id` (deCONZ) and command values straight out of **Developer tools → Events** in Home Assistant — listen to the event type, press the doorbell, and use whatever appears in the `data` payload. + +```yaml +type: custom:advanced-camera-card +cameras: + - camera_entity: camera.front_door + live_provider: go2rtc + go2rtc: + modes: + - webrtc + triggers: + # Opt out of the profile's `event.*` auto-discovery -- this camera + # uses an HA bus event instead. + doorbell: false + events: + - event_type: zha_event + event_data: + device_ieee: '00:11:22:33:44:55:66:77' + command: press +profiles: + - doorbell +``` + ## Events from other cameras `dependencies.cameras` allows events/recordings for other cameras to be shown @@ -1412,7 +1441,7 @@ cameras: motion: true entities: - binary_sensor.kitchen_door_opened - events: [] + media_events: [] view: triggers: show_trigger_status: true diff --git a/src/camera-manager/browse-media/engine-browse-media.ts b/src/camera-manager/browse-media/engine-browse-media.ts index 4aab5ad8..03ef4bea 100644 --- a/src/camera-manager/browse-media/engine-browse-media.ts +++ b/src/camera-manager/browse-media/engine-browse-media.ts @@ -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; diff --git a/src/camera-manager/camera.ts b/src/camera-manager/camera.ts index a34294b8..1c6c2f1d 100644 --- a/src/camera-manager/camera.ts +++ b/src/camera-manager/camera.ts @@ -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 { diff --git a/src/camera-manager/engine-factory.ts b/src/camera-manager/engine-factory.ts index 218a9987..1b744d73 100644 --- a/src/camera-manager/engine-factory.ts +++ b/src/camera-manager/engine-factory.ts @@ -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; diff --git a/src/camera-manager/frigate/camera.ts b/src/camera-manager/frigate/camera.ts index 2f0745bc..371976c1 100644 --- a/src/camera-manager/frigate/camera.ts +++ b/src/camera-manager/frigate/camera.ts @@ -457,7 +457,7 @@ export class FrigateCamera extends Camera { ): Promise { 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'), }); }; diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index baac1a82..5e7b2818 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -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, }); diff --git a/src/camera-manager/frigate/watcher.ts b/src/camera-manager/frigate/watcher.ts index 0186835d..48f37705 100644 --- a/src/camera-manager/frigate/watcher.ts +++ b/src/camera-manager/frigate/watcher.ts @@ -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 { unsubscribe(request: FrigateWatcherRequest): void; } -type SubscriptionUnsubscribe = () => Promise; - /** * Base class for Frigate WebSocket watchers. * Handles subscription management and message routing to callbacks. diff --git a/src/camera-manager/generic/engine-generic.ts b/src/camera-manager/generic/engine-generic.ts index 48f18c7b..2acd5d38 100644 --- a/src/camera-manager/generic/engine-generic.ts +++ b/src/camera-manager/generic/engine-generic.ts @@ -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: { diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index d8c2af34..5956749b 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -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; diff --git a/src/camera-manager/motioneye/engine-motioneye.ts b/src/camera-manager/motioneye/engine-motioneye.ts index 2ca454b5..8f010f54 100644 --- a/src/camera-manager/motioneye/engine-motioneye.ts +++ b/src/camera-manager/motioneye/engine-motioneye.ts @@ -78,6 +78,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine entityRegistryManager: this._entityRegistryManager, hass, stateWatcher: this._stateWatcher, + eventWatcher: this._eventWatcher, }); } diff --git a/src/camera-manager/reolink/engine-reolink.ts b/src/camera-manager/reolink/engine-reolink.ts index 9e4f9058..e7fa800a 100644 --- a/src/camera-manager/reolink/engine-reolink.ts +++ b/src/camera-manager/reolink/engine-reolink.ts @@ -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, }); } diff --git a/src/camera-manager/tplink/engine-tplink.ts b/src/camera-manager/tplink/engine-tplink.ts index 62340c4f..c8f860e4 100644 --- a/src/camera-manager/tplink/engine-tplink.ts +++ b/src/camera-manager/tplink/engine-tplink.ts @@ -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, }); } diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts index 65009982..7a5c6b55 100644 --- a/src/camera-manager/types.ts +++ b/src/camera-manager/types.ts @@ -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 diff --git a/src/card-controller/hass/event-watcher.ts b/src/card-controller/hass/event-watcher.ts new file mode 100644 index 00000000..ba298e8c --- /dev/null +++ b/src/card-controller/hass/event-watcher.ts @@ -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; + unsubscribe(request: EventSubscriptionRequest): Promise; +} + +/** + * 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>(); + + public async subscribe( + hass: HomeAssistant, + request: EventSubscriptionRequest, + ): Promise { + const isFirst = !this._hasSubscribers(request.event_type); + this._requests.push(request); + if (isFirst) { + const pendingSubscription = hass.connection.subscribeEvents( + (event) => this._receiveEvent(event), + request.event_type, + ); + this._unsubscribers.set(request.event_type, pendingSubscription); + await pendingSubscription; + } + } + + public async unsubscribe(request: EventSubscriptionRequest): Promise { + 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); + } + } + } +} diff --git a/src/card-controller/hass/hass-manager.ts b/src/card-controller/hass/hass-manager.ts index 03e95cc2..585dd798 100644 --- a/src/card-controller/hass/hass-manager.ts +++ b/src/card-controller/hass/hass-manager.ts @@ -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 diff --git a/src/card-controller/triggers-manager.ts b/src/card-controller/triggers-manager.ts index 198fb933..ba5c0b04 100644 --- a/src/card-controller/triggers-manager.ts +++ b/src/card-controller/triggers-manager.ts @@ -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 { 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 { 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(); diff --git a/src/config/management.ts b/src/config/management.ts index c5b5a9e4..79a919e8 100644 --- a/src/config/management.ts +++ b/src/config/management.ts @@ -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), + ), ]; diff --git a/src/config/schema/cameras.ts b/src/config/schema/cameras.ts index 752e6b4d..6d0ebc9a 100644 --- a/src/config/schema/cameras.ts +++ b/src/config/schema/cameras.ts @@ -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; + 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 diff --git a/src/config/schema/view.ts b/src/config/schema/view.ts index aeee1831..4f317010 100644 --- a/src/config/schema/view.ts +++ b/src/config/schema/view.ts @@ -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; diff --git a/src/const.ts b/src/const.ts index 3056b187..90acac8f 100644 --- a/src/const.ts +++ b/src/const.ts @@ -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; diff --git a/src/editor.ts b/src/editor.ts index 1b20d73e..eeaa694e 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -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 { this._modifyConfig((config: RawAdvancedCameraCardConfig): boolean => { const array = getConfigValue(config, configPathArray); @@ -2463,6 +2476,83 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard `; } + 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`
+ + ${this._expandedMenus[MENU_CAMERAS_TRIGGERS_EVENT] === eventIndex + ? html`
+ ${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'), + })} +
` + : ''} +
`; + } + + 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` + this._valueChangedHandler(configPath, ev)} + > + + `; + } + /** * Render a boolean selector. * @param configPath The configuration path to set/read. diff --git a/src/ha/event-data-match.ts b/src/ha/event-data-match.ts new file mode 100644 index 00000000..f0d59dc3 --- /dev/null +++ b/src/ha/event-data-match.ts @@ -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, + data: unknown, +): boolean => typeof data === 'object' && data !== null && isMatch(data, filter); diff --git a/src/ha/get-trigger-event-type.ts b/src/ha/get-trigger-event-type.ts index 5066a3d8..7818d4be 100644 --- a/src/ha/get-trigger-event-type.ts +++ b/src/ha/get-trigger-event-type.ts @@ -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'; diff --git a/src/ha/types.ts b/src/ha/types.ts index 98e0317b..124a2a39 100644 --- a/src/ha/types.ts +++ b/src/ha/types.ts @@ -243,6 +243,8 @@ export interface HassStateDifference { newState: HassEntity; } +export type SubscriptionUnsubscribe = () => Promise; + // ************************************************************************* // Home Assistant API types. // ************************************************************************* diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 93cb2244..b8f8c516 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -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" }, diff --git a/tests/camera-manager/browse-media/engine-browse-media.test.ts b/tests/camera-manager/browse-media/engine-browse-media.test.ts index 018e037c..55743fa9 100644 --- a/tests/camera-manager/browse-media/engine-browse-media.test.ts +++ b/tests/camera-manager/browse-media/engine-browse-media.test.ts @@ -1,25 +1,27 @@ import { describe, expect, it } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { BrowseMediaCameraManagerEngine } from '../../../src/camera-manager/browse-media/engine-browse-media'; +import { CameraManagerReadOnlyConfigStore } from '../../../src/camera-manager/store'; import { CameraManagerRequestCache, CameraQuery, QueryType, } from '../../../src/camera-manager/types'; -import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; +import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher'; +import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher'; import { BROWSE_MEDIA_CACHE_SECONDS } from '../../../src/ha/browse-media/types'; import { BrowseMediaWalker } from '../../../src/ha/browse-media/walker'; import { ResolvedMediaCache } from '../../../src/ha/resolved-media'; import { QuerySource } from '../../../src/query-source'; import { ViewMedia } from '../../../src/view/item'; -import { CameraManagerReadOnlyConfigStore } from '../../../src/camera-manager/store'; import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock'; import { createCameraConfig, createHASS } from '../../test-utils'; const createEngine = (): BrowseMediaCameraManagerEngine => { return new BrowseMediaCameraManagerEngine( new EntityRegistryManagerMock(), - mock(), + mock(), + mock(), new BrowseMediaWalker(), new ResolvedMediaCache(), new CameraManagerRequestCache(), diff --git a/tests/camera-manager/camera.test.ts b/tests/camera-manager/camera.test.ts index b42aa4c3..c68db75e 100644 --- a/tests/camera-manager/camera.test.ts +++ b/tests/camera-manager/camera.test.ts @@ -3,10 +3,12 @@ import { mock } from 'vitest-mock-extended'; import { Camera } from '../../src/camera-manager/camera.js'; import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic.js'; import { CameraProxyConfig } from '../../src/camera-manager/types.js'; +import { EventWatcherSubscriptionInterface } from '../../src/card-controller/hass/event-watcher.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 { + callEventWatcherCallback, callStateWatcherCallback, createCameraConfig, createCapabilities, @@ -23,7 +25,10 @@ describe('Camera', () => { const config = createCameraConfig(); const camera = new Camera( config, - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), ); expect(camera.getConfig()).toBe(config); }); @@ -33,7 +38,10 @@ describe('Camera', () => { const capabilities = createCapabilities(); const camera = await createInitializedCamera( createCameraConfig(), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), capabilities, ); expect(camera.getCapabilities()).toBe(capabilities); @@ -42,7 +50,10 @@ describe('Camera', () => { it('when unpopulated', async () => { const camera = new Camera( createCameraConfig(), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), ); expect(camera.getCapabilities()).toBeNull(); }); @@ -51,6 +62,7 @@ describe('Camera', () => { it('should get engine', async () => { const engine = new GenericCameraManagerEngine( mock(), + mock(), ); const camera = new Camera(createCameraConfig(), engine); expect(camera.getEngine()).toBe(engine); @@ -59,7 +71,10 @@ describe('Camera', () => { it('should set and get id', async () => { const camera = new Camera( createCameraConfig(), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), ); camera.setID('foo'); expect(camera.getID()).toBe('foo'); @@ -69,7 +84,10 @@ describe('Camera', () => { it('should throw without id', async () => { const camera = new Camera( createCameraConfig(), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), ); expect(() => camera.getID()).toThrowError( 'Could not determine camera id for the following ' + @@ -89,13 +107,17 @@ describe('Camera', () => { entities: ['camera.foo'], }, }), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), ); const stateWatcher = mock(); await camera.initialize({ hass: createHASS(), stateWatcher: stateWatcher, + eventWatcher: mock(), capabilityOptions: { capabilities: createCapabilities({ trigger: true }) }, }); @@ -116,7 +138,10 @@ describe('Camera', () => { stream: 'stream', }, }), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), ); vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true); @@ -124,6 +149,7 @@ describe('Camera', () => { await camera.initialize({ hass: createHASS(), stateWatcher: mock(), + eventWatcher: mock(), }); expect(liveProviderSupports2WayAudio).toHaveBeenCalledWith( @@ -154,7 +180,10 @@ describe('Camera', () => { stream: 'stream', }, }), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), ); vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(false); @@ -162,6 +191,7 @@ describe('Camera', () => { await camera.initialize({ hass: createHASS(), stateWatcher: mock(), + eventWatcher: mock(), }); expect(camera.getCapabilities()?.has('2-way-audio')).toBe(false); @@ -176,7 +206,10 @@ describe('Camera', () => { metadata_fetch_timeout_seconds: 20, }, }), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), ); vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true); @@ -184,6 +217,7 @@ describe('Camera', () => { await camera.initialize({ hass: createHASS(), stateWatcher: mock(), + eventWatcher: mock(), }); expect(liveProviderSupports2WayAudio).toHaveBeenCalledWith( @@ -206,7 +240,10 @@ describe('Camera', () => { live: true, }, }), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), ); vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true); @@ -217,6 +254,7 @@ describe('Camera', () => { await camera.initialize({ hass, stateWatcher: mock(), + eventWatcher: mock(), }); expect(liveProviderSupports2WayAudio).toHaveBeenCalledWith( @@ -243,7 +281,10 @@ describe('Camera', () => { createCameraConfig({ proxy: { live: true }, }), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), ); expect(camera.getLiveProxyConfig()).toEqual( expect.objectContaining({ enabled: true, enforce: true }), @@ -255,7 +296,10 @@ describe('Camera', () => { createCameraConfig({ proxy: { media: true }, }), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), ); expect(camera.getMediaProxyConfig()).toEqual( expect.objectContaining({ enabled: true, enforce: true }), @@ -268,7 +312,10 @@ describe('Camera', () => { live_provider: 'go2rtc', go2rtc: { url: 'http://go2rtc' }, }), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), ); expect(camera.getLiveProxyConfig()).toEqual( expect.objectContaining({ enabled: true, enforce: false }), @@ -280,7 +327,10 @@ describe('Camera', () => { createCameraConfig({ proxy: { media: 'auto' }, }), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), ); expect(camera.getMediaProxyConfig()).toEqual( expect.objectContaining({ enabled: false, enforce: false }), @@ -294,12 +344,16 @@ describe('Camera', () => { force: ['2-way-audio'], }, }), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), ); await camera.initialize({ hass: createHASS(), stateWatcher: mock(), + eventWatcher: mock(), }); expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled(); @@ -314,12 +368,16 @@ describe('Camera', () => { force: ['2-way-audio'], }, }), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), ); await camera.initialize({ hass: createHASS(), stateWatcher: mock(), + eventWatcher: mock(), }); expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled(); @@ -334,12 +392,16 @@ describe('Camera', () => { force: ['2-way-audio'], }, }), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), ); await camera.initialize({ hass: createHASS(), stateWatcher: mock(), + eventWatcher: mock(), }); expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled(); @@ -353,12 +415,16 @@ describe('Camera', () => { disable: ['2-way-audio'], }, }), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), ); await camera.initialize({ hass: createHASS(), stateWatcher: mock(), + eventWatcher: mock(), }); expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled(); @@ -372,12 +438,16 @@ describe('Camera', () => { disable_except: ['substream'], }, }), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), ); await camera.initialize({ hass: createHASS(), stateWatcher: mock(), + eventWatcher: mock(), }); expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled(); @@ -391,13 +461,17 @@ describe('Camera', () => { disable_except: ['substream', '2-way-audio'], }, }), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), ); vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true); await camera.initialize({ hass: createHASS(), stateWatcher: mock(), + eventWatcher: mock(), }); expect(liveProviderSupports2WayAudio).toHaveBeenCalled(); @@ -411,13 +485,17 @@ describe('Camera', () => { disable_except: [], }, }), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), ); vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true); await camera.initialize({ hass: createHASS(), stateWatcher: mock(), + eventWatcher: mock(), }); expect(liveProviderSupports2WayAudio).toHaveBeenCalled(); @@ -432,12 +510,16 @@ describe('Camera', () => { }); const camera = new Camera( createCameraConfig({ camera_entity: 'camera.front_door' }), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), ); await camera.initialize({ hass: createHASS(), stateWatcher: mock(), + eventWatcher: mock(), entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]), }); @@ -447,12 +529,16 @@ describe('Camera', () => { it('should leave entity null when camera_entity is unset', async () => { const camera = new Camera( createCameraConfig(), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), ); await camera.initialize({ hass: createHASS(), stateWatcher: mock(), + eventWatcher: mock(), entityRegistryManager: new EntityRegistryManagerMock(), }); @@ -462,12 +548,16 @@ describe('Camera', () => { it('should leave entity null when entityRegistryManager is not provided', async () => { const camera = new Camera( createCameraConfig({ camera_entity: 'camera.front_door' }), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), ); await camera.initialize({ hass: createHASS(), stateWatcher: mock(), + eventWatcher: mock(), }); expect(camera.getEntity()).toBeNull(); @@ -504,7 +594,10 @@ describe('Camera', () => { ...(options?.userEntities && { entities: options.userEntities }), }, }), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), ); const stateWatcher = mock(); const hass = createHASS( @@ -518,6 +611,7 @@ describe('Camera', () => { await camera.initialize({ hass, stateWatcher, + eventWatcher: mock(), ...(!options?.omitRegistryManager && { entityRegistryManager: new EntityRegistryManagerMock( options?.registryEntities ?? [cameraEntity, doorbellEntity], @@ -643,7 +737,10 @@ describe('Camera', () => { entities: ['binary_sensor.foo'], }, }), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), { eventCallback: eventCallback, }, @@ -653,6 +750,7 @@ describe('Camera', () => { await camera.initialize({ hass: createHASS(), stateWatcher: stateWatcher, + eventWatcher: mock(), capabilityOptions: { capabilities: createCapabilities({ trigger: true }) }, }); @@ -673,6 +771,100 @@ describe('Camera', () => { }, ); + it('should subscribe to configured triggers.events and dispatch momentary events', async () => { + const eventCallback = vi.fn(); + const camera = new Camera( + createCameraConfig({ + id: 'camera_1', + triggers: { + events: [{ event_type: 'zha_event' }], + }, + }), + new GenericCameraManagerEngine( + mock(), + mock(), + ), + { eventCallback }, + ); + + const eventWatcher = mock(); + await camera.initialize({ + hass: createHASS(), + stateWatcher: mock(), + eventWatcher, + capabilityOptions: { capabilities: createCapabilities({ trigger: true }) }, + }); + + expect(eventWatcher.subscribe).toBeCalledTimes(1); + const request = vi.mocked(eventWatcher.subscribe).mock.calls[0][1]; + expect(request.event_type).toBe('zha_event'); + expect(request.matcher).toBeUndefined(); + + callEventWatcherCallback(eventWatcher, { command: 'press' }); + + expect(eventCallback).toBeCalledWith({ + cameraID: 'camera_1', + id: 'event:zha_event', + type: 'momentary', + }); + + await camera.destroy(); + expect(eventWatcher.unsubscribe).toBeCalled(); + }); + + it('should attach a matcher when triggers.events entry has data filter', async () => { + const camera = new Camera( + createCameraConfig({ + id: 'camera_1', + triggers: { + events: [{ event_type: 'zha_event', event_data: { command: 'press' } }], + }, + }), + new GenericCameraManagerEngine( + mock(), + mock(), + ), + ); + + const eventWatcher = mock(); + await camera.initialize({ + hass: createHASS(), + stateWatcher: mock(), + eventWatcher, + capabilityOptions: { capabilities: createCapabilities({ trigger: true }) }, + }); + + const matcher = vi.mocked(eventWatcher.subscribe).mock.calls[0][1].matcher; + expect(matcher).toBeDefined(); + expect(matcher?.({ command: 'press', extra: 1 })).toBe(true); + expect(matcher?.({ command: 'release' })).toBe(false); + }); + + it('should not subscribe to events when trigger capability is disabled', async () => { + const camera = new Camera( + createCameraConfig({ + id: 'camera_1', + triggers: { + events: [{ event_type: 'zha_event' }], + }, + }), + new GenericCameraManagerEngine( + mock(), + mock(), + ), + ); + + const eventWatcher = mock(); + await camera.initialize({ + hass: createHASS(), + stateWatcher: mock(), + eventWatcher, + capabilityOptions: { capabilities: createCapabilities({ trigger: false }) }, + }); + + expect(eventWatcher.subscribe).not.toBeCalled(); + }); + it('should not dispatch when the helper returns null', async () => { vi.spyOn(global.console, 'warn').mockReturnValue(undefined); @@ -684,7 +876,10 @@ describe('Camera', () => { entities: ['event.front_door_doorbell'], }, }), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), { eventCallback: eventCallback, }, @@ -694,6 +889,7 @@ describe('Camera', () => { await camera.initialize({ hass: createHASS(), stateWatcher: stateWatcher, + eventWatcher: mock(), capabilityOptions: { capabilities: createCapabilities({ trigger: true }) }, }); @@ -706,7 +902,7 @@ describe('Camera', () => { expect(eventCallback).not.toBeCalled(); }); - it('should dispatch a signal for an event entity fire', async () => { + it('should dispatch a momentary event for an event entity fire', async () => { vi.spyOn(global.console, 'warn').mockReturnValue(undefined); const eventCallback = vi.fn(); @@ -717,7 +913,10 @@ describe('Camera', () => { entities: ['event.front_door_doorbell'], }, }), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), { eventCallback: eventCallback, }, @@ -727,6 +926,7 @@ describe('Camera', () => { await camera.initialize({ hass: createHASS(), stateWatcher: stateWatcher, + eventWatcher: mock(), capabilityOptions: { capabilities: createCapabilities({ trigger: true }) }, }); @@ -740,7 +940,7 @@ describe('Camera', () => { expect(eventCallback).toBeCalledWith({ cameraID: 'camera_1', id: 'event.front_door_doorbell', - type: 'signal', + type: 'momentary', }); }); @@ -753,7 +953,10 @@ describe('Camera', () => { entities: ['binary_sensor.foo'], }, }), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), { eventCallback: eventCallback, }, @@ -763,6 +966,7 @@ describe('Camera', () => { await camera.initialize({ hass: createHASS(), stateWatcher: stateWatcher, + eventWatcher: mock(), capabilityOptions: { capabilities: createCapabilities({ trigger: false }) }, }); @@ -924,7 +1128,10 @@ describe('Camera', () => { (_name: string, cameraConfig: unknown, expectedResult: CameraProxyConfig) => { const camera = new Camera( createCameraConfig(cameraConfig), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), ); expect(camera.getProxyConfig()).toEqual(expectedResult); }, @@ -938,7 +1145,10 @@ describe('Camera', () => { go2rtc: { stream: '' }, camera_entity: '', }), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), ); expect(camera.getEndpoints()).toBeNull(); }); @@ -952,7 +1162,10 @@ describe('Camera', () => { }, camera_entity: 'camera.foo', }), - new GenericCameraManagerEngine(mock()), + new GenericCameraManagerEngine( + mock(), + mock(), + ), ); expect(camera.getEndpoints()).toEqual({ diff --git a/tests/camera-manager/engine-factory.test.ts b/tests/camera-manager/engine-factory.test.ts index 34c7fd08..dbe00e84 100644 --- a/tests/camera-manager/engine-factory.test.ts +++ b/tests/camera-manager/engine-factory.test.ts @@ -7,10 +7,11 @@ import { MotionEyeCameraManagerEngine } from '../../src/camera-manager/motioneye import { ReolinkCameraManagerEngine } from '../../src/camera-manager/reolink/engine-reolink.js'; import { TPLinkCameraManagerEngine } from '../../src/camera-manager/tplink/engine-tplink.js'; import { Engine } from '../../src/camera-manager/types.js'; +import { EventWatcherSubscriptionInterface } from '../../src/card-controller/hass/event-watcher.js'; import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js'; import { CardWideConfig } from '../../src/config/schema/types.js'; -import { EntityRegistryManager } from '../../src/ha/registry/entity/types.js'; import { DeviceRegistryManager } from '../../src/ha/registry/device'; +import { EntityRegistryManager } from '../../src/ha/registry/entity/types.js'; import { ResolvedMediaCache } from '../../src/ha/resolved-media.js'; import { EntityRegistryManagerMock } from '../ha/registry/entity/mock.js'; import { @@ -232,6 +233,7 @@ describe('createEngine()', () => { expect( await createFactory().createEngine(Engine.Generic, { stateWatcher: mock(), + eventWatcher: mock(), resolvedMediaCache: mock(), }), ).toBeInstanceOf(GenericCameraManagerEngine); @@ -240,6 +242,7 @@ describe('createEngine()', () => { expect( await createFactory().createEngine(Engine.Frigate, { stateWatcher: mock(), + eventWatcher: mock(), resolvedMediaCache: mock(), }), ).toBeInstanceOf(FrigateCameraManagerEngine); @@ -248,6 +251,7 @@ describe('createEngine()', () => { expect( await createFactory().createEngine(Engine.MotionEye, { stateWatcher: mock(), + eventWatcher: mock(), resolvedMediaCache: mock(), }), ).toBeInstanceOf(MotionEyeCameraManagerEngine); @@ -256,6 +260,7 @@ describe('createEngine()', () => { expect( await createFactory().createEngine(Engine.Reolink, { stateWatcher: mock(), + eventWatcher: mock(), resolvedMediaCache: mock(), }), ).toBeInstanceOf(ReolinkCameraManagerEngine); @@ -264,6 +269,7 @@ describe('createEngine()', () => { expect( await createFactory().createEngine(Engine.TPLink, { stateWatcher: mock(), + eventWatcher: mock(), resolvedMediaCache: mock(), }), ).toBeInstanceOf(TPLinkCameraManagerEngine); diff --git a/tests/camera-manager/frigate/camera.test.ts b/tests/camera-manager/frigate/camera.test.ts index 4d9bd602..4721eede 100644 --- a/tests/camera-manager/frigate/camera.test.ts +++ b/tests/camera-manager/frigate/camera.test.ts @@ -18,9 +18,10 @@ import { FrigateReviewWatcher, } from '../../../src/camera-manager/frigate/watcher'; import { ActionsExecutor } from '../../../src/card-controller/actions/types'; -import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; +import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher'; +import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher'; import { PTZAction } from '../../../src/config/schema/actions/custom/ptz'; -import { CameraTriggerEventType } from '../../../src/config/schema/cameras'; +import { CameraTriggerMediaEventType } from '../../../src/config/schema/cameras'; import { Entity, EntityRegistryManager } from '../../../src/ha/registry/entity/types'; import { ViewMediaType } from '../../../src/view/item'; import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock'; @@ -69,7 +70,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -91,7 +93,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: entityRegistryManager, - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }), @@ -116,7 +119,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: entityRegistryManager, - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -141,7 +145,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: entityRegistryManager, - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -166,7 +171,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: entityRegistryManager, - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -191,7 +197,8 @@ describe('FrigateCamera', () => { }), }), entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -214,7 +221,8 @@ describe('FrigateCamera', () => { }), }), entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -231,7 +239,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -254,7 +263,8 @@ describe('FrigateCamera', () => { }), }), entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -278,7 +288,8 @@ describe('FrigateCamera', () => { }), }), entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -301,7 +312,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -329,7 +341,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -361,7 +374,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -391,7 +405,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -428,7 +443,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -460,7 +476,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -496,7 +513,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -814,7 +832,7 @@ describe('FrigateCamera', () => { camera_name: 'CAMERA', }, triggers: { - events: ['events'], + media_events: ['events'], }, }), mock(), @@ -825,7 +843,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: eventWatcher, frigateReviewWatcher: mock(), }); @@ -845,7 +864,7 @@ describe('FrigateCamera', () => { camera_name: 'CAMERA', }, triggers: { - events: [], + media_events: [], }, }), mock(), @@ -856,7 +875,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: eventWatcher, frigateReviewWatcher: mock(), }); @@ -882,7 +902,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: eventWatcher, frigateReviewWatcher: mock(), }); @@ -896,7 +917,7 @@ describe('FrigateCamera', () => { client_id: 'CLIENT_ID', }, triggers: { - events: ['events'], + media_events: ['events'], }, }), mock(), @@ -907,7 +928,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: eventWatcher, frigateReviewWatcher: mock(), }); @@ -919,7 +941,7 @@ describe('FrigateCamera', () => { createCameraConfig({ frigate: { camera_name: 'front_door' }, triggers: { - events: ['events'], + media_events: ['events'], }, }), mock(), @@ -932,7 +954,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: eventWatcher, frigateReviewWatcher: mock(), }); @@ -1002,7 +1025,7 @@ describe('FrigateCamera', () => { ])( 'with events %s when snapshot %s and clip %s', async ( - events: CameraTriggerEventType[], + events: CameraTriggerMediaEventType[], hasSnapshot: boolean, hasClip: boolean, call: boolean, @@ -1015,7 +1038,7 @@ describe('FrigateCamera', () => { camera_name: 'camera.front_door', }, triggers: { - events: events, + media_events: events, }, }), mock(), @@ -1029,7 +1052,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: eventWatcher, frigateReviewWatcher: mock(), }); @@ -1087,7 +1111,7 @@ describe('FrigateCamera', () => { zones: ['front_steps'], }, triggers: { - events: ['events'], + media_events: ['events'], }, }), mock(), @@ -1101,7 +1125,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: eventWatcher, frigateReviewWatcher: mock(), }); @@ -1146,7 +1171,7 @@ describe('FrigateCamera', () => { labels: ['person'], }, triggers: { - events: ['events'], + media_events: ['events'], }, }), mock(), @@ -1160,7 +1185,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: eventWatcher, frigateReviewWatcher: mock(), }); @@ -1202,7 +1228,7 @@ describe('FrigateCamera', () => { camera_name: 'camera.front_door', }, triggers: { - events: ['events'], + media_events: ['events'], }, }), mock(), @@ -1216,7 +1242,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: eventWatcher, frigateReviewWatcher: mock(), }); @@ -1269,7 +1296,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: reviewWatcher, }); @@ -1302,7 +1330,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: reviewWatcher, }); @@ -1331,7 +1360,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: reviewWatcher, }); @@ -1365,7 +1395,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: reviewWatcher, }); @@ -1429,7 +1460,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: reviewWatcher, }); @@ -1501,7 +1533,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: reviewWatcher, }); @@ -1573,7 +1606,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: reviewWatcher, }); @@ -1631,7 +1665,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: reviewWatcher, }); @@ -1688,7 +1723,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: reviewWatcher, }); @@ -1749,7 +1785,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: reviewWatcher, }); @@ -1807,7 +1844,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: reviewWatcher, }); @@ -1873,7 +1911,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: reviewWatcher, }); @@ -1951,7 +1990,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: entityRegistryManager, - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -1981,7 +2021,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: entityRegistryManager, - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -2005,7 +2046,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: new EntityRegistryManagerMock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -2030,7 +2072,8 @@ describe('FrigateCamera', () => { camera.initialize({ hass: createHASS(), entityRegistryManager: new EntityRegistryManagerMock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }), @@ -2056,7 +2099,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: entityRegistryManager, - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -2081,7 +2125,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: entityRegistryManager, - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -2105,7 +2150,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: new EntityRegistryManagerMock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -2138,7 +2184,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: entityRegistryManager, - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -2173,7 +2220,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: entityRegistryManager, - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -2193,7 +2241,8 @@ describe('FrigateCamera', () => { await camera.initialize({ hass: hass, entityRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -2226,7 +2275,8 @@ describe('FrigateCamera', () => { entityRegistryManager: new EntityRegistryManagerMock([ createRegistryEntity({ entity_id: 'camera.office_frigate' }), ]), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -2264,7 +2314,8 @@ describe('FrigateCamera', () => { entityRegistryManager: new EntityRegistryManagerMock([ createRegistryEntity({ entity_id: 'camera.office_frigate' }), ]), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -2317,7 +2368,8 @@ describe('FrigateCamera', () => { entityRegistryManager: new EntityRegistryManagerMock([ createRegistryEntity({ entity_id: 'camera.office_frigate' }), ]), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -2353,7 +2405,8 @@ describe('FrigateCamera', () => { entityRegistryManager: new EntityRegistryManagerMock([ createRegistryEntity({ entity_id: 'camera.office_frigate' }), ]), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); diff --git a/tests/camera-manager/frigate/engine-frigate.test.ts b/tests/camera-manager/frigate/engine-frigate.test.ts index 9d4bd36e..54b503e2 100644 --- a/tests/camera-manager/frigate/engine-frigate.test.ts +++ b/tests/camera-manager/frigate/engine-frigate.test.ts @@ -1,4 +1,5 @@ import { afterEach, assert, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; import { RecordingSegmentsCache } from '../../../src/camera-manager/cache'; import { Camera } from '../../../src/camera-manager/camera'; import { @@ -32,6 +33,7 @@ import { QueryResultsType, QueryType, } from '../../../src/camera-manager/types'; +import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher'; import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; import { CameraConfig } from '../../../src/config/schema/cameras'; import { RawAdvancedCameraCardConfig } from '../../../src/config/types'; @@ -58,6 +60,7 @@ const createEngine = (options?: { return new FrigateCameraManagerEngine( new EntityRegistryManagerMock(), new StateWatcher(), + mock(), options?.cache ?? new RecordingSegmentsCache(), options?.requestCache ?? new CameraManagerRequestCache(), ); diff --git a/tests/camera-manager/generic/engine-generic.test.ts b/tests/camera-manager/generic/engine-generic.test.ts index d9b55da2..6cea6b66 100644 --- a/tests/camera-manager/generic/engine-generic.test.ts +++ b/tests/camera-manager/generic/engine-generic.test.ts @@ -2,10 +2,11 @@ import { describe, expect, it } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { GenericCameraManagerEngine } from '../../../src/camera-manager/generic/engine-generic'; import { Engine, QueryResultsType, QueryType } from '../../../src/camera-manager/types'; -import { QuerySource } from '../../../src/query-source'; +import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher'; import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher'; import { CameraConfig } from '../../../src/config/schema/cameras'; import { RawAdvancedCameraCardConfig } from '../../../src/config/types'; +import { QuerySource } from '../../../src/query-source'; import { TestViewMedia, createCameraConfig, @@ -15,7 +16,10 @@ import { } from '../../test-utils'; const createEngine = (): GenericCameraManagerEngine => { - return new GenericCameraManagerEngine(mock()); + return new GenericCameraManagerEngine( + mock(), + mock(), + ); }; const createGenericCameraConfig = ( diff --git a/tests/camera-manager/manager.test.ts b/tests/camera-manager/manager.test.ts index 12d1045d..a3027d2e 100644 --- a/tests/camera-manager/manager.test.ts +++ b/tests/camera-manager/manager.test.ts @@ -574,15 +574,15 @@ describe('CameraManager', () => { config: createCameraConfig({ ...baseCameraConfig, triggers: { - events: ['snapshots'], + media_events: ['snapshots'], }, }), }, ]); await manager.initializeCamerasFromConfig(); - expect(manager.getStore().getCamera('id')?.getConfig().triggers.events).toEqual([ - 'snapshots', - ]); + expect( + manager.getStore().getCamera('id')?.getConfig().triggers.media_events, + ).toEqual(['snapshots']); }); }); diff --git a/tests/camera-manager/motioneye/camera.test.ts b/tests/camera-manager/motioneye/camera.test.ts index b31272d6..7ad6ab26 100644 --- a/tests/camera-manager/motioneye/camera.test.ts +++ b/tests/camera-manager/motioneye/camera.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { CameraManagerEngine } from '../../../src/camera-manager/engine'; import { MotionEyeCamera } from '../../../src/camera-manager/motioneye/camera'; -import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; +import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher'; +import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher'; import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock'; import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils'; @@ -48,7 +49,8 @@ describe('MotionEyeCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const endpoints = camera.getEndpoints(); @@ -65,7 +67,8 @@ describe('MotionEyeCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const endpoints = camera.getEndpoints(); @@ -82,7 +85,8 @@ describe('MotionEyeCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const capabilities = camera.getCapabilities(); @@ -107,7 +111,8 @@ describe('MotionEyeCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const capabilities = camera.getCapabilities(); diff --git a/tests/camera-manager/motioneye/engine-motioneye.test.ts b/tests/camera-manager/motioneye/engine-motioneye.test.ts index f4d8239e..3468af7d 100644 --- a/tests/camera-manager/motioneye/engine-motioneye.test.ts +++ b/tests/camera-manager/motioneye/engine-motioneye.test.ts @@ -14,7 +14,8 @@ import { QueryResultsType, QueryType, } from '../../../src/camera-manager/types'; -import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; +import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher'; +import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher'; import { BrowseMediaMetadata } from '../../../src/ha/browse-media/types'; import { BrowseMediaStep, BrowseMediaWalker } from '../../../src/ha/browse-media/walker'; import { Entity } from '../../../src/ha/registry/entity/types'; @@ -50,7 +51,8 @@ const createEngine = (options?: { }): MotionEyeCameraManagerEngine => { return new MotionEyeCameraManagerEngine( new EntityRegistryManagerMock(options?.entities ?? [createEntity()]), - mock(), + mock(), + mock(), options?.walker ?? new BrowseMediaWalker(), new ResolvedMediaCache(), options?.requestCache ?? new CameraManagerRequestCache(), @@ -73,7 +75,8 @@ const createMotionEyeStore = async ( await camera.initialize({ hass: createHASS(), entityRegistryManager: new EntityRegistryManagerMock([entity]), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); camera.setID(options?.cameraID ?? 'camera-1'); const store = new CameraManagerStore(); @@ -497,7 +500,8 @@ describe('MotionEyeCameraManagerEngine', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: new EntityRegistryManagerMock([createEntity()]), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); camera.setID('camera-1'); const store = new CameraManagerStore(); @@ -545,7 +549,8 @@ describe('MotionEyeCameraManagerEngine', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: new EntityRegistryManagerMock([createEntity()]), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); camera.setID('camera-1'); const store = new CameraManagerStore(); @@ -599,7 +604,8 @@ describe('MotionEyeCameraManagerEngine', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: new EntityRegistryManagerMock([createEntity()]), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); camera.setID('camera-1'); const store = new CameraManagerStore(); diff --git a/tests/camera-manager/reolink/camera.test.ts b/tests/camera-manager/reolink/camera.test.ts index a991da57..8d6f21bf 100644 --- a/tests/camera-manager/reolink/camera.test.ts +++ b/tests/camera-manager/reolink/camera.test.ts @@ -4,7 +4,8 @@ import { CameraManagerEngine } from '../../../src/camera-manager/engine'; import { ReolinkCamera } from '../../../src/camera-manager/reolink/camera'; import { CameraProxyConfig } from '../../../src/camera-manager/types'; import { ActionsExecutor } from '../../../src/card-controller/actions/types'; -import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; +import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher'; +import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher'; import { DeviceRegistryManager } from '../../../src/ha/registry/device'; import { EntityRegistryManagerLive } from '../../../src/ha/registry/entity'; import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock'; @@ -105,7 +106,8 @@ describe('ReolinkCamera', () => { hass: createHASS(), entityRegistryManager: mock(), deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }), ).rejects.toThrowError('Could not find camera entity'); }); @@ -129,7 +131,8 @@ describe('ReolinkCamera', () => { hass: createHASS(), entityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }), ).rejects.toThrowError('Could not initialize Reolink camera'); }); @@ -153,7 +156,8 @@ describe('ReolinkCamera', () => { hass: createHASS(), entityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }), ).rejects.toThrowError('Could not initialize Reolink camera'); }); @@ -169,7 +173,8 @@ describe('ReolinkCamera', () => { hass: createHASS(), entityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); expect(camera.getChannel()).toBe(0); @@ -201,7 +206,8 @@ describe('ReolinkCamera', () => { hass: createHASS(), entityRegistryManager, deviceRegistryManager, - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); expect(camera.getChannel()).toBe(3); @@ -224,7 +230,8 @@ describe('ReolinkCamera', () => { hass: createHASS(), entityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); expect(camera.getChannel()).toBe(7); @@ -247,7 +254,8 @@ describe('ReolinkCamera', () => { hass: createHASS(), entityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); expect(camera.getChannel()).toBe(0); @@ -270,7 +278,8 @@ describe('ReolinkCamera', () => { hass: createHASS(), entityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); expect(camera.getChannel()).toBe(7); @@ -295,7 +304,8 @@ describe('ReolinkCamera', () => { hass: createHASS(), entityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); expect(camera.getChannel()).toBe(0); @@ -327,7 +337,8 @@ describe('ReolinkCamera', () => { hass: createHASS(), entityRegistryManager, deviceRegistryManager, - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); expect(camera.getChannel()).toBe(0); @@ -359,7 +370,8 @@ describe('ReolinkCamera', () => { hass: createHASS(), entityRegistryManager, deviceRegistryManager, - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); expect(camera.getChannel()).toBe(0); @@ -391,7 +403,8 @@ describe('ReolinkCamera', () => { hass: createHASS(), entityRegistryManager, deviceRegistryManager, - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); expect(camera.getChannel()).toBe(0); @@ -410,7 +423,8 @@ describe('ReolinkCamera', () => { hass: createHASS(), entityRegistryManager: ptzPopulatedEntityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ @@ -449,7 +463,8 @@ describe('ReolinkCamera', () => { }), ]), deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ @@ -475,7 +490,8 @@ describe('ReolinkCamera', () => { }), entityRegistryManager: ptzPopulatedEntityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ @@ -508,7 +524,8 @@ describe('ReolinkCamera', () => { hass: createHASS(), entityRegistryManager: ptzPopulatedEntityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ @@ -695,7 +712,8 @@ describe('ReolinkCamera', () => { hass: createHASS(), entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]), deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const executor = mock(); @@ -727,7 +745,8 @@ describe('ReolinkCamera', () => { buttonEntityPTZLeft, ]), deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const executor = mock(); await camera.executePTZAction(executor, 'left', { phase: 'start' }); @@ -754,7 +773,8 @@ describe('ReolinkCamera', () => { hass: createHASS(), entityRegistryManager: ptzPopulatedEntityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const executor = mock(); @@ -795,7 +815,8 @@ describe('ReolinkCamera', () => { hass: createHASS(), entityRegistryManager: ptzPopulatedEntityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const executor = mock(); @@ -821,7 +842,8 @@ describe('ReolinkCamera', () => { }), entityRegistryManager: ptzPopulatedEntityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const executor = mock(); @@ -852,7 +874,8 @@ describe('ReolinkCamera', () => { hass: createHASS(), entityRegistryManager: ptzPopulatedEntityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const executor = mock(); @@ -875,7 +898,8 @@ describe('ReolinkCamera', () => { numberEntityZoom, ]), deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ @@ -902,7 +926,8 @@ describe('ReolinkCamera', () => { }), ]), deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); expect(camera.getCapabilities()?.getPTZCapabilities()).toBeNull(); @@ -924,7 +949,8 @@ describe('ReolinkCamera', () => { numberEntityZoom, ]), deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ @@ -946,7 +972,8 @@ describe('ReolinkCamera', () => { numberEntityZoom, ]), deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const executor = mock(); @@ -984,7 +1011,8 @@ describe('ReolinkCamera', () => { numberEntityZoom, ]), deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const executor = mock(); @@ -1022,7 +1050,8 @@ describe('ReolinkCamera', () => { numberEntityZoom, ]), deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const executor = mock(); @@ -1060,7 +1089,8 @@ describe('ReolinkCamera', () => { numberEntityZoom, ]), deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const executor = mock(); @@ -1098,7 +1128,8 @@ describe('ReolinkCamera', () => { numberEntityZoom, ]), deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const executor = mock(); @@ -1127,7 +1158,8 @@ describe('ReolinkCamera', () => { numberEntityZoom, ]), deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const executor = mock(); @@ -1155,7 +1187,8 @@ describe('ReolinkCamera', () => { numberEntityZoom, ]), deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const executor = mock(); @@ -1180,7 +1213,8 @@ describe('ReolinkCamera', () => { numberEntityZoom, ]), deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const executor = mock(); @@ -1211,7 +1245,8 @@ describe('ReolinkCamera', () => { buttonEntityPTZStop, ]), deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const executor = mock(); @@ -1233,7 +1268,8 @@ describe('ReolinkCamera', () => { numberEntityZoom, ]), deviceRegistryManager: mock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const executor = mock(); diff --git a/tests/camera-manager/reolink/engine-reolink.test.ts b/tests/camera-manager/reolink/engine-reolink.test.ts index 6f51119d..a3f0adb7 100644 --- a/tests/camera-manager/reolink/engine-reolink.test.ts +++ b/tests/camera-manager/reolink/engine-reolink.test.ts @@ -23,19 +23,20 @@ import { QueryReturnType, QueryType, } from '../../../src/camera-manager/types'; -import { QuerySource } from '../../../src/query-source'; -import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; +import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher'; +import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher'; import { BrowseMedia, browseMediaSchema } from '../../../src/ha/browse-media/types'; import { BrowseMediaWalker } from '../../../src/ha/browse-media/walker'; import { DeviceRegistryManager } from '../../../src/ha/registry/device'; import { EntityRegistryManager } from '../../../src/ha/registry/entity/types'; import { ResolvedMediaCache } from '../../../src/ha/resolved-media'; import { homeAssistantWSRequest } from '../../../src/ha/ws-request'; +import { QuerySource } from '../../../src/query-source'; import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock'; import { - createInitializedCamera, createCameraConfig, createHASS, + createInitializedCamera, createRegistryEntity, createStore, } from '../../test-utils'; @@ -186,7 +187,8 @@ const createEngine = (options?: { return new ReolinkCameraManagerEngine( options?.entityRegistryManager ?? new EntityRegistryManagerMock(), mock(), - mock(), + mock(), + mock(), options?.browseMediaManager ?? new BrowseMediaWalker(), new ResolvedMediaCache(), new CameraManagerRequestCache(), diff --git a/tests/camera-manager/store.test.ts b/tests/camera-manager/store.test.ts index d9d7d560..7122571b 100644 --- a/tests/camera-manager/store.test.ts +++ b/tests/camera-manager/store.test.ts @@ -5,6 +5,7 @@ import { Capabilities } from '../../src/camera-manager/capabilities.js'; import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js'; import { CameraManagerStore } from '../../src/camera-manager/store.js'; import { Engine } from '../../src/camera-manager/types.js'; +import { EventWatcherSubscriptionInterface } from '../../src/card-controller/hass/event-watcher.js'; import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js'; import { DeviceRegistryManager } from '../../src/ha/registry/device/index.js'; import { EntityRegistryManager } from '../../src/ha/registry/entity/types.js'; @@ -31,10 +32,12 @@ describe('CameraManagerStore', async () => { const engineGeneric = await engineFactory.createEngine(Engine.Generic, { stateWatcher: mock(), + eventWatcher: mock(), resolvedMediaCache: mock(), }); const engineFrigate = await engineFactory.createEngine(Engine.Frigate, { stateWatcher: mock(), + eventWatcher: mock(), resolvedMediaCache: mock(), }); diff --git a/tests/camera-manager/tplink/camera.test.ts b/tests/camera-manager/tplink/camera.test.ts index ddbaf0aa..2bf3270c 100644 --- a/tests/camera-manager/tplink/camera.test.ts +++ b/tests/camera-manager/tplink/camera.test.ts @@ -3,7 +3,8 @@ import { mock } from 'vitest-mock-extended'; import { CameraManagerEngine } from '../../../src/camera-manager/engine'; import { TPLinkCamera } from '../../../src/camera-manager/tplink/camera'; import { ActionsExecutor } from '../../../src/card-controller/actions/types'; -import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; +import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher'; +import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher'; import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock'; import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils'; @@ -78,7 +79,8 @@ describe('TPLinkCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: new EntityRegistryManagerMock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }), ).rejects.toThrowError('Could not find camera entity'); }); @@ -94,7 +96,8 @@ describe('TPLinkCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: new EntityRegistryManagerMock(), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }), ).rejects.toThrowError('Could not find camera entity'); }); @@ -112,7 +115,8 @@ describe('TPLinkCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager, - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); expect(camera.getEntity()).toBe(cameraEntity); @@ -129,7 +133,8 @@ describe('TPLinkCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager, - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); expect(camera.getEntity()).toBe(cameraEntity); @@ -146,7 +151,8 @@ describe('TPLinkCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: ptzPopulatedEntityRegistryManager, - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ @@ -175,7 +181,8 @@ describe('TPLinkCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: ptzPopulatedEntityRegistryManager, - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ @@ -198,7 +205,8 @@ describe('TPLinkCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const executor = mock(); @@ -219,7 +227,8 @@ describe('TPLinkCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: ptzPopulatedEntityRegistryManager, - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const executor = mock(); @@ -242,7 +251,8 @@ describe('TPLinkCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: ptzPopulatedEntityRegistryManager, - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const executor = mock(); @@ -262,7 +272,8 @@ describe('TPLinkCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: ptzPopulatedEntityRegistryManager, - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const executor = mock(); @@ -290,7 +301,8 @@ describe('TPLinkCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: ptzPopulatedEntityRegistryManager, - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const executor = mock(); @@ -318,7 +330,8 @@ describe('TPLinkCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: ptzPopulatedEntityRegistryManager, - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const executor = mock(); @@ -357,7 +370,8 @@ describe('TPLinkCamera', () => { cameraEntity, buttonEntityPanLeft, ]), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const executor = mock(); await camera.executePTZAction(executor, 'left', { phase: 'start' }); @@ -395,7 +409,8 @@ describe('TPLinkCamera', () => { cameraWithDifferentUniqueId, buttonEntityPanLeft, ]), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); // Should not find PTZ entities since unique_id doesn't end with _live_view @@ -420,7 +435,8 @@ describe('TPLinkCamera', () => { cameraWithoutUniqueId, buttonEntityPanLeft, ]), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); // Should not find PTZ entities since camera has no unique_id @@ -442,7 +458,8 @@ describe('TPLinkCamera', () => { cameraEntity, buttonEntityPanLeft, // Only left button available ]), - stateWatcher: mock(), + stateWatcher: mock(), + eventWatcher: mock(), }); const executor = mock(); diff --git a/tests/camera-manager/tplink/engine-tplink.test.ts b/tests/camera-manager/tplink/engine-tplink.test.ts index f8a32be9..edb01929 100644 --- a/tests/camera-manager/tplink/engine-tplink.test.ts +++ b/tests/camera-manager/tplink/engine-tplink.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { TPLinkCameraManagerEngine } from '../../../src/camera-manager/tplink/engine-tplink'; import { Engine } from '../../../src/camera-manager/types'; -import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; +import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher'; +import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher'; import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock'; import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils'; @@ -11,7 +12,8 @@ const createEngine = (options?: { }): TPLinkCameraManagerEngine => { return new TPLinkCameraManagerEngine( options?.entityRegistryManager ?? new EntityRegistryManagerMock(), - mock(), + mock(), + mock(), ); }; diff --git a/tests/card-controller/hass/event-watcher.test.ts b/tests/card-controller/hass/event-watcher.test.ts new file mode 100644 index 00000000..b521c533 --- /dev/null +++ b/tests/card-controller/hass/event-watcher.test.ts @@ -0,0 +1,133 @@ +import { HassEvent } from 'home-assistant-js-websocket'; +import { describe, expect, it, vi } from 'vitest'; +import { EventWatcher } from '../../../src/card-controller/hass/event-watcher'; +import { HomeAssistant } from '../../../src/ha/types'; +import { createHASS } from '../../test-utils'; + +const fireEvent = (hass: HomeAssistant, event: HassEvent, n = 0): void => { + const mock = vi.mocked(hass.connection.subscribeEvents).mock; + expect(mock.calls.length).greaterThan(n); + + // subscribeEvents(callback, event_type) -- callback is the first argument. + mock.calls[n][0]?.(event); +}; + +const createHassEvent = (event_type: string, data: object = {}): HassEvent => ({ + event_type, + data: data as { [key: string]: string }, + origin: 'LOCAL', + time_fired: '2026-05-25T00:00:00Z', + context: { id: 'ctx', user_id: null, parent_id: null }, +}); + +describe('EventWatcher', () => { + it('opens a single WS subscription per event_type regardless of subscribers', async () => { + const watcher = new EventWatcher(); + const hass = createHASS(); + + await watcher.subscribe(hass, { event_type: 'zha_event', callback: vi.fn() }); + await watcher.subscribe(hass, { event_type: 'zha_event', callback: vi.fn() }); + + expect(hass.connection.subscribeEvents).toBeCalledTimes(1); + expect(vi.mocked(hass.connection.subscribeEvents).mock.calls[0][1]).toBe( + 'zha_event', + ); + }); + + it('opens separate WS subscriptions for distinct event_types', async () => { + const watcher = new EventWatcher(); + const hass = createHASS(); + + await watcher.subscribe(hass, { event_type: 'zha_event', callback: vi.fn() }); + await watcher.subscribe(hass, { event_type: 'deconz_event', callback: vi.fn() }); + + expect(hass.connection.subscribeEvents).toBeCalledTimes(2); + }); + + it('only tears down the WS subscription when the last subscriber unsubscribes', async () => { + const watcher = new EventWatcher(); + const hass = createHASS(); + const unsub = vi.fn(); + vi.mocked(hass.connection.subscribeEvents).mockResolvedValue(unsub); + + const req1 = { event_type: 'zha_event', callback: vi.fn() }; + const req2 = { event_type: 'zha_event', callback: vi.fn() }; + await watcher.subscribe(hass, req1); + await watcher.subscribe(hass, req2); + + await watcher.unsubscribe(req1); + expect(unsub).not.toBeCalled(); + + await watcher.unsubscribe(req2); + expect(unsub).toBeCalledTimes(1); + }); + + it('dispatches to all subscribers whose event_type matches', async () => { + const watcher = new EventWatcher(); + const hass = createHASS(); + const cb1 = vi.fn(); + const cb2 = vi.fn(); + + await watcher.subscribe(hass, { event_type: 'zha_event', callback: cb1 }); + await watcher.subscribe(hass, { event_type: 'zha_event', callback: cb2 }); + + fireEvent(hass, createHassEvent('zha_event', { command: 'press' })); + + expect(cb1).toBeCalledWith({ command: 'press' }); + expect(cb2).toBeCalledWith({ command: 'press' }); + }); + + it('drops events whose event_type does not match the request', async () => { + const watcher = new EventWatcher(); + const hass = createHASS(); + const cb = vi.fn(); + + await watcher.subscribe(hass, { event_type: 'zha_event', callback: cb }); + // Inject an unrelated event into the shared dispatcher. + fireEvent(hass, createHassEvent('other_event', { x: 1 })); + + expect(cb).not.toBeCalled(); + }); + + it('gates dispatch on the request matcher when provided', async () => { + const watcher = new EventWatcher(); + const hass = createHASS(); + const cb = vi.fn(); + const matcher = vi.fn((data: unknown) => (data as { x?: number }).x === 1); + + await watcher.subscribe(hass, { event_type: 'zha_event', matcher, callback: cb }); + + fireEvent(hass, createHassEvent('zha_event', { x: 1 })); + fireEvent(hass, createHassEvent('zha_event', { x: 2 })); + + expect(matcher).toBeCalledTimes(2); + expect(cb).toBeCalledTimes(1); + expect(cb).toBeCalledWith({ x: 1 }); + }); + + it('handles unsubscribe during a still-pending subscribe without leaking', async () => { + const watcher = new EventWatcher(); + const hass = createHASS(); + const unsub = vi.fn(); + + let resolveSubscription: ((cb: () => Promise) => void) | undefined; + const subscriptionPromise = new Promise<() => Promise>((resolve) => { + resolveSubscription = resolve; + }); + vi.mocked(hass.connection.subscribeEvents).mockReturnValue(subscriptionPromise); + + const req = { event_type: 'zha_event', callback: vi.fn() }; + const subscribePromise = watcher.subscribe(hass, req); + + // Unsubscribe before the underlying connection has resolved. + const unsubscribePromise = watcher.unsubscribe(req); + + // Resolve the connection -- the watcher should now have the unsub fn and + // call it as part of completing the unsubscribe. + resolveSubscription?.(unsub); + await subscribePromise; + await unsubscribePromise; + + expect(unsub).toBeCalledTimes(1); + }); +}); diff --git a/tests/card-controller/hass/hass-manager.test.ts b/tests/card-controller/hass/hass-manager.test.ts index e4b8ec5e..bc44e10b 100644 --- a/tests/card-controller/hass/hass-manager.test.ts +++ b/tests/card-controller/hass/hass-manager.test.ts @@ -1,5 +1,6 @@ import { STATE_RUNNING, STATE_STARTING } from 'home-assistant-js-websocket'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { EventWatcher } from '../../../src/card-controller/hass/event-watcher'; import { HASSManager } from '../../../src/card-controller/hass/hass-manager'; import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; import { @@ -29,6 +30,11 @@ describe('HASSManager', () => { expect(manager.getStateWatcher()).toEqual(expect.any(StateWatcher)); }); + it('should get event watcher', () => { + const manager = new HASSManager(createCardAPI()); + expect(manager.getEventWatcher()).toEqual(expect.any(EventWatcher)); + }); + it('should get hass after set', () => { const manager = new HASSManager(createCardAPI()); const hass = createHASS(); diff --git a/tests/card-controller/triggers-manager.test.ts b/tests/card-controller/triggers-manager.test.ts index 322a3d34..b1e9d80d 100644 --- a/tests/card-controller/triggers-manager.test.ts +++ b/tests/card-controller/triggers-manager.test.ts @@ -25,7 +25,7 @@ vi.mock('lodash-es', async () => ({ const baseTriggersConfig: TriggersOptions = { untrigger_delay_seconds: 10, untrigger_force_seconds: 0, - signal_hold_seconds: 0, + event_hold_seconds: 0, filter_selected_camera: false, show_trigger_status: false, actions: { @@ -810,7 +810,7 @@ describe('TriggersManager', () => { expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalledTimes(1); }); - it('should auto-untrigger after the delay on a signal event', async () => { + it('should auto-untrigger after the delay on a momentary event', async () => { const api = createTriggerAPI({ config: { actions: { trigger: 'none', untrigger: 'default' }, @@ -821,7 +821,7 @@ describe('TriggersManager', () => { await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event.doorbell', - type: 'signal', + type: 'momentary', }); expect(manager.isTriggered()).toBeTruthy(); @@ -835,7 +835,7 @@ describe('TriggersManager', () => { expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled(); }); - it('should auto-untrigger immediately on a signal when delay is 0', async () => { + it('should auto-untrigger immediately on a momentary event when delay is 0', async () => { const api = createTriggerAPI({ config: { untrigger_delay_seconds: 0, @@ -847,7 +847,7 @@ describe('TriggersManager', () => { await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event.doorbell', - type: 'signal', + type: 'momentary', }); await flushPromises(); @@ -855,7 +855,7 @@ describe('TriggersManager', () => { expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled(); }); - it('should not auto-untrigger from a signal while a continuous source remains active', async () => { + it('should not auto-untrigger from a momentary event while a continuous source remains active', async () => { const api = createTriggerAPI({ config: { actions: { trigger: 'none', untrigger: 'default' }, @@ -874,7 +874,7 @@ describe('TriggersManager', () => { await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event.doorbell', - type: 'signal', + type: 'momentary', }); vi.setSystemTime(add(start, { seconds: 10 })); @@ -886,11 +886,11 @@ describe('TriggersManager', () => { expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled(); }); - it('should add signal_hold_seconds on top of untrigger_delay_seconds for signals', async () => { + it('should add event_hold_seconds on top of untrigger_delay_seconds for momentary events', async () => { const api = createTriggerAPI({ config: { untrigger_delay_seconds: 5, - signal_hold_seconds: 30, + event_hold_seconds: 30, actions: { trigger: 'none', untrigger: 'default' }, }, }); @@ -899,7 +899,7 @@ describe('TriggersManager', () => { await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event.doorbell', - type: 'signal', + type: 'momentary', }); // At 34s the additive 35s window is still active. @@ -916,11 +916,11 @@ describe('TriggersManager', () => { expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled(); }); - it('should not apply signal_hold_seconds to non-signal events', async () => { + it('should not apply event_hold_seconds to non-momentary events', async () => { const api = createTriggerAPI({ config: { untrigger_delay_seconds: 5, - signal_hold_seconds: 30, + event_hold_seconds: 30, actions: { trigger: 'none', untrigger: 'default' }, }, }); @@ -1305,9 +1305,9 @@ describe('TriggersManager', () => { expect(manager.isTriggered()).toBeTruthy(); }); - it('should not reset the untrigger timer when a filtered-out signal arrives', async () => { - // Guards the `if (handled)` branch on the signal synthesis: a - // filter-rejected signal must not call the internal 'end', which would + it('should not reset the untrigger timer when a filtered-out momentary event arrives', async () => { + // Guards the `if (handled)` branch on the momentary-event synthesis: a + // filter-rejected momentary event must not call the internal 'end', which would // otherwise reset an already-running untrigger-delay timer. const api = createTriggerAPI({ config: { @@ -1341,7 +1341,7 @@ describe('TriggersManager', () => { await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event.doorbell', - type: 'signal', + type: 'momentary', }); // At t=10 (the original delay's deadline), the timer should fire. diff --git a/tests/config/management.test.ts b/tests/config/management.test.ts index fcfb0e9e..04b94244 100644 --- a/tests/config/management.test.ts +++ b/tests/config/management.test.ts @@ -4052,6 +4052,154 @@ describe('should handle version specific upgrades', () => { }); }); + describe('cameras[].triggers.events string[] → triggers.media_events', () => { + it('migrates a legacy string array to media_events', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [ + { + camera_entity: 'camera.office', + triggers: { events: ['events', 'clips'] }, + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.cameras[0].triggers).toEqual({ + media_events: ['events', 'clips'], + }); + postUpgradeChecks(config); + }); + + it('migrates an empty legacy array', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office', triggers: { events: [] } }], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.cameras[0].triggers).toEqual({ media_events: [] }); + postUpgradeChecks(config); + }); + + it('leaves the new object-array shape untouched', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [ + { + camera_entity: 'camera.office', + triggers: { events: [{ event_type: 'zha_event' }] }, + }, + ], + }; + // The new shape might still trigger OTHER upgrades to fire on the + // config, so we don't assert the overall upgrade result -- only that + // this specific field is not touched. + upgradeConfig(config); + expect(config.cameras[0].triggers).toEqual({ + events: [{ event_type: 'zha_event' }], + }); + }); + + it('drops legacy events but keeps an existing media_events untouched', () => { + // Both fields present is implausible in real user configs, but if it + // happens we still must remove the legacy `events: string[]` because + // the new schema would reject it; the explicit `media_events` wins. + const config = { + type: 'custom:advanced-camera-card', + cameras: [ + { + camera_entity: 'camera.office', + triggers: { + events: ['snapshots'], + media_events: ['clips'], + }, + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.cameras[0].triggers).toEqual({ + media_events: ['clips'], + }); + postUpgradeChecks(config); + }); + + it('is idempotent across repeated runs', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [ + { + camera_entity: 'camera.office', + triggers: { events: ['events'] }, + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(upgradeConfig(config)).toBeFalsy(); + expect(config.cameras[0].triggers).toEqual({ media_events: ['events'] }); + }); + + it('migrates per camera independently', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [ + { camera_entity: 'camera.a', triggers: { events: ['clips'] } }, + { + camera_entity: 'camera.b', + triggers: { events: [{ event_type: 'zha_event' }] }, + }, + { camera_entity: 'camera.c' }, + ], + }; + upgradeConfig(config); + expect(config.cameras[0].triggers).toEqual({ media_events: ['clips'] }); + expect(config.cameras[1].triggers).toEqual({ + events: [{ event_type: 'zha_event' }], + }); + expect(config.cameras[2].triggers).toBeUndefined(); + }); + + it('migrates a legacy cameras_global.triggers.events string array', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + cameras_global: { + triggers: { + events: ['clips', 'snapshots'], + }, + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.cameras_global.triggers).toEqual({ + media_events: ['clips', 'snapshots'], + }); + postUpgradeChecks(config); + }); + + it('leaves cameras_global new-shape events untouched', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + cameras_global: { + triggers: { + events: [{ event_type: 'zha_event' }], + }, + }, + }; + upgradeConfig(config); + expect(config.cameras_global.triggers).toEqual({ + events: [{ event_type: 'zha_event' }], + }); + }); + + it('is a no-op when triggers is not an object (malformed user config)', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office', triggers: 'not-an-object' }], + }; + upgradeConfig(config); + expect(config.cameras[0].triggers).toBe('not-an-object'); + }); + }); + describe('live_substream_{on,off,select} → substream_{on,off}', () => { it('rewrites live_substream_on to substream_on in an automation', () => { const config = { diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index c215cdb6..fada62c7 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -57,6 +57,7 @@ describe('config defaults', () => { doorbell: false, entities: [], events: [], + media_events: [], motion: false, occupancy: false, reviews: { @@ -550,7 +551,7 @@ describe('config defaults', () => { }, filter_selected_camera: true, show_trigger_status: false, - signal_hold_seconds: 30, + event_hold_seconds: 30, untrigger_delay_seconds: 0, untrigger_force_seconds: 0, }, diff --git a/tests/ha/event-data-match.test.ts b/tests/ha/event-data-match.test.ts new file mode 100644 index 00000000..85f3d51a --- /dev/null +++ b/tests/ha/event-data-match.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { matchesEventData } from '../../src/ha/event-data-match'; + +describe('matchesEventData', () => { + describe('non-object data', () => { + it.each([['string'], [42], [true], [null], [undefined]])( + 'rejects non-object data (%s) with a non-empty filter', + (data) => { + expect(matchesEventData({ a: 1 }, data)).toBe(false); + }, + ); + }); + + it('matches when every filter key matches', () => { + expect(matchesEventData({ command: 'press' }, { command: 'press', extra: 1 })).toBe( + true, + ); + }); + + it('rejects when a filter value differs', () => { + expect(matchesEventData({ command: 'press' }, { command: 'release' })).toBe(false); + }); + + it('rejects when a filter key is missing from data', () => { + expect(matchesEventData({ command: 'press' }, { other: 'press' })).toBe(false); + }); + + it('ignores extra keys in data', () => { + expect(matchesEventData({ a: 1 }, { a: 1, b: 2, c: 3 })).toBe(true); + }); + + it('matches nested objects as a subset', () => { + expect( + matchesEventData( + { device: { id: 'abc' } }, + { device: { id: 'abc', name: 'Front Door' } }, + ), + ).toBe(true); + }); + + it('rejects nested objects when a nested key differs', () => { + expect(matchesEventData({ device: { id: 'abc' } }, { device: { id: 'xyz' } })).toBe( + false, + ); + }); + + it('matches arrays element-wise', () => { + expect(matchesEventData({ tags: ['a', 'b'] }, { tags: ['a', 'b'] })).toBe(true); + }); + + it('matches arrays as a subset by index (filter shorter than data)', () => { + // Same partial-match semantics lodash uses for objects: a shorter filter + // array matches if every index it specifies matches in data. Useful when + // the user only cares about the first N values. + expect(matchesEventData({ tags: ['a'] }, { tags: ['a', 'b'] })).toBe(true); + }); + + it('rejects arrays with mismatched elements at the same index', () => { + expect(matchesEventData({ tags: ['a', 'c'] }, { tags: ['a', 'b'] })).toBe(false); + }); + + it('rejects when filter array is longer than data array', () => { + expect(matchesEventData({ tags: ['a', 'b'] }, { tags: ['a'] })).toBe(false); + }); + + it('distinguishes null from undefined', () => { + expect(matchesEventData({ a: null }, { a: null })).toBe(true); + expect(matchesEventData({ a: null }, { a: 0 })).toBe(false); + }); + + it('accepts an empty filter against any object', () => { + expect(matchesEventData({}, {})).toBe(true); + expect(matchesEventData({}, { anything: 1, nested: { x: 2 } })).toBe(true); + }); +}); diff --git a/tests/ha/get-trigger-event-type.test.ts b/tests/ha/get-trigger-event-type.test.ts index 00d20bb9..86027a44 100644 --- a/tests/ha/get-trigger-event-type.test.ts +++ b/tests/ha/get-trigger-event-type.test.ts @@ -36,14 +36,14 @@ describe('getTriggerEventType', () => { }); describe('for event entities', () => { - it('returns "signal" for a transition between two real timestamps', () => { + it('returns "momentary" for a transition between two real timestamps', () => { expect( getTriggerEventType({ entityID: 'event.front_door_doorbell', oldState: createStateEntity({ state: '2026-05-24T12:00:00.000+00:00' }), newState: createStateEntity({ state: '2026-05-24T12:00:05.123+00:00' }), }), - ).toBe('signal'); + ).toBe('momentary'); }); it('returns null when there is no old state', () => { @@ -65,14 +65,14 @@ describe('getTriggerEventType', () => { ).toBeNull(); }); - it('returns "signal" when the old state is unknown (first fire after startup)', () => { + it('returns "momentary" when the old state is unknown (first fire after startup)', () => { expect( getTriggerEventType({ entityID: 'event.front_door_doorbell', oldState: createStateEntity({ state: 'unknown' }), newState: createStateEntity({ state: '2026-05-24T12:00:05.123+00:00' }), }), - ).toBe('signal'); + ).toBe('momentary'); }); it.each([['unavailable'], ['unknown']])( diff --git a/tests/test-utils.ts b/tests/test-utils.ts index ace17f73..251b6b45 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -38,6 +38,7 @@ import { FoldersManager } from '../src/card-controller/folders/manager'; import { FolderQuery } from '../src/card-controller/folders/types'; import { FullscreenManager } from '../src/card-controller/fullscreen/fullscreen-manager'; import { HASSManager } from '../src/card-controller/hass/hass-manager'; +import { EventWatcherSubscriptionInterface } from '../src/card-controller/hass/event-watcher'; import { StateWatcherSubscriptionInterface } from '../src/card-controller/hass/state-watcher'; import { InitializationManager } from '../src/card-controller/initialization-manager'; import { InteractionManager } from '../src/card-controller/interaction-manager'; @@ -124,6 +125,7 @@ export const createInitializedCamera = async ( await camera.initialize({ hass: createHASS(), stateWatcher: mock(), + eventWatcher: mock(), ...(capabilities ? { capabilityOptions: { capabilities } } : {}), }); return camera; @@ -143,6 +145,7 @@ export const createHASS = (states?: HassEntities, user?: CurrentUser): HomeAssis // startup state still represent a "ready" instance. hass.config.state = STATE_RUNNING; hass.connection.subscribeMessage = vi.fn(); + hass.connection.subscribeEvents = vi.fn(); // ha-nunjucks calls sendMessagePromise to fetch label registry; return empty array to prevent crash. hass.connection.sendMessagePromise = vi.fn().mockResolvedValue([]); @@ -282,6 +285,7 @@ export const createStore = ( cameraProps.engine ?? new GenericCameraManagerEngine( mock(), + mock(), mock(), eventCallback, ), @@ -737,6 +741,16 @@ export const callStateWatcherCallback = ( mock.calls[n][0](diff); }; +export const callEventWatcherCallback = ( + eventWatcher: EventWatcherSubscriptionInterface, + data: unknown, + n = 0, +): void => { + const mock = vi.mocked(eventWatcher.subscribe).mock; + expect(mock.calls.length).greaterThan(n); + mock.calls[n][1].callback(data); +}; + /** * Flush resolved promises. */