diff --git a/docs/configuration/cameras/README.md b/docs/configuration/cameras/README.md index 96bd30d9..c65068fc 100644 --- a/docs/configuration/cameras/README.md +++ b/docs/configuration/cameras/README.md @@ -416,10 +416,11 @@ cameras: - 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`. | +| 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. May be a single string or a list of strings to match any of them. | +| `event_data` | | Optional dictionary of key/value pairs the event's payload must contain for this entry to trigger. Mirrors Home Assistant's `event_data` matching exactly: the top-level keys you list must be present in the payload (extra payload keys are ignored), and nested objects are matched the same way -- list only the keys you care about and extra nested keys are ignored. Omit entirely to trigger on every fire of this `event_type`. | +| `context` | | Optional filter on the event's `context` object. Recognised fields: `id`, `user_id`, `parent_id`. Each field may be a single value (equality) or a list (membership). All listed fields must match. | > [!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. diff --git a/docs/configuration/conditions-triggers.md b/docs/configuration/conditions-triggers.md index 6cae23fc..3c64c066 100644 --- a/docs/configuration/conditions-triggers.md +++ b/docs/configuration/conditions-triggers.md @@ -176,6 +176,33 @@ triggers: | `condition` / `trigger` | Must be `display_mode`. | | `display_mode` | Must be `single` or `grid`. | +## `event` + +_Trigger only._ + +Fires when a Home Assistant bus event matching `event_type` is dispatched, with optional payload (`event_data`) and context (`context`) filtering. Field names and semantics mirror HA's [event trigger](https://www.home-assistant.io/docs/automation/trigger/#event-trigger), so YAML copied from HA works without modification. + +```yaml +triggers: + - trigger: event + event_type: zha_event + event_data: + device_ieee: '00:11:22:33:44:55:66:77' + command: press +``` + +| Parameter | Description | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `trigger` | Must be `event`. | +| `event_type` | The Home Assistant event type to subscribe to (e.g. `zha_event`, `deconz_event`, or a custom event fired by one of your automations). May be a single string or a list of strings to match any of them. | +| `event_data` | Optional dictionary of key/value pairs the event's payload must contain for this entry to match. Mirrors Home Assistant's `event_data` matching exactly: the top-level keys you list must be present in the payload (extra payload keys are ignored), and nested objects are matched the same way -- list only the keys you care about and extra nested keys are ignored. Omit entirely to match every fire of `event_type`. | +| `context` | Optional filter on the event's `context` object. Recognised fields: `id`, `user_id`, `parent_id`. Each field may be a single value (equality) or a list (membership). All listed fields must match. | + +The fired event is exposed to action templates as `trigger.event.*`, matching HA's event trigger template surface (`trigger.event.event_type`, `trigger.event.data`, `trigger.event.context`, `trigger.event.origin`, `trigger.event.time_fired`). + +> [!TIP] +> Shared event types like `zha_event` and `deconz_event` fire for **every** device on that integration. Without an `event_data` filter the trigger would fire on every Zigbee/deCONZ device press in your home. Use `event_data` to narrow to the specific device you care about; you can copy values straight out of **Developer tools → Events** in Home Assistant. + ## `expand` Matches whether the card is in "expanded" mode (in a dialog/popup). As a @@ -654,9 +681,9 @@ Several Home Assistant condition types are **not** currently supported: `time`, `zone`, `sun`, `location`, `device`, and `condition: trigger` (matching on the `id` of the trigger that fired). -On the trigger side, only the stock `state`, `numeric_state` and `template` -platforms are supported, alongside the card-specific triggers listed above. -Other Home Assistant trigger platforms -- including `event`, `time`, +On the trigger side, only the stock `event`, `state`, `numeric_state` and +`template` platforms are supported, alongside the card-specific triggers listed +above. Other Home Assistant trigger platforms -- including `time`, `time_pattern`, `sun`, `zone`, `calendar`, `webhook`, `tag`, `device` and `mqtt` -- are **not** supported. @@ -751,6 +778,14 @@ triggers: - 'menu.style' - trigger: display_mode display_mode: single + - trigger: event + event_type: + - zha_event + - deconz_event + event_data: + command: press + context: + user_id: 581fca7fdc014b8b894519cc531f9a04 - trigger: expand expand: true - trigger: fullscreen diff --git a/src/camera-manager/browse-media/engine-browse-media.ts b/src/camera-manager/browse-media/engine-browse-media.ts index 03ef4bea..f3f73758 100644 --- a/src/camera-manager/browse-media/engine-browse-media.ts +++ b/src/camera-manager/browse-media/engine-browse-media.ts @@ -1,5 +1,4 @@ -import { EventWatcherSubscriptionInterface } from '../../card-controller/hass/event-watcher'; -import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher'; +import { HASSManagerReadonlyInterface } from '../../card-controller/hass/types'; import { CameraConfig } from '../../config/schema/cameras'; import { BROWSE_MEDIA_CACHE_SECONDS } from '../../ha/browse-media/types'; import { BrowseMediaWalker } from '../../ha/browse-media/walker'; @@ -37,14 +36,13 @@ export class BrowseMediaCameraManagerEngine public constructor( entityRegistryManager: EntityRegistryManager, - stateWatcher: StateWatcherSubscriptionInterface, - eventWatcher: EventWatcherSubscriptionInterface, + hassManager: HASSManagerReadonlyInterface, browseMediaManager: BrowseMediaWalker, resolvedMediaCache: ResolvedMediaCache, requestCache: CameraManagerRequestCache, eventCallback?: CameraEventCallback, ) { - super(stateWatcher, eventWatcher, entityRegistryManager, eventCallback); + super(hassManager, 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 24b6dbb8..d8d8fa48 100644 --- a/src/camera-manager/camera.ts +++ b/src/camera-manager/camera.ts @@ -1,20 +1,19 @@ 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 { EventSubscriptionRequest } from '../card-controller/hass/event-watcher'; +import { HASSManagerReadonlyInterface } from '../card-controller/hass/types'; import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz'; -import { CameraConfig, TriggerEvent } from '../config/schema/cameras'; +import { CameraConfig } from '../config/schema/cameras'; +import { HAEvent } from '../config/schema/common/ha-event'; import { EnabledProxyConfig, resolveProxyConfig } from '../config/schema/common/proxy'; import { computeDomain } from '../ha/compute-domain'; -import { matchesEventData } from '../ha/event-data-match'; +import { matchesEventContext, matchesEventData } from '../ha/event-match'; import { getTriggerEventType } from '../ha/get-trigger-event-type'; import { Entity, EntityRegistryManager } from '../ha/registry/entity/types'; import { HassStateDifference, HomeAssistant } from '../ha/types'; import { localize } from '../localize/localize'; import { CapabilitiesRaw, CapabilityKey, Endpoint } from '../types'; +import { arrayify } from '../utils/basic'; import { liveProviderSupports2WayAudio } from '../utils/live-provider'; import { Capabilities } from './capabilities'; import { CameraManagerEngine } from './engine'; @@ -43,9 +42,7 @@ interface CapabilityOptions { } export interface CameraInitializationOptions { - hass: HomeAssistant; - stateWatcher: StateWatcherSubscriptionInterface; - eventWatcher: EventWatcherSubscriptionInterface; + hassManager: HASSManagerReadonlyInterface; capabilityOptions?: CapabilityOptions; entityRegistryManager?: EntityRegistryManager; } @@ -58,7 +55,6 @@ export class Camera { protected _capabilities?: Capabilities; protected _eventCallback?: CameraEventCallback; protected _destroyCallbacks: DestroyCallback[] = []; - protected _destroyed = false; protected _entity: Entity | null = null; constructor( @@ -80,63 +76,57 @@ export class Camera { } async initialize(options: CameraInitializationOptions): Promise { - this._entity = await this._resolveEntity(options); - await this._initialize(options); + // Freeze a single HASS snapshot for the whole (async, multi-step) + // initialization so every step observes a consistent entity world; live + // subscriptions below still use the manager's current watchers. + const hass = options.hassManager.getHASS(); + if (!hass) { + return this; + } + + this._entity = await this._resolveEntity(hass, options); + await this._initialize(hass, options); this._capabilities = options.capabilityOptions?.capabilities ?? this._capabilities ?? - (await this._buildCapabilities(options)); + (await this._buildCapabilities(hass, options)); if (this._capabilities.has('trigger')) { - await this._getTriggerEntities(options); + await this._getTriggerEntities(hass, options); this._config.triggers.entities = uniq(this._config.triggers.entities); // Subscribe to state based triggers (sync; no race with destroy). - options.stateWatcher.subscribe( - this._stateChangeHandler, - this._config.triggers.entities, - ); - this._onDestroy(() => options.stateWatcher.unsubscribe(this._stateChangeHandler)); + const stateWatcher = options.hassManager.getStateWatcher(); + stateWatcher.subscribe(this._stateChangeHandler, this._config.triggers.entities); + this._onDestroy(() => stateWatcher.unsubscribe(this._stateChangeHandler)); - // Subscribe to event based triggers. + // Subscribe to event based triggers. List-form `event_type` expands into + // one subscription per type sharing the same data/context matcher. + const eventWatcher = options.hassManager.getEventWatcher(); for (const event of this._config.triggers.events) { - const request = this._buildEventSubscriptionRequest(event); - await this._setupSubscription( - () => options.eventWatcher.subscribe(options.hass, request), - () => options.eventWatcher.unsubscribe(request), - ); + for (const request of this._buildEventSubscriptionRequests(event)) { + eventWatcher.subscribe(request); + this._onDestroy(() => eventWatcher.unsubscribe(request)); + } } } return this; } - /** - * Wire up an async subscription with its teardown. Registers the unsubscribe - * callback synchronously before awaiting subscribe, so a destroy during the - * await reliably triggers cleanup; short-circuits if destroy has already - * run, so the cleanup callback can't fire (and enqueue an unsubscribe) - * before the subscribe runs. - */ - protected async _setupSubscription( - subscribe: () => Promise, - unsubscribe: () => void | Promise, - ): Promise { - if (this._destroyed) { - return; - } - this._onDestroy(unsubscribe); - await subscribe(); - } - - 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 _buildEventSubscriptionRequests(event: HAEvent): EventSubscriptionRequest[] { + const dataFilter = event.event_data; + const contextFilter = event.context; + return uniq(arrayify(event.event_type)).map((eventType) => ({ + event_type: eventType, + ...((dataFilter || contextFilter) && { + matcher: (evt) => + (!dataFilter || matchesEventData(dataFilter, evt.data)) && + (!contextFilter || matchesEventContext(contextFilter, evt.context)), + }), + callback: () => this._momentaryEventHandler(eventType), + })); } private _momentaryEventHandler(eventType: string): void { @@ -148,13 +138,14 @@ export class Camera { } private async _resolveEntity( + hass: HomeAssistant, options: CameraInitializationOptions, ): Promise { const cameraEntityID = getCameraEntityFromConfig(this._config); if (!cameraEntityID || !options.entityRegistryManager) { return null; } - return await options.entityRegistryManager.getEntity(options.hass, cameraEntityID); + return await options.entityRegistryManager.getEntity(hass, cameraEntityID); } /** @@ -162,12 +153,14 @@ export class Camera { * to add engine-specific discovery; call `super` to keep the base discoveries. */ protected async _getTriggerEntities( + hass: HomeAssistant, options: CameraInitializationOptions, ): Promise { - await this._getDoorbellEntities(options); + await this._getDoorbellEntities(hass, options); } private async _getDoorbellEntities( + hass: HomeAssistant, options: CameraInitializationOptions, ): Promise { if ( @@ -183,7 +176,7 @@ export class Camera { // narrow by `device_id` + domain first and filter by device_class against // `hass.states` second. const candidates = await options.entityRegistryManager.getMatchingEntities( - options.hass, + hass, (ent) => ent.device_id === deviceID && !ent.disabled_by && @@ -192,8 +185,7 @@ export class Camera { const doorbells = candidates .filter( - (ent) => - options.hass.states[ent.entity_id]?.attributes?.device_class === 'doorbell', + (ent) => hass.states[ent.entity_id]?.attributes?.device_class === 'doorbell', ) .map((ent) => ent.entity_id); @@ -204,16 +196,19 @@ export class Camera { * Subclass initialization hook. Override for async initialization work. */ protected async _initialize( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _hass: HomeAssistant, // eslint-disable-next-line @typescript-eslint/no-unused-vars _options: CameraInitializationOptions, ): Promise {} protected async _buildCapabilities( + hass: HomeAssistant, options: CameraInitializationOptions, ): Promise { - const rawCapabilities = await this._getRawCapabilities(options); + const rawCapabilities = await this._getRawCapabilities(hass, options); const config = this.getConfig(); - const has2WayAudio = await this._has2WayAudioCapability(options.hass); + const has2WayAudio = await this._has2WayAudioCapability(hass); return new Capabilities( { @@ -254,6 +249,7 @@ export class Camera { * and call super._getRawCapabilities() to extend defaults. */ protected async _getRawCapabilities( + _hass: HomeAssistant, options: CameraInitializationOptions, ): Promise { return { @@ -267,7 +263,6 @@ export class Camera { } public async destroy(): Promise { - this._destroyed = true; const callbacks = this._destroyCallbacks; this._destroyCallbacks = []; await Promise.all(callbacks.map((callback) => callback())); diff --git a/src/camera-manager/engine-factory.ts b/src/camera-manager/engine-factory.ts index 1b744d73..8289ebb3 100644 --- a/src/camera-manager/engine-factory.ts +++ b/src/camera-manager/engine-factory.ts @@ -1,5 +1,4 @@ -import { EventWatcherSubscriptionInterface } from '../card-controller/hass/event-watcher'; -import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher'; +import { HASSManagerReadonlyInterface } from '../card-controller/hass/types'; import { CameraConfig } from '../config/schema/cameras'; import { BrowseMediaWalker } from '../ha/browse-media/walker'; import { DeviceRegistryManager } from '../ha/registry/device'; @@ -13,8 +12,7 @@ import { CameraEventCallback, CameraManagerRequestCache, Engine } from './types' import { getCameraEntityFromConfig } from './utils/camera-entity-from-config'; interface CameraManagerEngineFactoryOptions { - stateWatcher: StateWatcherSubscriptionInterface; - eventWatcher: EventWatcherSubscriptionInterface; + hassManager: HASSManagerReadonlyInterface; resolvedMediaCache: ResolvedMediaCache; eventCallback?: CameraEventCallback; } @@ -40,8 +38,7 @@ export class CameraManagerEngineFactory { case Engine.Generic: const { GenericCameraManagerEngine } = await import('./generic/engine-generic'); cameraManagerEngine = new GenericCameraManagerEngine( - options.stateWatcher, - options.eventWatcher, + options.hassManager, this._entityRegistryManager, options.eventCallback, ); @@ -50,8 +47,7 @@ export class CameraManagerEngineFactory { const { FrigateCameraManagerEngine } = await import('./frigate/engine-frigate'); cameraManagerEngine = new FrigateCameraManagerEngine( this._entityRegistryManager, - options.stateWatcher, - options.eventWatcher, + options.hassManager, new RecordingSegmentsCache(), new CameraManagerRequestCache(), options.eventCallback, @@ -63,8 +59,7 @@ export class CameraManagerEngineFactory { ); cameraManagerEngine = new MotionEyeCameraManagerEngine( this._entityRegistryManager, - options.stateWatcher, - options.eventWatcher, + options.hassManager, new BrowseMediaWalker(), options.resolvedMediaCache, new CameraManagerRequestCache(), @@ -76,8 +71,7 @@ export class CameraManagerEngineFactory { cameraManagerEngine = new ReolinkCameraManagerEngine( this._entityRegistryManager, this._deviceRegistryManager, - options.stateWatcher, - options.eventWatcher, + options.hassManager, new BrowseMediaWalker(), options.resolvedMediaCache, new CameraManagerRequestCache(), @@ -88,8 +82,7 @@ export class CameraManagerEngineFactory { const { TPLinkCameraManagerEngine } = await import('./tplink/engine-tplink'); cameraManagerEngine = new TPLinkCameraManagerEngine( this._entityRegistryManager, - options.stateWatcher, - options.eventWatcher, + options.hassManager, options.eventCallback, ); break; diff --git a/src/camera-manager/engine.ts b/src/camera-manager/engine.ts index 69a83b14..08791329 100644 --- a/src/camera-manager/engine.ts +++ b/src/camera-manager/engine.ts @@ -34,7 +34,7 @@ export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; export interface CameraManagerEngine { getEngineType(): Engine; - createCamera(hass: HomeAssistant, cameraConfig: CameraConfig): Promise; + createCamera(cameraConfig: CameraConfig): Promise; /** * Get default query parameters for a camera based on its configuration. diff --git a/src/camera-manager/entity-camera.ts b/src/camera-manager/entity-camera.ts index 6e904f23..eb33a429 100644 --- a/src/camera-manager/entity-camera.ts +++ b/src/camera-manager/entity-camera.ts @@ -1,3 +1,4 @@ +import { HomeAssistant } from '../ha/types'; import { Camera, CameraInitializationOptions } from './camera'; import { CameraNoEntityError } from './error'; @@ -9,11 +10,12 @@ import { CameraNoEntityError } from './error'; */ export class EntityCamera extends Camera { protected override async _initialize( + hass: HomeAssistant, options: CameraInitializationOptions, ): Promise { if (!this._entity) { throw new CameraNoEntityError(this.getConfig()); } - await super._initialize(options); + await super._initialize(hass, options); } } diff --git a/src/camera-manager/frigate/camera.ts b/src/camera-manager/frigate/camera.ts index 67fd963a..8777b682 100644 --- a/src/camera-manager/frigate/camera.ts +++ b/src/camera-manager/frigate/camera.ts @@ -43,17 +43,33 @@ export const isBirdseye = (cameraConfig: CameraConfig): boolean => { }; export class FrigateCamera extends Camera { + // Short-circuits subscription when destroy() was invoked while base + // initialization was still awaiting. Set BEFORE awaiting `super.destroy()` so + // an in-flight initialize() sees the flip immediately. + private _destroyed = false; + public async initialize(options: FrigateCameraInitializationOptions): Promise { await super.initialize(options); + // A destroy() during the await above means the camera is being torn down; + // it must not register live subscriptions afterward. + if (this._destroyed) { + return this; + } + if (this._capabilities?.has('trigger')) { - await this._subscribeToEvents(options.hass, options.frigateEventWatcher); - await this._subscribeToReviews(options.hass, options.frigateReviewWatcher); + this._subscribeToEvents(options.frigateEventWatcher); + this._subscribeToReviews(options.frigateReviewWatcher); } return this; } + public override async destroy(): Promise { + this._destroyed = true; + await super.destroy(); + } + public async executePTZAction( executor: ActionsExecutor, action: PTZAction, @@ -104,9 +120,7 @@ export class FrigateCamera extends Camera { return true; } - protected override async _initialize( - options: FrigateCameraInitializationOptions, - ): Promise { + protected override async _initialize(hass: HomeAssistant): Promise { const config = this.getConfig(); const hasCameraName = !!config.frigate?.camera_name; const cameraEntity = getCameraEntityFromConfig(config); @@ -126,7 +140,7 @@ export class FrigateCamera extends Camera { } if (!this._config.frigate.client_id) { - const stateEntity = cameraEntity ? options.hass.states[cameraEntity] : undefined; + const stateEntity = cameraEntity ? hass.states[cameraEntity] : undefined; const clientID = stateEntity?.attributes?.client_id; if (typeof clientID === 'string' && clientID) { this._config.frigate.client_id = clientID; @@ -137,13 +151,15 @@ export class FrigateCamera extends Camera { } protected override async _getTriggerEntities( + hass: HomeAssistant, options: FrigateCameraInitializationOptions, ): Promise { - await this._getFrigateMotionAndOccupancyEntities(options); - await super._getTriggerEntities(options); + await this._getFrigateMotionAndOccupancyEntities(hass, options); + await super._getTriggerEntities(hass, options); } private async _getFrigateMotionAndOccupancyEntities( + hass: HomeAssistant, options: FrigateCameraInitializationOptions, ): Promise { const config = this.getConfig(); @@ -162,7 +178,7 @@ export class FrigateCamera extends Camera { // searching via unique_id ensures this still works if the user renames // the entity_id. const binarySensorEntities = await options.entityRegistryManager.getMatchingEntities( - options.hass, + hass, (ent) => ent.config_entry_id === this._entity?.config_entry_id && !ent.disabled_by && @@ -189,12 +205,13 @@ export class FrigateCamera extends Camera { } protected async _getRawCapabilities( + hass: HomeAssistant, options: FrigateCameraInitializationOptions, ): Promise { - const base = await super._getRawCapabilities(options); + const base = await super._getRawCapabilities(hass, options); const config = this.getConfig(); - const frigatePTZ = await this._getPTZCapabilities(options.hass, config); + const frigatePTZ = await this._getPTZCapabilities(hass, config); const configPTZ = getPTZCapabilitiesFromCameraConfig(config); const combinedPTZ: PTZCapabilities | null = configPTZ || frigatePTZ ? { ...frigatePTZ, ...configPTZ } : null; @@ -451,10 +468,9 @@ export class FrigateCamera extends Camera { return null; } - private async _subscribeToEvents( - hass: HomeAssistant, + private _subscribeToEvents( frigateEventWatcher: FrigateWatcherSubscriptionInterface, - ): Promise { + ): void { const config = this.getConfig(); if ( !config.triggers.media_events.length || @@ -473,10 +489,8 @@ export class FrigateCamera extends Camera { event.after.camera === config.frigate.camera_name, }; - await this._setupSubscription( - () => frigateEventWatcher.subscribe(hass, request), - () => frigateEventWatcher.unsubscribe(request), - ); + frigateEventWatcher.subscribe(request); + this._onDestroy(() => frigateEventWatcher.unsubscribe(request)); } private _frigateEventHandler = (ev: FrigateEventChange): void => { @@ -494,23 +508,33 @@ export class FrigateCamera extends Camera { return; } - if ( - (config.frigate.zones?.length && - !config.frigate.zones.some((zone) => ev.after.current_zones.includes(zone))) || - (config.frigate.labels?.length && !config.frigate.labels.includes(ev.after.label)) - ) { - return; - } - const mediaEventsToTriggerOn = config.triggers.media_events; - if ( - !( - mediaEventsToTriggerOn.includes('events') || - (mediaEventsToTriggerOn.includes('snapshots') && snapshotChange) || - (mediaEventsToTriggerOn.includes('clips') && clipChange) - ) - ) { - return; + + // The zone/label/media checks decide when to START a trigger, so they only + // apply to 'new'/'update'. An 'end' always passes through: it ends whatever + // trigger an earlier event with the same id started, and by 'end' the + // object may have left the zone or the media flag may differ -- the trigger + // must still clear. (The trigger manager ignores an 'end' for an id that + // never triggered, so a pass-through 'end' is harmless.) + if (ev.type !== 'end') { + if ( + (config.frigate.zones?.length && + !config.frigate.zones.some((zone) => ev.after.current_zones.includes(zone))) || + (config.frigate.labels?.length && + !config.frigate.labels.includes(ev.after.label)) + ) { + return; + } + + if ( + !( + mediaEventsToTriggerOn.includes('events') || + (mediaEventsToTriggerOn.includes('snapshots') && snapshotChange) || + (mediaEventsToTriggerOn.includes('clips') && clipChange) + ) + ) { + return; + } } this._eventCallback?.({ @@ -525,10 +549,9 @@ export class FrigateCamera extends Camera { }); }; - private async _subscribeToReviews( - hass: HomeAssistant, + private _subscribeToReviews( frigateReviewWatcher: FrigateWatcherSubscriptionInterface, - ): Promise { + ): void { const config = this.getConfig(); const reviewConfig = config.triggers.reviews; @@ -550,10 +573,8 @@ export class FrigateCamera extends Camera { review.after.camera === config.frigate.camera_name, }; - await this._setupSubscription( - () => frigateReviewWatcher.subscribe(hass, request), - () => frigateReviewWatcher.unsubscribe(request), - ); + frigateReviewWatcher.subscribe(request); + this._onDestroy(() => frigateReviewWatcher.unsubscribe(request)); } private _frigateReviewHandler = (review: FrigateReviewChange): void => { diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index 5e7b2818..43eb657b 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -1,7 +1,6 @@ import { add, endOfHour, fromUnixTime, startOfHour } from 'date-fns'; import { isEqual, orderBy, throttle, uniqWith } from 'lodash-es'; -import { EventWatcherSubscriptionInterface } from '../../card-controller/hass/event-watcher'; -import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher'; +import { HASSManagerReadonlyInterface } from '../../card-controller/hass/types'; import { CameraConfig } from '../../config/schema/cameras'; import { getEntityTitle } from '../../ha/get-entity-title'; import { EntityRegistryManager } from '../../ha/registry/entity/types'; @@ -136,16 +135,15 @@ export class FrigateCameraManagerEngine constructor( entityRegistryManager: EntityRegistryManager, - stateWatcher: StateWatcherSubscriptionInterface, - eventWatcher: EventWatcherSubscriptionInterface, + hassManager: HASSManagerReadonlyInterface, recordingSegmentsCache: RecordingSegmentsCache, requestCache: CameraManagerRequestCache, eventCallback?: CameraEventCallback, ) { - super(stateWatcher, eventWatcher, entityRegistryManager, eventCallback); + super(hassManager, entityRegistryManager, eventCallback); this._entityRegistryManager = entityRegistryManager; - this._frigateEventWatcher = new FrigateEventWatcher(); - this._frigateReviewWatcher = new FrigateReviewWatcher(); + this._frigateEventWatcher = new FrigateEventWatcher(hassManager); + this._frigateReviewWatcher = new FrigateReviewWatcher(hassManager); this._recordingSegmentsCache = recordingSegmentsCache; this._requestCache = requestCache; } @@ -154,18 +152,13 @@ export class FrigateCameraManagerEngine return Engine.Frigate; } - public async createCamera( - hass: HomeAssistant, - cameraConfig: CameraConfig, - ): Promise { + public async createCamera(cameraConfig: CameraConfig): Promise { const camera = new FrigateCamera(cameraConfig, this, { eventCallback: this._eventCallback, }); return await camera.initialize({ - hass, + hassManager: this._hassManager, entityRegistryManager: this._entityRegistryManager, - stateWatcher: this._stateWatcher, - eventWatcher: this._eventWatcher, frigateEventWatcher: this._frigateEventWatcher, frigateReviewWatcher: this._frigateReviewWatcher, }); diff --git a/src/camera-manager/frigate/watcher.ts b/src/camera-manager/frigate/watcher.ts index e957eea9..f9e91a18 100644 --- a/src/camera-manager/frigate/watcher.ts +++ b/src/camera-manager/frigate/watcher.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { HomeAssistant } from '../../ha/types'; -import { KeyedSubscriptionManager } from '../../utils/concurrency/keyed-subscription-manager'; +import { HASSConnectionSubscriptionManager } from '../../ha/connection/subscription-manager'; +import { HASSSource } from '../../ha/source'; import { FrigateEventChange, FrigateReviewChange, @@ -17,43 +17,48 @@ export interface FrigateWatcherRequest { // Generic subscription interface export interface FrigateWatcherSubscriptionInterface { - subscribe(hass: HomeAssistant, request: FrigateWatcherRequest): Promise; - unsubscribe(request: FrigateWatcherRequest): Promise; + subscribe(request: FrigateWatcherRequest): void; + unsubscribe(request: FrigateWatcherRequest): void; } /** - * Base class for Frigate WebSocket watchers. Counted per `instanceID`: the - * first subscriber for an instance opens the WS subscription, the last to - * unsubscribe tears it down. Each message is parsed, schema-validated, and - * fanned out to every registered request whose `instanceID` matches and whose - * `matcher` accepts the payload. + * Base class for Frigate WebSocket watchers. Thin wrapper over + * `HASSConnectionSubscriptionManager`: keys by `instanceID`, parses and + * schema-validates each message, then fans out to every registered request + * whose `instanceID` matches and whose optional `matcher` accepts the payload. */ abstract class FrigateWatcher implements FrigateWatcherSubscriptionInterface { protected abstract _type: string; protected abstract _schema: z.ZodType; - private _subscriptions = new KeyedSubscriptionManager< - string, - FrigateWatcherRequest - >((request) => request.instanceID); + private _manager: HASSConnectionSubscriptionManager>; - public async subscribe( - hass: HomeAssistant, - request: FrigateWatcherRequest, - ): Promise { - await this._subscriptions.subscribe(request, () => - hass.connection.subscribeMessage( - (data) => this._receiveHandler(request.instanceID, data), + constructor(source: HASSSource) { + this._manager = new HASSConnectionSubscriptionManager( + (request) => request.instanceID, + source, + ); + } + + public subscribe(request: FrigateWatcherRequest): void { + this._manager.subscribe(request, (connection, liveness) => + connection.subscribeMessage( + (data) => { + if (!liveness.isConnected()) { + return; + } + this._receive(request.instanceID, data); + }, { type: this._type, instance_id: request.instanceID }, ), ); } - public async unsubscribe(request: FrigateWatcherRequest): Promise { - await this._subscriptions.unsubscribe(request); + public unsubscribe(request: FrigateWatcherRequest): void { + this._manager.unsubscribe(request); } - protected _receiveHandler(instanceID: string, data: string): void { + private _receive(instanceID: string, data: string): void { let json: unknown; try { json = JSON.parse(data); @@ -69,7 +74,7 @@ abstract class FrigateWatcher implements FrigateWatcherSubscriptionInterface< return; } - for (const request of this._subscriptions.getRequestsForKey(instanceID)) { + for (const request of this._manager.getRequestsForKey(instanceID)) { if (!request.matcher || request.matcher(parseResult.data)) { request.callback(parseResult.data); } diff --git a/src/camera-manager/generic/engine-generic.ts b/src/camera-manager/generic/engine-generic.ts index 2acd5d38..70b873fd 100644 --- a/src/camera-manager/generic/engine-generic.ts +++ b/src/camera-manager/generic/engine-generic.ts @@ -1,7 +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 { HASSManagerReadonlyInterface } from '../../card-controller/hass/types'; import { CameraConfig } from '../../config/schema/cameras'; import { getEntityTitle } from '../../ha/get-entity-title'; import { EntityRegistryManager } from '../../ha/registry/entity/types'; @@ -41,18 +40,15 @@ import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; export class GenericCameraManagerEngine implements CameraManagerEngine { protected _eventCallback?: CameraEventCallback; - protected _stateWatcher: StateWatcherSubscriptionInterface; - protected _eventWatcher: EventWatcherSubscriptionInterface; + protected _hassManager: HASSManagerReadonlyInterface; protected _entityRegistryManager?: EntityRegistryManager; constructor( - stateWatcher: StateWatcherSubscriptionInterface, - eventWatcher: EventWatcherSubscriptionInterface, + hassManager: HASSManagerReadonlyInterface, entityRegistryManager?: EntityRegistryManager, eventCallback?: CameraEventCallback, ) { - this._stateWatcher = stateWatcher; - this._eventWatcher = eventWatcher; + this._hassManager = hassManager; this._entityRegistryManager = entityRegistryManager; this._eventCallback = eventCallback; } @@ -61,16 +57,11 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { return Engine.Generic; } - public async createCamera( - hass: HomeAssistant, - cameraConfig: CameraConfig, - ): Promise { + public async createCamera(cameraConfig: CameraConfig): Promise { return await new Camera(cameraConfig, this, { eventCallback: this._eventCallback, }).initialize({ - hass, - stateWatcher: this._stateWatcher, - eventWatcher: this._eventWatcher, + hassManager: this._hassManager, entityRegistryManager: this._entityRegistryManager, capabilityOptions: { raw: { diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index cfc87b70..3fe78be1 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -205,8 +205,7 @@ export class CameraManager { (await this._engineFactory.createEngine(engineType, { eventCallback: (ev) => this._api.getCameraTriggersManager().handleCameraEvent(ev), - stateWatcher: this._api.getHASSManager().getStateWatcher(), - eventWatcher: this._api.getHASSManager().getEventWatcher(), + hassManager: this._api.getHASSManager(), resolvedMediaCache: this._api.getResolvedMediaCache(), })) : null; @@ -253,7 +252,7 @@ export class CameraManager { // Configuration is initialized in parallel. const cameras = await allPromises( engineByConfig.entries(), - async ([cameraConfig, engine]) => await engine.createCamera(hass, cameraConfig), + async ([cameraConfig, engine]) => await engine.createCamera(cameraConfig), ); const destroyCameras = async () => { diff --git a/src/camera-manager/motioneye/camera.ts b/src/camera-manager/motioneye/camera.ts index a1daf5f1..6929a41b 100644 --- a/src/camera-manager/motioneye/camera.ts +++ b/src/camera-manager/motioneye/camera.ts @@ -1,3 +1,4 @@ +import { HomeAssistant } from '../../ha/types'; import { CapabilitiesRaw, Endpoint } from '../../types'; import { CameraInitializationOptions } from '../camera'; import { EntityCamera } from '../entity-camera'; @@ -20,12 +21,13 @@ export class MotionEyeCamera extends EntityCamera { } protected async _getRawCapabilities( + hass: HomeAssistant, options: CameraInitializationOptions, ): Promise { const ptz = getPTZCapabilitiesFromCameraConfig(this.getConfig()); return { - ...(await super._getRawCapabilities(options)), + ...(await super._getRawCapabilities(hass, options)), clips: true, snapshots: true, ...(ptz && { ptz }), diff --git a/src/camera-manager/motioneye/engine-motioneye.ts b/src/camera-manager/motioneye/engine-motioneye.ts index 8f010f54..379f96f0 100644 --- a/src/camera-manager/motioneye/engine-motioneye.ts +++ b/src/camera-manager/motioneye/engine-motioneye.ts @@ -67,18 +67,13 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine return Engine.MotionEye; } - public async createCamera( - hass: HomeAssistant, - cameraConfig: CameraConfig, - ): Promise { + public async createCamera(cameraConfig: CameraConfig): Promise { const camera = new MotionEyeCamera(cameraConfig, this, { eventCallback: this._eventCallback, }); return await camera.initialize({ + hassManager: this._hassManager, entityRegistryManager: this._entityRegistryManager, - hass, - stateWatcher: this._stateWatcher, - eventWatcher: this._eventWatcher, }); } diff --git a/src/camera-manager/reolink/camera.ts b/src/camera-manager/reolink/camera.ts index ba7f0e93..22e6b62a 100644 --- a/src/camera-manager/reolink/camera.ts +++ b/src/camera-manager/reolink/camera.ts @@ -172,14 +172,12 @@ export class ReolinkCamera extends EntityCamera { } protected async _initialize( + hass: HomeAssistant, options: ReolinkCameraInitializationOptions, ): Promise { - await super._initialize(options); - await this._initializeChannel(options.hass, options.deviceRegistryManager); - this._ptzEntities = await this._getPTZEntities( - options.hass, - options.entityRegistryManager, - ); + await super._initialize(hass, options); + await this._initializeChannel(hass, options.deviceRegistryManager); + this._ptzEntities = await this._getPTZEntities(hass, options.entityRegistryManager); } // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -188,18 +186,19 @@ export class ReolinkCamera extends EntityCamera { } protected async _getRawCapabilities( + hass: HomeAssistant, options: ReolinkCameraInitializationOptions, ): Promise { const configPTZ = getPTZCapabilitiesFromCameraConfig(this.getConfig()); const reolinkPTZ = this._ptzEntities - ? this._entitiesToCapabilities(options.hass, this._ptzEntities) + ? this._entitiesToCapabilities(hass, this._ptzEntities) : null; const combinedPTZ: PTZCapabilities | null = configPTZ || reolinkPTZ ? { ...reolinkPTZ, ...configPTZ } : null; return { - ...(await super._getRawCapabilities(options)), + ...(await super._getRawCapabilities(hass, options)), clips: true, ...(combinedPTZ && { ptz: combinedPTZ }), }; diff --git a/src/camera-manager/reolink/engine-reolink.ts b/src/camera-manager/reolink/engine-reolink.ts index e7fa800a..61e5cbec 100644 --- a/src/camera-manager/reolink/engine-reolink.ts +++ b/src/camera-manager/reolink/engine-reolink.ts @@ -1,7 +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 { HASSManagerReadonlyInterface } from '../../card-controller/hass/types'; import { CameraConfig } from '../../config/schema/cameras'; import { getViewMediaFromBrowseMediaArray } from '../../ha/browse-media/browse-media-to-view-media'; import { sortMostRecentFirst } from '../../ha/browse-media/sort'; @@ -61,8 +60,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine { public constructor( entityRegistryManager: EntityRegistryManager, deviceRegistryManager: DeviceRegistryManager, - stateWatcher: StateWatcherSubscriptionInterface, - eventWatcher: EventWatcherSubscriptionInterface, + hassManager: HASSManagerReadonlyInterface, browseMediaManager: BrowseMediaWalker, resolvedMediaCache: ResolvedMediaCache, requestCache: CameraManagerRequestCache, @@ -70,8 +68,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine { ) { super( entityRegistryManager, - stateWatcher, - eventWatcher, + hassManager, browseMediaManager, resolvedMediaCache, requestCache, @@ -167,19 +164,14 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine { : null; } - public async createCamera( - hass: HomeAssistant, - cameraConfig: CameraConfig, - ): Promise { + public async createCamera(cameraConfig: CameraConfig): Promise { const camera = new ReolinkCamera(cameraConfig, this, { eventCallback: this._eventCallback, }); return await camera.initialize({ + hassManager: this._hassManager, entityRegistryManager: this._entityRegistryManager, deviceRegistryManager: this._deviceRegistryManager, - hass, - stateWatcher: this._stateWatcher, - eventWatcher: this._eventWatcher, }); } diff --git a/src/camera-manager/tplink/camera.ts b/src/camera-manager/tplink/camera.ts index 24e258e9..1c24136d 100644 --- a/src/camera-manager/tplink/camera.ts +++ b/src/camera-manager/tplink/camera.ts @@ -23,16 +23,15 @@ export class TPLinkCamera extends EntityCamera { private _ptzEntities: PTZEntities | null = null; protected async _initialize( + hass: HomeAssistant, options: TPLinkCameraInitializationOptions, ): Promise { - await super._initialize(options); - this._ptzEntities = await this._getPTZEntities( - options.hass, - options.entityRegistryManager, - ); + await super._initialize(hass, options); + this._ptzEntities = await this._getPTZEntities(hass, options.entityRegistryManager); } protected async _getRawCapabilities( + hass: HomeAssistant, options: TPLinkCameraInitializationOptions, ): Promise { const configPTZ = getPTZCapabilitiesFromCameraConfig(this.getConfig()); @@ -44,7 +43,7 @@ export class TPLinkCamera extends EntityCamera { configPTZ || tplinkPTZ ? { ...tplinkPTZ, ...configPTZ } : null; return { - ...(await super._getRawCapabilities(options)), + ...(await super._getRawCapabilities(hass, options)), ...(combinedPTZ && { ptz: combinedPTZ }), }; } diff --git a/src/camera-manager/tplink/engine-tplink.ts b/src/camera-manager/tplink/engine-tplink.ts index c8f860e4..91e32bde 100644 --- a/src/camera-manager/tplink/engine-tplink.ts +++ b/src/camera-manager/tplink/engine-tplink.ts @@ -1,5 +1,4 @@ -import { EventWatcherSubscriptionInterface } from '../../card-controller/hass/event-watcher'; -import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher'; +import { HASSManagerReadonlyInterface } from '../../card-controller/hass/types'; import { CameraConfig } from '../../config/schema/cameras'; import { EntityRegistryManager } from '../../ha/registry/entity/types'; import { HomeAssistant } from '../../ha/types'; @@ -11,11 +10,10 @@ import { TPLinkCamera } from './camera'; export class TPLinkCameraManagerEngine extends GenericCameraManagerEngine { constructor( entityRegistryManager: EntityRegistryManager, - stateWatcher: StateWatcherSubscriptionInterface, - eventWatcher: EventWatcherSubscriptionInterface, + hassManager: HASSManagerReadonlyInterface, eventCallback?: CameraEventCallback, ) { - super(stateWatcher, eventWatcher, entityRegistryManager, eventCallback); + super(hassManager, entityRegistryManager, eventCallback); this._entityRegistryManager = entityRegistryManager; } @@ -23,18 +21,13 @@ export class TPLinkCameraManagerEngine extends GenericCameraManagerEngine { return Engine.TPLink; } - public async createCamera( - hass: HomeAssistant, - cameraConfig: CameraConfig, - ): Promise { + public async createCamera(cameraConfig: CameraConfig): Promise { const camera = new TPLinkCamera(cameraConfig, this, { eventCallback: this._eventCallback, }); return await camera.initialize({ + hassManager: this._hassManager, entityRegistryManager: this._entityRegistryManager, - hass, - stateWatcher: this._stateWatcher, - eventWatcher: this._eventWatcher, }); } diff --git a/src/card-controller/automations-manager.ts b/src/card-controller/automations-manager.ts index 38754d6f..0de46a7d 100644 --- a/src/card-controller/automations-manager.ts +++ b/src/card-controller/automations-manager.ts @@ -37,6 +37,7 @@ export class AutomationsManager { const triggers = new TriggersManager( automation.triggers, this._api.getConditionStateManager(), + this._api.getHASSManager(), ); // The ongoing `conditions:` block is pull-evaluated at trigger time, so diff --git a/src/card-controller/card-element-manager.ts b/src/card-controller/card-element-manager.ts index 53d07bd9..ac93c62c 100644 --- a/src/card-controller/card-element-manager.ts +++ b/src/card-controller/card-element-manager.ts @@ -44,6 +44,10 @@ export class CardElementManager { return this._element; } + public isConnected(): boolean { + return this._element.isConnected; + } + public scrollReset(): void { this._scrollCallback(); } @@ -160,7 +164,8 @@ export class CardElementManager { this._api.getIssueManager().resume(); - // Make sure reconnections call the initialization code. + // A reconnected card (e.g. after HA rebuilt it on restart) won't re-render + // on its own; request one so it re-initializes and shows current state. this._element.requestUpdate(); } diff --git a/src/card-controller/controller.ts b/src/card-controller/controller.ts index 2f78c67d..796adeee 100644 --- a/src/card-controller/controller.ts +++ b/src/card-controller/controller.ts @@ -122,7 +122,7 @@ export class CardController private _expandManager = new ExpandManager(this); private _foldersManager = new FoldersManager(this); private _fullscreenManager = new FullscreenManager(this); - private _hassManager = new HASSManager(this); + private _hassManager: HASSManager; private _initializationManager = new InitializationManager(this); private _interactionManager = new InteractionManager(this); private _keyboardStateManager = new KeyboardStateManager(this); @@ -133,7 +133,7 @@ export class CardController private _microphoneManager = new MicrophoneManager(this); private _notificationManager = new NotificationManager(this); private _pipManager = new PIPManager(this); - private _issueManager = createIssueManager(this); + private _issueManager: IssueManager; private _queryStringManager = new QueryStringManager(this); private _statusBarItemManager = new StatusBarItemManager(this); private _styleManager = new StyleManager(this); @@ -145,8 +145,13 @@ export class CardController host: CardHTMLElement, scrollCallback: ScrollCallback, menuToggleCallback: MenuToggleCallback, + hassManager?: HASSManager, ) { - host.addController(this); + this._hassManager = hassManager ?? new HASSManager(this); + this._issueManager = createIssueManager( + this, + this._hassManager.getEventWatcher().getHealth(), + ); this._cardElementManager = new CardElementManager( this, @@ -154,6 +159,16 @@ export class CardController scrollCallback, menuToggleCallback, ); + + // ConditionStateManager MUST be wired first so its `hass` is current before + // any later listener fires. Otherwise StateWatcher could fire a + // camera-trigger handler that writes back to ConditionStateManager, fanning + // out to automations that still read a stale `hass`. + this._hassManager.addListener((hass) => + this._conditionStateManager.setState({ hass }), + ); + + host.addController(this); } // ************************************************************************* diff --git a/src/card-controller/hass/event-watcher.ts b/src/card-controller/hass/event-watcher.ts index b1896a28..de55f3bb 100644 --- a/src/card-controller/hass/event-watcher.ts +++ b/src/card-controller/hass/event-watcher.ts @@ -1,55 +1,75 @@ import { HassEvent } from 'home-assistant-js-websocket'; -import { HomeAssistant } from '../../ha/types'; -import { KeyedSubscriptionManager } from '../../utils/concurrency/keyed-subscription-manager'; +import { + SubscriptionHealthInterface, + SubscriptionHealthMonitor, +} from '../../ha/connection/subscription-health-monitor'; +import { HASSConnectionSubscriptionManager } from '../../ha/connection/subscription-manager'; +import { HASSSource } from '../../ha/source'; export interface EventSubscriptionRequest { event_type: string; - callback: (data: unknown) => void; + callback: (event: HassEvent) => void; - // Optional payload filter. Receives the event's `data`; if it returns false - // the event is dropped for this request. - matcher?: (data: unknown) => boolean; + // Optional filter receiving the full event so callers can match on payload + // (`event.data`) and/or context (`event.context`). Returning false drops the + // event for this request. + matcher?: (event: HassEvent) => boolean; } export interface EventWatcherSubscriptionInterface { - subscribe(hass: HomeAssistant, request: EventSubscriptionRequest): Promise; - unsubscribe(request: EventSubscriptionRequest): Promise; + subscribe(request: EventSubscriptionRequest): void; + unsubscribe(request: EventSubscriptionRequest): void; + getHealth(): SubscriptionHealthInterface; } /** - * 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. + * Subscribes to HA bus events via the WebSocket connection. Thin wrapper over + * `HASSConnectionSubscriptionManager` (connection-era lifecycle, refcounting, + * retry budgets, stale-callback guards): keys by `event_type`, runs each + * request's optional matcher before fan-out. */ export class EventWatcher implements EventWatcherSubscriptionInterface { - private _subscriptions = new KeyedSubscriptionManager< - string, - EventSubscriptionRequest - >((request) => request.event_type); + private _manager: HASSConnectionSubscriptionManager; + private _health: SubscriptionHealthMonitor; - public async subscribe( - hass: HomeAssistant, - request: EventSubscriptionRequest, - ): Promise { - await this._subscriptions.subscribe(request, () => - hass.connection.subscribeEvents( - (event) => this._receiveEvent(event), - request.event_type, - ), + constructor(source: HASSSource) { + this._manager = new HASSConnectionSubscriptionManager( + (request) => request.event_type, + source, + ); + this._health = new SubscriptionHealthMonitor((request) => + this._manager.retry(request), ); } - public async unsubscribe(request: EventSubscriptionRequest): Promise { - await this._subscriptions.unsubscribe(request); + public subscribe(request: EventSubscriptionRequest): void { + this._manager.subscribe( + request, + (connection, liveness) => + connection.subscribeEvents((event) => { + if (!liveness.isConnected()) { + return; + } + this._dispatch(event); + }, request.event_type), + (status) => this._health.update(status), + ); } - private _receiveEvent(event: HassEvent): void { - for (const request of this._subscriptions.getRequestsForKey(event.event_type)) { - if (!request.matcher || request.matcher(event.data)) { - request.callback(event.data); + public unsubscribe(request: EventSubscriptionRequest): void { + this._manager.unsubscribe(request); + } + + public getHealth(): SubscriptionHealthInterface { + return this._health; + } + + private _dispatch(event: HassEvent): void { + for (const request of this._manager.getRequestsForKey(event.event_type)) { + if (request.matcher && !request.matcher(event)) { + continue; } + request.callback(event); } } } diff --git a/src/card-controller/hass/hass-manager.ts b/src/card-controller/hass/hass-manager.ts index 585dd798..8edd361d 100644 --- a/src/card-controller/hass/hass-manager.ts +++ b/src/card-controller/hass/hass-manager.ts @@ -1,19 +1,26 @@ -import { STATE_RUNNING } from 'home-assistant-js-websocket'; +import { isHassReady } from '../../ha/is-hass-ready'; +import { HASSListener, HASSUnlistenCallback } from '../../ha/source'; 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'; +import { HASSManagerReadonlyInterface } from './types'; -export class HASSManager { +export class HASSManager implements HASSManagerReadonlyInterface { private _hass: HomeAssistant | null = null; private _api: CardHASSAPI; - private _stateWatcher: StateWatcher = new StateWatcher(); - private _eventWatcher: EventWatcher = new EventWatcher(); + + private _hassListeners = new Set(); + + private _stateWatcher: StateWatcherSubscriptionInterface; + private _eventWatcher: EventWatcherSubscriptionInterface; constructor(api: CardHASSAPI) { this._api = api; + this._stateWatcher = new StateWatcher(this); + this._eventWatcher = new EventWatcher(this); } public getHASS(): HomeAssistant | null { @@ -32,20 +39,22 @@ export class HASSManager { return this._eventWatcher; } + public addListener(listener: HASSListener): HASSUnlistenCallback { + this._hassListeners.add(listener); + return () => { + this._hassListeners.delete(listener); + }; + } + 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 - // view. This is necessary because event subscriptions (e.g. Frigate - // WebSocket subscriptions via hass.connection.subscribeMessage) are tied to - // the old connection and are lost when it drops. Without reinitialization, - // triggers and thumbnail updates stop working. - // - // We deliberately wait for hass.config.state === STATE_RUNNING rather than - // just hass.connected, because HA exposes the WebSocket before integrations - // have finished loading. Triggering re-init too early would race against - // integration startup and fail with "Unknown command" on - // integration-specific WS calls. - if (this._hass && !this._isReady(this._hass) && this._isReady(hass)) { + // When HA goes from "not ready" to "ready" (WebSocket reconnected AND all + // integrations finished loading), rebuild cameras and the view from + // scratch: the available entities may have changed while it was down. + const becameReady = !!this._hass && !isHassReady(this._hass) && isHassReady(hass); + + if (becameReady) { + // Tear cameras down before the listeners below see the new hass, + // otherwise they would briefly rebuild against the old entities. log( this._api.getConfigManager().getCardWideConfig(), 'Advanced Camera Card: HA fully ready, reinitializing...', @@ -66,17 +75,15 @@ export class HASSManager { const oldHass = this._hass; this._hass = hass; - this._api.getConditionStateManager().setState({ - hass: this._hass, - }); + // Notify each listener of the new hass, in subscription order. + for (const listener of this._hassListeners) { + listener(hass, oldHass); + } - // Theme may depend on HASS. - this._api.getStyleManager().applyTheme(); - - this._stateWatcher.setHASS(oldHass, hass); - } - - private _isReady(hass?: HomeAssistant | null): boolean { - return !!hass?.connected && hass.config?.state === STATE_RUNNING; + // Try to (re)initialize whenever hass changes. Initialization normally + // happens on the next re-render, but the teardown above can leave a + // reconnected card without a re-render, so it could stay stuck + // uninitialized. Harmless no-op when already initialized or not yet ready. + this._api.getInitializationManager().triggerInitialization(); } } diff --git a/src/card-controller/hass/state-watcher.ts b/src/card-controller/hass/state-watcher.ts index 74e602bf..4c3e895f 100644 --- a/src/card-controller/hass/state-watcher.ts +++ b/src/card-controller/hass/state-watcher.ts @@ -1,21 +1,53 @@ import { getHassDifferences } from '../../ha/get-hass-differences'; +import { HASSSource, HASSUnlistenCallback } from '../../ha/source'; import { HassStateDifference, HomeAssistant } from '../../ha/types'; type StateWatcherCallback = (difference: HassStateDifference) => void; export interface StateWatcherSubscriptionInterface { - subscribe(callback: StateWatcherCallback, entityIDs: string[]): void; + subscribe(callback: StateWatcherCallback, entityIDs: string[]): boolean; unsubscribe(callback: StateWatcherCallback): void; } export class StateWatcher implements StateWatcherSubscriptionInterface { + private _source: HASSSource; private _watcherCallbacks = new Map(); + private _unlisten: HASSUnlistenCallback | null = null; - public setHASS(oldHass: HomeAssistant | null, hass: HomeAssistant): void { + constructor(source: HASSSource) { + this._source = source; + } + + public subscribe(callback: StateWatcherCallback, entityIDs: string[]): boolean { + if (!entityIDs.length) { + return false; + } + const wasEmpty = this._watcherCallbacks.size === 0; + if (this._watcherCallbacks.has(callback)) { + this._watcherCallbacks.get(callback)?.push(...entityIDs); + } else { + this._watcherCallbacks.set(callback, entityIDs); + } + if (wasEmpty) { + this._unlisten = this._source.addListener((hass, oldHass) => + this._onHASS(hass, oldHass), + ); + } + return true; + } + + public unsubscribe(callback: StateWatcherCallback): void { + this._watcherCallbacks.delete(callback); + if (this._watcherCallbacks.size === 0 && this._unlisten) { + this._unlisten(); + this._unlisten = null; + } + } + + private _onHASS(hass: HomeAssistant, oldHass: HomeAssistant | null): void { if (!oldHass) { return; } - for (const [callback, entityIDs] of this._watcherCallbacks.entries()) { const differences = getHassDifferences(hass, oldHass, entityIDs, { stateOnly: true, @@ -26,26 +58,4 @@ export class StateWatcher implements StateWatcherSubscriptionInterface { } } } - - /** - * Calls callback when the state of any of the entities changes. The callback is - * called with the state difference of the first entity that changed. - * @param callback The callback. - * @param entityIDs An array of entity IDs to watch. - */ - public subscribe(callback: StateWatcherCallback, entityIDs: string[]): boolean { - if (!entityIDs.length) { - return false; - } - if (this._watcherCallbacks.has(callback)) { - this._watcherCallbacks.get(callback)?.push(...entityIDs); - } else { - this._watcherCallbacks.set(callback, entityIDs); - } - return true; - } - - public unsubscribe(callback: StateWatcherCallback): void { - this._watcherCallbacks.delete(callback); - } } diff --git a/src/card-controller/hass/types.ts b/src/card-controller/hass/types.ts new file mode 100644 index 00000000..5ff5f255 --- /dev/null +++ b/src/card-controller/hass/types.ts @@ -0,0 +1,8 @@ +import { HASSSource } from '../../ha/source'; +import { EventWatcherSubscriptionInterface } from './event-watcher'; +import { StateWatcherSubscriptionInterface } from './state-watcher'; + +export interface HASSManagerReadonlyInterface extends HASSSource { + getStateWatcher(): StateWatcherSubscriptionInterface; + getEventWatcher(): EventWatcherSubscriptionInterface; +} diff --git a/src/card-controller/initialization-manager.ts b/src/card-controller/initialization-manager.ts index efdc85ee..266c915a 100644 --- a/src/card-controller/initialization-manager.ts +++ b/src/card-controller/initialization-manager.ts @@ -1,5 +1,6 @@ import { STATE_RUNNING } from 'home-assistant-js-websocket'; import PQueue from 'p-queue'; +import { isHassReady } from '../ha/is-hass-ready'; import { sideLoadHomeAssistantElements } from '../ha/side-load-ha-elements'; import { loadLanguages } from '../localize/localize'; import { errorToConsole } from '../utils/basic'; @@ -70,6 +71,32 @@ export class InitializationManager { ]); } + // The one place that decides whether to (re)start mandatory initialization, + // so callers don't re-check the conditions themselves. Called on every render + // (from the card's shouldUpdate) and whenever hass changes (from + // HASSManager); a reconnect or a cleared issue reaches it by causing a + // render. + public triggerInitialization(): void { + if (!this._shouldInitializeMandatory()) { + return; + } + /* async */ this.initializeMandatory(); + } + + private _shouldInitializeMandatory(): boolean { + return ( + this._api.getConfigManager().hasConfig() && + this._api.getCardElementManager().isConnected() && + isHassReady(this._api.getHASSManager().getHASS()) && + !this.isInitializedMandatory() && + // Don't start while a full-card issue (e.g. the "Home Assistant is + // starting" notice) is shown: each initialization step aborts as soon as + // it sees one, so an attempt now would be wasted. The card tries again + // once the issue clears. + !this._api.getIssueManager().getStateManager().hasFullCardIssue() + ); + } + /** * Initialize the hard requirements for rendering anything. * @returns `true` if card rendering can continue. diff --git a/src/card-controller/issues/factory.ts b/src/card-controller/issues/factory.ts index b9bca7aa..62d7754d 100644 --- a/src/card-controller/issues/factory.ts +++ b/src/card-controller/issues/factory.ts @@ -1,16 +1,21 @@ +import { SubscriptionHealthInterface } from '../../ha/connection/subscription-health-monitor'; import { CardIssueManagerAPI } from '../types'; import { IssueManager } from './issue-manager'; import { ConfigErrorIssue } from './issues/config-error'; import { ConfigUpgradeIssue } from './issues/config-upgrade'; import { ConfigUpgradeFailureIssue } from './issues/config-upgrade-failure'; import { ConnectionIssue } from './issues/connection'; +import { EventSubscriptionIssue } from './issues/event-subscription'; import { InitializationIssue } from './issues/initialization'; import { LegacyResourceIssue } from './issues/legacy-resource'; import { MediaLoadIssue } from './issues/media-load'; import { MediaQueryIssue } from './issues/media-query'; import { ViewIncompatibleIssue } from './issues/view-incompatible'; -export const createIssueManager = (api: CardIssueManagerAPI): IssueManager => { +export const createIssueManager = ( + api: CardIssueManagerAPI, + eventSubscriptionHealth: SubscriptionHealthInterface, +): IssueManager => { const manager = new IssueManager(api); const changeCallback = () => manager.evaluate(); @@ -23,6 +28,7 @@ export const createIssueManager = (api: CardIssueManagerAPI): IssueManager => { manager.addIssue(new ConfigUpgradeFailureIssue(api)); manager.addIssue(new ViewIncompatibleIssue(api)); manager.addIssue(new ConnectionIssue()); + manager.addIssue(new EventSubscriptionIssue(eventSubscriptionHealth, changeCallback)); manager.addIssue(new InitializationIssue(api)); manager.addIssue(new LegacyResourceIssue(changeCallback)); manager.addIssue(new MediaQueryIssue(api)); diff --git a/src/card-controller/issues/issue-manager.ts b/src/card-controller/issues/issue-manager.ts index 707b481b..7e3c57bf 100644 --- a/src/card-controller/issues/issue-manager.ts +++ b/src/card-controller/issues/issue-manager.ts @@ -1,7 +1,7 @@ import type { IssueTriggerContext } from 'issue'; import { ConditionStateChange } from '../../condition-trigger/conditions/types'; import { isActionAllowedBasedOnInteractionState } from '../../utils/interaction-mode'; -import { Timer } from '../../utils/timer'; +import { RetryTimer } from '../../utils/retry-timer'; import { CardIssueManagerAPI } from '../types'; import { IssueStateManager } from './state-manager'; import { Issue, IssueKey, IssueReadOnlyState, IssueTriggerContextKey } from './types'; @@ -11,21 +11,21 @@ import { Issue, IssueKey, IssueReadOnlyState, IssueTriggerContextKey } from './t // lower-level recovery has had a chance to work, not in parallel with it. export const RETRY_EXPONENTIAL_BASE_SECONDS = 30; export const RETRY_EXPONENTIAL_MAX_SECONDS = 600; -const RETRY_EXPONENTIAL_JITTER_MIN = 0.5; -const RETRY_EXPONENTIAL_JITTER_MAX = 1.0; // Wraps the passive IssueStateManager with reaction logic. A single -// condition-state listener drives everything: it runs one-shot static -// detection when mandatory-init completes (`initialized` transitions to -// true), then evaluates dynamic issues on every subsequent state change, -// schedules retries, and updates the card. Full-card issues are rendered by -// card.ts via getStateManager().getFullCardIssue(). Non-full-card issue -// notifications are shown on demand via showNotification(). +// condition-state listener drives everything: it runs one-shot static detection +// when mandatory-init completes (`initialized` transitions to true), then +// evaluates dynamic issues on every subsequent state change, schedules retries, +// and updates the card. Full-card issues are rendered by card.ts via +// getStateManager().getFullCardIssue(). Non-full-card issue notifications are +// shown on demand via showNotification(). export class IssueManager { private _api: CardIssueManagerAPI; private _stateManager = new IssueStateManager(); - private _retryTimer = new Timer(); - private _retryAttempt = 0; + private _retryTimer = new RetryTimer({ + baseSeconds: RETRY_EXPONENTIAL_BASE_SECONDS, + maxSeconds: RETRY_EXPONENTIAL_MAX_SECONDS, + }); private _suspended = false; // Reentrancy guard: evaluate() calls setState() on the condition state @@ -89,6 +89,9 @@ export class IssueManager { issues: this._stateManager.getIssuePresence(), }) ) { + // Re-render to show the change. The re-render also re-attempts + // initialization, which matters when a blocking notice like "Home + // Assistant is starting" clears and the card can finally initialize. this._api.getCardElementManager().update(); } @@ -106,7 +109,7 @@ export class IssueManager { // user action resets the backoff schedule. public retry(key: IssueKey, force?: boolean): void { this._stateManager.retry(key, force); - this._retryTimer.stop(); + this._retryTimer.reset(); this.evaluate(); } @@ -140,7 +143,7 @@ export class IssueManager { // loading timeout). Evaluation resumes on resume(). public suspend(): void { this._suspended = true; - this._retryTimer.stop(); + this._retryTimer.cancel(); this._stateManager.suspend(); } @@ -150,7 +153,7 @@ export class IssueManager { } public destroy(): void { - this._retryTimer.stop(); + this._retryTimer.cancel(); this._stateManager.destroy(); } @@ -178,8 +181,7 @@ export class IssueManager { private _scheduleRetryIfNeeded(): void { if (!this._stateManager.needsRetry()) { - this._retryTimer.stop(); - this._retryAttempt = 0; + this._retryTimer.reset(); return; } if (this._retryTimer.isRunning()) { @@ -188,48 +190,50 @@ export class IssueManager { const config = this._api.getConfigManager().getConfig(); if (!config) { - this._retryAttempt = 0; - return; - } - const delaySeconds = this._nextRetryDelaySeconds(config.view.issues.retry_seconds); - if (delaySeconds === null) { - this._retryAttempt = 0; + this._retryTimer.reset(); return; } - this._retryTimer.start(delaySeconds, () => { - if (!this._stateManager.needsRetry()) { - this._retryAttempt = 0; - return; - } - if (this._isScheduledRetryAllowed()) { - this._stateManager.retry(); - this._retryAttempt++; - // evaluate() re-arms the timer via _scheduleRetryIfNeeded. - this.evaluate(); - } else { - // Retry was gated (e.g. user interaction). This isn't a failed attempt - // so don't increment — re-arm at the same delay. - this._scheduleRetryIfNeeded(); - } - }); - } - - private _nextRetryDelaySeconds(retryConfig: 'auto' | number): number | null { - if (typeof retryConfig === 'number') { - return retryConfig === 0 ? null : retryConfig; + const retryConfig = config.view.issues.retry_seconds; + if (retryConfig === 0) { + this._retryTimer.reset(); + return; } - // 'auto': exponential backoff, capped, with jitter to avoid thundering-herd - // when multiple cards retry the same backend in lockstep. - const exp = Math.min( - RETRY_EXPONENTIAL_MAX_SECONDS, - RETRY_EXPONENTIAL_BASE_SECONDS * 2 ** this._retryAttempt, + this._retryTimer.setOptions( + retryConfig === 'auto' + ? { + baseSeconds: RETRY_EXPONENTIAL_BASE_SECONDS, + maxSeconds: RETRY_EXPONENTIAL_MAX_SECONDS, + } + : retryConfig, + ); + + // Schedule without advancing: the backoff only escalates if the retry + // actually runs (via the explicit advance() below), not when it's gated. + this._retryTimer.schedule( + () => { + if (!this._stateManager.needsRetry()) { + this._retryTimer.reset(); + return; + } + if (this._isScheduledRetryAllowed()) { + this._stateManager.retry(); + + // This attempt counts: advance the backoff so the next schedule + // (re-armed by evaluate() via _scheduleRetryIfNeeded) uses a longer + // delay. For static-delay mode (base = max, no jitter) advancing is + // observable in `getAttempts()` but doesn't change the next delay. + this._retryTimer.advance(); + this.evaluate(); + } else { + // Retry was gated (e.g. user interaction). Not a failed attempt; the + // backoff stays put and we re-arm at the same delay. + this._scheduleRetryIfNeeded(); + } + }, + { advance: false }, ); - const jitter = - RETRY_EXPONENTIAL_JITTER_MIN + - Math.random() * (RETRY_EXPONENTIAL_JITTER_MAX - RETRY_EXPONENTIAL_JITTER_MIN); - return exp * jitter; } private _isScheduledRetryAllowed(): boolean { diff --git a/src/card-controller/issues/issues/event-subscription.ts b/src/card-controller/issues/issues/event-subscription.ts new file mode 100644 index 00000000..d3a5b8e6 --- /dev/null +++ b/src/card-controller/issues/issues/event-subscription.ts @@ -0,0 +1,81 @@ +import { Notification } from '../../../config/schema/actions/types'; +import { SubscriptionHealthInterface } from '../../../ha/connection/subscription-health-monitor'; +import { UnlistenCallback } from '../../../health'; +import { localize } from '../../../localize/localize'; +import { createRetryControl } from '../retry-control'; +import { Issue, IssueDescription } from '../types'; + +const ISSUE_ICON = 'mdi:lan-disconnect'; + +/** + * Surfaces persistent HA event-subscription failures (from the EventWatcher's + * health monitor) as a non-full-card notification listing the failing event + * types. Self-detects by observing the health monitor and asking the + * IssueManager to re-evaluate on change. + * + * Detection scope: the transport reports `failing` only when a subscribe + * attempt rejects (initial subscribe, era replay, or retry) -- there is no + * heartbeat on an established subscription, so this catches subscribe-time + * failures, not a subscription that goes silently dead after subscribing. + * + * Recovery is the subscription manager's own forever-retry loop, so this issue + * does NOT implement `needsRetry()` (no IssueManager-scheduled retry that would + * race the transport loop). The notification's Retry button is user-forced + * only: it re-drives the failing subscriptions immediately via the monitor. + */ +export class EventSubscriptionIssue implements Issue { + public readonly key = 'event_subscription' as const; + + private _health: SubscriptionHealthInterface; + private _unsubscribe: UnlistenCallback; + + constructor(health: SubscriptionHealthInterface, changeCallback: () => void) { + this._health = health; + this._unsubscribe = health.addListener(changeCallback); + } + + public hasIssue(): boolean { + return this._health.getFailures().length > 0; + } + + public getIssue(): IssueDescription | null { + if (!this.hasIssue()) { + return null; + } + return { + icon: ISSUE_ICON, + severity: 'medium', + notification: this._buildNotification(), + }; + } + + public getNotification(): Notification | null { + return this.getIssue()?.notification ?? null; + } + + public retry(): boolean { + this._health.retry(); + return true; + } + + public destroy(): void { + this._unsubscribe(); + } + + private _buildNotification(): Notification { + const eventTypes = this._health + .getFailures() + .map((failure) => failure.key) + .sort(); + return { + heading: { + text: localize('issues.event_subscription.heading'), + icon: ISSUE_ICON, + severity: 'medium', + }, + body: { text: localize('issues.event_subscription.text') }, + metadata: eventTypes.map((eventType) => ({ text: eventType })), + controls: [createRetryControl(this.key)], + }; + } +} diff --git a/src/card-controller/issues/state-manager.ts b/src/card-controller/issues/state-manager.ts index 52cd8b8b..a4a8005c 100644 --- a/src/card-controller/issues/state-manager.ts +++ b/src/card-controller/issues/state-manager.ts @@ -143,6 +143,9 @@ export class IssueStateManager implements IssueReadOnlyState { } public destroy(): void { + for (const issue of this._issues.values()) { + issue.destroy?.(); + } this.reset(); this._issues.clear(); this._loggedKeys.clear(); diff --git a/src/card-controller/issues/types.ts b/src/card-controller/issues/types.ts index 357fcea3..c1d4adc5 100644 --- a/src/card-controller/issues/types.ts +++ b/src/card-controller/issues/types.ts @@ -9,6 +9,7 @@ export type IssueKey = | 'config_upgrade' | 'config_upgrade_failure' | 'connection' + | 'event_subscription' | 'initialization' | 'legacy_resource' | 'media_load' @@ -83,7 +84,10 @@ export interface Issue { // callers (e.g. notification control actions) invoke this directly. fix?(hass: HomeAssistant): Promise; - // Reset internal state (clear errors, stop timers, etc.). + // Clear transient state (errors, timers) while the issue stays registered and + // able to re-activate. Runs repeatedly during the card's life (e.g. when the + // underlying problem recovers), so it must NOT release anything the issue + // needs to keep working -- that belongs in `destroy()`. reset?(): void; // Called when the card is detached. Issues with age-based timers (e.g. @@ -94,4 +98,10 @@ export interface Issue { // evaluate(), so any timer that should restart is re-armed via // detectDynamic against the current condition state. suspend?(): void; + + // Release external resources (e.g. a listener registered on another manager) + // at end of life. Called once when the IssueManager is destroyed -- unlike + // `reset()`, which runs repeatedly while the issue is still live, this is the + // final teardown. + destroy?(): void; } diff --git a/src/card.ts b/src/card.ts index 89fa06a7..7031a0a4 100644 --- a/src/card.ts +++ b/src/card.ts @@ -183,15 +183,7 @@ class AdvancedCameraCard extends LitElement { return false; } - // Always allow blocking issues to render, as they may be generated during - // initialization. - if (this._controller.getIssueManager().getStateManager().hasFullCardIssue()) { - return true; - } - - if (!this._controller.getInitializationManager().isInitializedMandatory()) { - /* async */ this._controller.getInitializationManager().initializeMandatory(); - } + this._controller.getInitializationManager().triggerInitialization(); return true; } diff --git a/src/condition-trigger/triggers/factory.ts b/src/condition-trigger/triggers/factory.ts index 43486b50..c3554b8e 100644 --- a/src/condition-trigger/triggers/factory.ts +++ b/src/condition-trigger/triggers/factory.ts @@ -3,6 +3,7 @@ import { CallTrigger } from './triggers/call'; import { CameraTrigger } from './triggers/camera'; import { ConfigTrigger } from './triggers/config'; import { DisplayModeTrigger } from './triggers/display-mode'; +import { EventTrigger } from './triggers/event'; import { ExpandTrigger } from './triggers/expand'; import { FullscreenTrigger } from './triggers/fullscreen'; import { InitializedTrigger } from './triggers/initialized'; @@ -30,6 +31,8 @@ export const createTriggerEvaluator = ( return new NumericStateTrigger(trigger, context); case 'template': return new TemplateTrigger(trigger, context); + case 'event': + return new EventTrigger(trigger, context); // `screen` watches window.matchMedia. case 'screen': diff --git a/src/condition-trigger/triggers/manager.ts b/src/condition-trigger/triggers/manager.ts index 82b601ec..cb1e4422 100644 --- a/src/condition-trigger/triggers/manager.ts +++ b/src/condition-trigger/triggers/manager.ts @@ -1,3 +1,4 @@ +import { HASSManagerReadonlyInterface } from '../../card-controller/hass/types'; import { TemplateRenderer } from '../../card-controller/templates'; import { Trigger } from '../../config/schema/condition-trigger/triggers/types'; import { isEnabled } from '../common/is-enabled'; @@ -30,8 +31,13 @@ export class TriggersManager { constructor( triggers: Trigger[], stateManager: ConditionStateManagerReadonlyInterface, + hassManager: HASSManagerReadonlyInterface, ) { - this._context = { stateManager, templateRenderer: new TemplateRenderer() }; + this._context = { + stateManager, + templateRenderer: new TemplateRenderer(), + hassManager, + }; this._triggers = triggers.map((config) => ({ config, evaluator: createTriggerEvaluator(config, this._context), diff --git a/src/condition-trigger/triggers/triggers/event.ts b/src/condition-trigger/triggers/triggers/event.ts new file mode 100644 index 00000000..71b3ed79 --- /dev/null +++ b/src/condition-trigger/triggers/triggers/event.ts @@ -0,0 +1,57 @@ +import { uniq } from 'lodash-es'; +import { + EventSubscriptionRequest, + EventWatcherSubscriptionInterface, +} from '../../../card-controller/hass/event-watcher'; +import { matchesEventContext, matchesEventData } from '../../../ha/event-match'; +import { arrayify } from '../../../utils/basic'; +import { + TriggerCallback, + TriggerEvaluator, + TriggerEvaluatorContext, + TriggerOfType, +} from './types'; + +// Subscribes via the shared EventWatcher to one or more HA bus event types and +// fires every time a matching event arrives. List-form `event_type` expands +// into one EventWatcher subscription per (de-duplicated) type sharing the same +// data/context matcher; `event_data` and `context` filters are AND-gated. +// +// https://www.home-assistant.io/docs/automation/trigger/#event-trigger +export class EventTrigger implements TriggerEvaluator { + private _trigger: TriggerOfType<'event'>; + + private _eventWatcher: EventWatcherSubscriptionInterface; + private _unsubscribeCallback: (() => void) | null = null; + + constructor(trigger: TriggerOfType<'event'>, context: TriggerEvaluatorContext) { + this._trigger = trigger; + this._eventWatcher = context.hassManager.getEventWatcher(); + } + + public subscribe(callback: TriggerCallback): void { + const dataFilter = this._trigger.event_data; + const contextFilter = this._trigger.context; + + const requests = uniq(arrayify(this._trigger.event_type)).map( + (eventType): EventSubscriptionRequest => ({ + event_type: eventType, + ...((dataFilter || contextFilter) && { + matcher: (evt) => + (!dataFilter || matchesEventData(dataFilter, evt.data)) && + (!contextFilter || matchesEventContext(contextFilter, evt.context)), + }), + callback: (event) => callback({ platform: 'event', event }), + }), + ); + + requests.forEach((request) => this._eventWatcher.subscribe(request)); + this._unsubscribeCallback = () => + requests.forEach((request) => this._eventWatcher.unsubscribe(request)); + } + + public destroy(): void { + this._unsubscribeCallback?.(); + this._unsubscribeCallback = null; + } +} diff --git a/src/condition-trigger/triggers/triggers/types.ts b/src/condition-trigger/triggers/triggers/types.ts index 13eb72ba..8a11d0d3 100644 --- a/src/condition-trigger/triggers/triggers/types.ts +++ b/src/condition-trigger/triggers/triggers/types.ts @@ -1,3 +1,4 @@ +import { HASSManagerReadonlyInterface } from '../../../card-controller/hass/types'; import { TemplateRenderer } from '../../../card-controller/templates'; import { Trigger } from '../../../config/schema/condition-trigger/triggers/types'; import { ConditionStateManagerReadonlyInterface } from '../../conditions/types'; @@ -8,6 +9,7 @@ export type TriggerCallback = (data: TriggerData) => void; export interface TriggerEvaluatorContext { stateManager: ConditionStateManagerReadonlyInterface; templateRenderer: TemplateRenderer; + hassManager: HASSManagerReadonlyInterface; } export type TriggerOfType = Extract; diff --git a/src/condition-trigger/triggers/types.ts b/src/condition-trigger/triggers/types.ts index 0a0bc82d..81bef710 100644 --- a/src/condition-trigger/triggers/types.ts +++ b/src/condition-trigger/triggers/types.ts @@ -1,4 +1,4 @@ -import { HassEntity } from 'home-assistant-js-websocket'; +import { HassEntity, HassEvent } from 'home-assistant-js-websocket'; import { TemplateAdvancedCameraCardState } from '../../card-controller/templates/types'; // The top-level `trigger` template variable produced each time an evaluator @@ -15,6 +15,10 @@ export interface TriggerData { from_state?: HassEntity; to_state?: HassEntity; + // For `platform: 'event'` (HA event trigger) -- the full HA event, surfaced + // as `trigger.event.*` to mirror HA's event-trigger template variables. + event?: HassEvent; + // Card (`acc` platform) fields -- full before/after card-state trigger data: from_acc?: TemplateAdvancedCameraCardState; to_acc?: TemplateAdvancedCameraCardState; diff --git a/src/config/schema/cameras.ts b/src/config/schema/cameras.ts index 6d0ebc9a..b6383588 100644 --- a/src/config/schema/cameras.ts +++ b/src/config/schema/cameras.ts @@ -4,6 +4,7 @@ import { mediaLayoutConfigSchema } from './camera/media-layout'; import { ptzCameraConfigDefaults, ptzCameraConfigSchema } from './camera/ptz'; import { aspectRatioSchema } from './common/aspect-ratio'; import { eventsMediaTypeSchema } from './common/events-media'; +import { haEventSchema } from './common/ha-event'; import { imageBaseConfigDefault, imageBaseConfigSchema } from './common/image'; import { proxyBaseConfigDefault, proxyBaseConfigSchema } from './common/proxy'; import { severitySchema } from './common/severity'; @@ -219,12 +220,6 @@ 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(), @@ -259,7 +254,7 @@ 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: triggerEventSchema.array().default(cameraConfigDefault.triggers.events), + events: haEventSchema.array().default(cameraConfigDefault.triggers.events), media_events: z .enum(CAMERA_TRIGGER_MEDIA_EVENT_TYPES) .array() diff --git a/src/config/schema/common/ha-event.ts b/src/config/schema/common/ha-event.ts new file mode 100644 index 00000000..6e4c7d7b --- /dev/null +++ b/src/config/schema/common/ha-event.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; +import { stringOrArray } from './string-or-array'; + +// Filter on the event's `context` (HA's three fixed fields). Each defined field +// is equality-matched against a scalar or membership-matched against a list. +// `.strict()` rejects unknown keys at parse time so a typo (e.g. `user:` vs +// `user_id:`) surfaces instead of silently collapsing to "match everything". +const eventContextFilterSchema = z + .object({ + id: stringOrArray.optional(), + user_id: stringOrArray.optional(), + parent_id: stringOrArray.optional(), + }) + .strict(); +export type HAEventContextFilter = z.infer; + +// A Home Assistant bus event filter: `event_type` (one type, or a list to match +// any of them) plus optional payload (`event_data`) and context (`context`) +// filters. `event_data` mirrors HA's matching exactly: listed keys (top-level +// and nested) must be present and extra keys are ignored -- see `event-match.ts` +// for the precise nested-object/array semantics. `context` is field-level +// equality or list-membership. Field names mirror HA's native event trigger +// exactly so the same YAML works in either place. +// https://www.home-assistant.io/docs/automation/trigger/#event-trigger +export const haEventSchema = z.object({ + event_type: stringOrArray, + event_data: z.record(z.string(), z.unknown()).optional(), + context: eventContextFilterSchema.optional(), +}); +export type HAEvent = z.infer; diff --git a/src/config/schema/condition-trigger/triggers/stock/event.ts b/src/config/schema/condition-trigger/triggers/stock/event.ts new file mode 100644 index 00000000..9158e9ad --- /dev/null +++ b/src/config/schema/condition-trigger/triggers/stock/event.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; +import { haEventSchema } from '../../../common/ha-event'; +import { triggerBaseSchema } from '../base'; + +// Subscribes to one or more Home Assistant bus event types and fires every time +// a matching event arrives. `event_type`, `event_data` and `context` filters +// mirror HA's native fields exactly. +// https://www.home-assistant.io/docs/automation/trigger/#event-trigger +export const eventTriggerSchema = triggerBaseSchema.extend(haEventSchema.shape).extend({ + trigger: z.literal('event'), +}); diff --git a/src/config/schema/condition-trigger/triggers/types.ts b/src/config/schema/condition-trigger/triggers/types.ts index 96ee4473..62bcee59 100644 --- a/src/config/schema/condition-trigger/triggers/types.ts +++ b/src/config/schema/condition-trigger/triggers/types.ts @@ -13,12 +13,14 @@ import { microphoneTriggerSchema } from './custom/microphone'; import { screenTriggerSchema } from './custom/screen'; import { triggeredTriggerSchema } from './custom/triggered'; import { viewTriggerSchema } from './custom/view'; +import { eventTriggerSchema } from './stock/event'; import { numericStateTriggerSchema } from './stock/numeric-state'; import { stateTriggerSchema } from './stock/state'; import { templateTriggerSchema } from './stock/template'; export const triggerSchema = z.union([ // Stock triggers (HA automation triggers): + eventTriggerSchema, numericStateTriggerSchema, stateTriggerSchema, templateTriggerSchema, diff --git a/src/ha/connection/subscription-health-monitor.ts b/src/ha/connection/subscription-health-monitor.ts new file mode 100644 index 00000000..c6b25557 --- /dev/null +++ b/src/ha/connection/subscription-health-monitor.ts @@ -0,0 +1,101 @@ +import { RecoverableHealthInterface, UnlistenCallback } from '../../health'; +import { HASSWebSocketSubscriptionStatus } from './subscription-manager'; + +// One keyed subscription that is currently failing, with the most recent +// rejection reason and attempt count. +export interface SubscriptionFailure { + key: K; + error: unknown; + failureCount?: number; +} + +// Recoverable health of a set of keyed subscriptions. A named specialisation +// for readability and the extension point for any subscription-specific health +// surface later. +export type SubscriptionHealthInterface = RecoverableHealthInterface< + SubscriptionFailure +>; + +/** + * Aggregates the per-request status stream of a + * `HASSConnectionSubscriptionManager` into per-key health: which keys are + * currently failing, observable for changes, and retriable on demand. Generic + * over the manager's key/request types so any consumer can reuse it; it never + * knows how failures are surfaced (issue, log, nothing). + */ +export class SubscriptionHealthMonitor implements SubscriptionHealthInterface { + // The latest significant (`subscribed`/`failing`) status per request. + private _health = new Map>(); + private _listeners = new Set<() => void>(); + + private _retry: (request: R) => void; + + constructor(retry: (request: R) => void) { + this._retry = retry; + } + + public update(status: HASSWebSocketSubscriptionStatus): void { + // `waiting` is emitted before every retry attempt; treating it as + // significant would flap a failing request back to healthy mid-backoff. + if (status.state === 'waiting') { + return; + } + + const wasFailing = this._failingKeys().has(status.key); + switch (status.state) { + case 'subscribed': + case 'failing': + // Retain the status as-is; only these two are significant. + this._health.set(status.request, status); + break; + case 'unsubscribed': + this._health.delete(status.request); + break; + } + + // A single status changes at most one key's failing state, so notifying on + // that key's transition keeps observers off the manager's per-retry churn. + if (this._failingKeys().has(status.key) !== wasFailing) { + for (const listener of this._listeners) { + listener(); + } + } + } + + public getFailures(): SubscriptionFailure[] { + const failing = new Map>(); + for (const status of this._health.values()) { + if (status.state === 'failing' && !failing.has(status.key)) { + failing.set(status.key, { + key: status.key, + error: status.error, + failureCount: status.failureCount, + }); + } + } + return [...failing.values()]; + } + + public addListener(listener: () => void): UnlistenCallback { + this._listeners.add(listener); + return () => { + this._listeners.delete(listener); + }; + } + + // Re-drive one currently-`failing` request per failing key; one suffices + // since the WS subscription is keyed. + public retry(): void { + const retried = new Set(); + for (const [request, status] of this._health) { + if (status.state === 'failing' && !retried.has(status.key)) { + retried.add(status.key); + this._retry(request); + } + } + } + + private _failingKeys(): Set { + return new Set(this.getFailures().map((failure) => failure.key)); + } +} diff --git a/src/ha/connection/subscription-manager.ts b/src/ha/connection/subscription-manager.ts new file mode 100644 index 00000000..9608063c --- /dev/null +++ b/src/ha/connection/subscription-manager.ts @@ -0,0 +1,378 @@ +import { Connection } from 'home-assistant-js-websocket'; +import { + GetKeyCallback, + KeyedSubscriptionManager, +} from '../../utils/concurrency/keyed-subscription-manager'; +import { RetryTimer } from '../../utils/retry-timer'; +import { isHassReady } from '../is-hass-ready'; +import { HASSSource, HASSUnlistenCallback } from '../source'; +import { HomeAssistant } from '../types'; +import { HASSWebSocketLiveness, HASSWebSocketOpenCallback } from './types'; + +const RETRY_BASE_SECONDS = 1; +const RETRY_MAX_SECONDS = 300; + +/** + * Lifecycle status reported back to subscribers via their optional + * `statusCallback` at `subscribe` time. The manager owns the retry policy; + * consumers are pure observers that translate state changes into whatever they + * want (an Issue/notification, a log, nothing). Consumers never call + * `subscribe` again to retry -- they use `retry(request)` which routes through + * the same state machine. + */ +export interface HASSWebSocketSubscriptionStatus { + key: K; + request: R; + + // The subscription's lifecycle state: + // - `waiting`: not subscribed (e.g. HA isn't ready yet, submission in flight). + // - `subscribed`: the underlying WS subscription is live. + // - `failing`: the most recent attempt rejected. A retry is armed; the next + // status will be `waiting` then either `subscribed` or `failing` again. + // - `unsubscribed`: the request was removed via `unsubscribe()`. + state: 'subscribed' | 'failing' | 'waiting' | 'unsubscribed'; + + // Present only on `failing`: the rejection reason, and the total + // failed-attempts count so far. + error?: unknown; + failureCount?: number; +} + +export type HASSWebSocketStatusCallback = ( + status: HASSWebSocketSubscriptionStatus, +) => void; + +interface RequestRegistration { + openCallback: HASSWebSocketOpenCallback; + statusCallback: HASSWebSocketStatusCallback | null; + + // Era-local, reset on every era boundary (see the class docs for "era"). + // + // `token` tags the latest subscribe attempt so a newer attempt can replace + // old/slow attempts; null when none is in flight. + token: symbol | null; + + // `retry` schedules the next attempt after a failed subscribe; exponential + // backoff spaces attempts out across HASS pushes. + retry: RetryTimer; +} + +/** + * Manages subscriptions whose lifetime is bound to a HASS WebSocket + * `Connection`. Layered on top of `KeyedSubscriptionManager` (KSM: per-key + * refcount + sub/unsub serialization within ONE connection era). + * + * An **era** is a contiguous window during which the manager is bound to a + * single live `Connection`. The KSM instance is replaced and each request's + * era-local state (`token` + `retry`) is reset on every era boundary. The + * durable `_requests` mirror is preserved across eras and drives replay. + * - Era STARTS when: a ready HASS arrives for the first time, OR the + * `Connection` object swaps to a different instance, OR the manager + * transitions from a not-ready dead era back to ready. + * - Era ENDS (becomes a dead era) when: HASS goes not-ready. + * + * Each era is identified by a `Symbol()` minted at era start and stored in + * `_connectionEra`. The `HASSWebSocketLiveness` objects (returned to callers' + * dispatch closures) capture the era symbol; their `isConnected()` method + * compares the captured symbol against the manager's current `_connectionEra`. + * Nulling or replacing `_connectionEra` therefore synchronously flips every + * outstanding state to disconnected. + * + * Symbol identity (not `Connection` pointer identity) is what defines an era, + * because the HA `Connection` library can reuse the same `Connection` object + * across reconnect cycles. From the manager's standpoint a not-ready -> ready + * transition with the same `Connection` is a NEW era (we've torn down era state + * during not-ready), and we need old states to keep reporting disconnected even + * if the pointer matches. + * + * Adds on top of KSM: + * - Deferred submit until HASS is ready. + * - Era boundaries as described above. + * - `HASSWebSocketLiveness` for caller dispatch callbacks: drops events that + * arrived from an era that's no longer current. + * - Time-spaced exponential-backoff retries on subscribe failure. Retries fire + * on a per-request `Timer`, NOT on HASS-push cadence (which is far too + * frequent). After many failures, retries naturally space out to the + * `RETRY_MAX_SECONDS` ceiling. + * - Lazy source attach / detach driven by request count. + */ +export class HASSConnectionSubscriptionManager { + private readonly _source: HASSSource; + private readonly _getKeyCallback: GetKeyCallback; + + private _connection: Connection | null = null; + private _connectionEra: symbol | null = null; + + // Per-key refcount + sub/unsub serialization for the CURRENT era only. + // Replaced with a fresh instance on every era boundary; the abandoned + // instance's pending tasks resolve into an unreachable object. + private _ksm: KeyedSubscriptionManager; + + // Durable: source-of-truth list of currently-registered requests (with their + // era-local token + retry). Survives era transitions and drives replay + // against the new era's `KeyedSubscriptionManager`. The + // `KeyedSubscriptionManager`'s internal request list lags behind by the time + // of its async task; this mirror is updated synchronously on + // subscribe/unsubscribe. + private _requests = new Map>(); + + private _unlistenCallback: HASSUnlistenCallback | null = null; + + constructor(getKeyCallback: GetKeyCallback, source: HASSSource) { + this._getKeyCallback = getKeyCallback; + this._source = source; + this._ksm = this._createEmptyKSM(); + } + + public subscribe( + request: R, + openCallback: HASSWebSocketOpenCallback, + statusCallback?: HASSWebSocketStatusCallback, + ): void { + const wasEmpty = this._requests.size === 0; + + const registration: RequestRegistration = { + openCallback, + statusCallback: statusCallback ?? null, + token: null, + retry: new RetryTimer({ + baseSeconds: RETRY_BASE_SECONDS, + maxSeconds: RETRY_MAX_SECONDS, + }), + }; + this._requests.set(request, registration); + + if (wasEmpty) { + this._listenToHASS(); + } + + if (this._connection && !registration.token) { + this._submit(this._connection, request, registration); + } else if (!this._connection) { + // Dead era. Caller observes the request as waiting until the era starts + // and `_submit` fires, at which point status flips to `subscribed` or + // `failing`. + this._emitStatus(request, 'waiting'); + } + } + + public retry(request: R): void { + const registration = this._requests.get(request); + if (!registration) { + return; + } + registration.retry.reset(); + if (this._connection) { + this._submit(this._connection, request, registration); + } + } + + public unsubscribe(request: R): void { + const registration = this._requests.get(request); + if (!registration) { + return; + } + this._emitStatus(request, 'unsubscribed'); + registration.retry.cancel(); + this._requests.delete(request); + + // `KeyedSubscriptionManager` unsubscribe failures are internal (HA returned + // an error on the close message). Caller can't act; swallow. + this._ksm.unsubscribe(request).catch(() => {}); + + if (this._requests.size === 0) { + this._unlistenFromHASS(); + } + } + + public destroy(): void { + this._unlistenFromHASS(); + this._endEra(); + this._requests.clear(); + } + + public getRequestsForKey(key: K): R[] { + const result: R[] = []; + for (const request of this._requests.keys()) { + if (this._getKeyCallback(request) === key) { + result.push(request); + } + } + return result; + } + + private _listenToHASS(): void { + /* istanbul ignore if: only called when transitioning from zero to one + request, so `_unlistenCallback` is always null here -- @preserve */ + if (this._unlistenCallback) { + return; + } + this._unlistenCallback = this._source.addListener((hass) => + this._handleHASSChange(hass), + ); + + // Handle initial state. + this._handleHASSChange(this._source.getHASS()); + } + + private _unlistenFromHASS(): void { + if (!this._unlistenCallback) { + return; + } + this._unlistenCallback(); + this._unlistenCallback = null; + } + + private _handleHASSChange(hass: HomeAssistant | null): void { + if (!hass || !isHassReady(hass)) { + if (this._connectionEra !== null) { + this._endEra(); + // Surface the era end to consumers so they can update any UI that was + // reflecting `subscribed` or `failing` for the now-dead era. + for (const request of this._requests.keys()) { + this._emitStatus(request, 'waiting'); + } + } + return; + } + + if (hass.connection === this._connection && this._connectionEra !== null) { + // Nothing to do. + return; + } + + // Era transition (connection swap or reanimation from a dead era). + this._endEra(); + this._connection = hass.connection; + this._connectionEra = Symbol(); + + for (const [request, registration] of this._requests) { + this._submit(this._connection, request, registration); + } + } + + private _submit( + connection: Connection, + request: R, + registration: RequestRegistration, + ): void { + const token = Symbol(); + registration.token = token; + registration.retry.cancel(); + + const { openCallback } = registration; + const liveness = this._createWebSocketLiveness(); + + this._emitStatus(request, 'waiting'); + + this._ksm + .subscribe(request, () => openCallback(connection, liveness)) + .then(() => { + const eraState = this._getCurrentEraState(request, token); + if (!eraState) { + return; + } + + // Reset the backoff so the next failure (e.g. after an era swap) + // starts at the base delay again instead of jumping to wherever we + // had escalated to. + eraState.retry.reset(); + this._emitStatus(request, 'subscribed'); + }) + .catch((e) => { + const eraState = this._getCurrentEraState(request, token); + if (!eraState) { + return; + } + + eraState.token = null; + eraState.retry.schedule(() => this._runScheduledRetry(request)); + this._emitStatus(request, 'failing', e, eraState.retry.getAttempts()); + }); + } + + private _runScheduledRetry(request: R): void { + const registration = this._requests.get(request); + + /* istanbul ignore if: unsubscribe() and `_endEra()` both stop the timer + before tearing down state, so by the time we get here the request is + still alive and the era is still ready -- @preserve */ + if (!registration || !this._connection) { + return; + } + + /* istanbul ignore if: the timer can only fire while its token is null (set + null by the catch that scheduled this timer) -- @preserve */ + if (registration.token != null) { + return; + } + this._submit(this._connection, request, registration); + } + + private _emitStatus( + request: R, + state: HASSWebSocketSubscriptionStatus['state'], + error?: unknown, + failureCount?: number, + ): void { + const registration = this._requests.get(request); + try { + registration?.statusCallback?.({ + key: this._getKeyCallback(request), + request, + state, + ...(error != null && { error }), + ...(failureCount && { failureCount }), + }); + } catch { + // Swallowed: a buggy observer must not corrupt the state machine. + } + } + + // Returns the request's registration only while `token` is still its current + // submission. A mismatch (or a removed request) means the era moved on or a + // newer submission superseded this one, so the caller must leave all state + // untouched. + private _getCurrentEraState( + request: R, + token: symbol, + ): RequestRegistration | null { + const registration = this._requests.get(request); + return registration?.token === token ? registration : null; + } + + // End the current era: drop the connection and clear the era symbol (so every + // outstanding `HASSWebSocketLiveness.isConnected()` flips to disconnected), + // reset each request's era-local state, and close + replace the KSM. + // Subscriptions are closed via the durable `_requests` mirror because KSM's + // own list lags pending subscribe tasks; per-request close failures are + // swallowed (an abandoned/dead connection has no live socket to ack the + // close). + private _endEra(): void { + this._connection = null; + this._connectionEra = null; + + const ksm = this._ksm; + this._ksm = this._createEmptyKSM(); + for (const [request, registration] of this._requests) { + registration.retry.reset(); + registration.token = null; + ksm.unsubscribe(request).catch(() => {}); + } + } + + private _createWebSocketLiveness(): HASSWebSocketLiveness { + // Capture the era at submit time. `isConnected` compares it against the + // manager's current era; if they differ, the manager has moved on and the + // liveness reports disconnected. Arrow form so `this` is the class + // instance. + const era = this._connectionEra; + return { + isConnected: (): boolean => era !== null && this._connectionEra === era, + }; + } + + private _createEmptyKSM(): KeyedSubscriptionManager { + return new KeyedSubscriptionManager(this._getKeyCallback); + } +} diff --git a/src/ha/connection/types.ts b/src/ha/connection/types.ts new file mode 100644 index 00000000..295136b9 --- /dev/null +++ b/src/ha/connection/types.ts @@ -0,0 +1,26 @@ +import { Connection } from 'home-assistant-js-websocket'; + +/** + * Types describing the caller-supplied callbacks for opening and closing a + * single WebSocket subscription on the HA bus. + * `HASSConnectionSubscriptionManager` invokes `HASSWebSocketOpenCallback` every + * time it needs to open a fresh WebSocket subscription on a given `Connection` + * (initial open, replay after the manager moved to a new connection, retry + * after a previous failure), and later invokes the returned + * `HASSWebSocketCloseCallback` once to close it. + * + * `HASSWebSocketLiveness.isConnected()` is provided to the caller's open-callback + * so its WS dispatch callback can drop events that arrived after the manager + * has moved on to a different connection or is torn down. See + * `subscription-manager.ts` for the "era" model behind this. + */ +export interface HASSWebSocketLiveness { + isConnected(): boolean; +} + +type HASSWebSocketCloseCallback = () => Promise; + +export type HASSWebSocketOpenCallback = ( + connection: Connection, + liveness: HASSWebSocketLiveness, +) => Promise; diff --git a/src/ha/event-data-match.ts b/src/ha/event-data-match.ts deleted file mode 100644 index e76dbb6b..00000000 --- a/src/ha/event-data-match.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { isMatch } from 'lodash-es'; -import { isRecord } from '../utils/basic'; - -// 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 => isRecord(data) && isMatch(data, filter); diff --git a/src/ha/event-match.ts b/src/ha/event-match.ts new file mode 100644 index 00000000..c3c687c6 --- /dev/null +++ b/src/ha/event-match.ts @@ -0,0 +1,111 @@ +import { HassEventBase } from 'home-assistant-js-websocket'; +import { isEqualWith } from 'lodash-es'; +import { HAEventContextFilter } from '../config/schema/common/ha-event'; +import { isRecord } from '../utils/basic'; + +// A plain object (HA/Python `dict`), excluding arrays -- the distinction HA's +// event trigger keys off when deciding how to match a value. +const isDict = (value: unknown): value is Record => + isRecord(value) && !Array.isArray(value); + +// Deep equality matching Python's `==`: identical to a normal deep-equal except +// that Python's `bool` is a subtype of `int`, so `true`/`false` equal `1`/`0` +// (and that equivalence propagates through nested lists/dicts). HA relies on +// it, so we must too for byte-for-byte parity. +const haEqual = (a: unknown, b: unknown): boolean => + isEqualWith(a, b, (x, y) => { + if (typeof x === 'boolean' && typeof y === 'number') { + return Number(x) === y; + } + if (typeof x === 'number' && typeof y === 'boolean') { + return x === Number(y); + } + return undefined; + }); + +// Matches HA's event-trigger `event_data` filtering precisely, including its +// fast-path/slow-path split (homeassistant/components/homeassistant/triggers/ +// event.py): +// +// - If NO top-level filter value is a dict, HA does a plain items-subset +// compare (`event.items() >= filter.items()`): every filter key must be +// present with an equal value (`haEqual`; lists by order + length). +// - If ANY top-level filter value is a dict, HA validates the event against +// `vol.Schema(filter, extra=ALLOW_EXTRA, required=True)` instead: every +// filter key is required (recursively into nested dicts), extra keys are +// allowed at every level, and a list filter is matched by voluptuous +// membership -- each event-array element must equal one of the filter +// array's entries (order/length-free). Scalars match by equality. +// +// The asymmetry is real: the same top-level list filter is strict in the fast +// path but membership-matched in the slow path (when a sibling key is a dict). +// The `unknown` guard lives here because event payloads arrive untyped from the +// WebSocket bus. +// https://github.com/home-assistant/core/blob/dev/homeassistant/components/homeassistant/triggers/event.py +export const matchesEventData = ( + filter: Record, + data: unknown, +): boolean => { + if (!isRecord(data)) { + return false; + } + + if (!Object.values(filter).some(isDict)) { + // Fast path: strict items subset. + return Object.entries(filter).every( + ([key, expected]) => key in data && haEqual(data[key], expected), + ); + } + + // Slow path: `vol.Schema(filter, extra=ALLOW_EXTRA, required=True)`. + return matchesSchemaDict(filter, data); +}; + +// A voluptuous dict schema with `required=True, extra=ALLOW_EXTRA`: every schema +// key must be present and recursively match; extra event keys are allowed. +const matchesSchemaDict = (schema: Record, value: unknown): boolean => + isDict(value) && + Object.entries(schema).every( + ([key, expected]) => key in value && matchesSchemaValue(expected, value[key]), + ); + +const matchesSchemaValue = (expected: unknown, actual: unknown): boolean => { + if (isDict(expected)) { + return matchesSchemaDict(expected, actual); + } + if (Array.isArray(expected)) { + // voluptuous list schema: `actual` must be a list whose every element + // matches one of the filter's element schemas. + return ( + Array.isArray(actual) && + actual.every((item) => expected.some((schema) => matchesSchemaValue(schema, item))) + ); + } + return haEqual(actual, expected); +}; + +// HA-faithful match for the event `context` object: every defined filter field +// must match the event's corresponding field by equality (scalar filter) or +// list-membership (array filter). A null event-side field never satisfies an +// explicit filter, mirroring HA's behaviour. +// https://www.home-assistant.io/docs/automation/trigger/#event-trigger +export const matchesEventContext = ( + filter: HAEventContextFilter, + context: HassEventBase['context'], +): boolean => + matchesContextField(filter.id, context.id) && + matchesContextField(filter.user_id, context.user_id) && + matchesContextField(filter.parent_id, context.parent_id); + +const matchesContextField = ( + expected: string | string[] | undefined, + actual: string | null, +): boolean => { + if (expected === undefined) { + return true; + } + if (actual === null) { + return false; + } + return Array.isArray(expected) ? expected.includes(actual) : expected === actual; +}; diff --git a/src/ha/is-hass-ready.ts b/src/ha/is-hass-ready.ts new file mode 100644 index 00000000..5af3b0de --- /dev/null +++ b/src/ha/is-hass-ready.ts @@ -0,0 +1,14 @@ +import { STATE_RUNNING } from 'home-assistant-js-websocket'; +import { HomeAssistant } from './types'; + +// HA is "ready" when the WebSocket is connected AND integrations have finished +// loading (`config.state === STATE_RUNNING`). HA exposes the WebSocket before +// integrations load, so `connected` alone is insufficient for +// integration-specific calls (e.g. Frigate WS subscriptions, which fail with +// "Unknown command" against a half-loaded HA). +// +// Typed as a predicate so callers can use the readiness check to narrow a +// nullable `hass?` reference to a non-null `HomeAssistant` for follow-up +// `hass.connection`-style access. +export const isHassReady = (hass?: HomeAssistant | null): hass is HomeAssistant => + !!hass?.connected && hass.config?.state === STATE_RUNNING; diff --git a/src/ha/source.ts b/src/ha/source.ts new file mode 100644 index 00000000..a04f5364 --- /dev/null +++ b/src/ha/source.ts @@ -0,0 +1,16 @@ +import { HomeAssistant } from './types'; + +/** + * `HASSSource` is the observer-pattern API that `HASSManager` exposes for any + * long-lived code that needs to react to HASS changes. The listener fires on + * every non-null HASS push; `oldHass` is the previous value (null on first + * fire). + */ +export type HASSListener = (hass: HomeAssistant, oldHass: HomeAssistant | null) => void; + +export type HASSUnlistenCallback = () => void; + +export interface HASSSource { + getHASS(): HomeAssistant | null; + addListener(listener: HASSListener): HASSUnlistenCallback; +} diff --git a/src/health.ts b/src/health.ts new file mode 100644 index 00000000..25a6eabb --- /dev/null +++ b/src/health.ts @@ -0,0 +1,16 @@ +// Returned by `addListener`; invoke it to stop listening. +export type UnlistenCallback = () => void; + +// A source of health information: the current failures (of some domain-specific +// shape `F`) and a way to observe changes to them. +interface HealthInterface { + getFailures(): F[]; + addListener(listener: () => void): UnlistenCallback; +} + +// Health that also supports a user-driven retry of whatever is currently +// failing. Separate from HealthInterface because observation and recovery are +// distinct capabilities: a read-only health source has nothing to retry. +export interface RecoverableHealthInterface extends HealthInterface { + retry(): void; +} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index a1b3397f..798964fb 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -844,6 +844,10 @@ "text": "Waiting for Home Assistant startup to complete" } }, + "event_subscription": { + "heading": "Home Assistant event subscriptions", + "text": "The card could not subscribe to one or more Home Assistant event types and will keep retrying" + }, "initialization": { "heading": "Initialization failed" }, diff --git a/src/utils/concurrency/keyed-subscription-manager.ts b/src/utils/concurrency/keyed-subscription-manager.ts index cfcee7d1..b073092b 100644 --- a/src/utils/concurrency/keyed-subscription-manager.ts +++ b/src/utils/concurrency/keyed-subscription-manager.ts @@ -1,7 +1,14 @@ import PQueue from 'p-queue'; -type UnsubscribeFn = () => Promise; -type SubscribeFn = () => Promise; +type UnsubscribeCallback = () => Promise; +type SubscribeCallback = () => Promise; + +/** + * Extracts the key from a request. Used by `KeyedSubscriptionManager` and any + * higher-level wrapper that shares its request-to-key mapping (e.g. the HASS + * connection subscription manager). + */ +export type GetKeyCallback = (request: R) => K; /** * Manages subscriptions keyed by `K`: the first subscriber for a key invokes @@ -15,27 +22,36 @@ type SubscribeFn = () => Promise; */ export class KeyedSubscriptionManager { private _requests: R[] = []; - private _unsubscribers = new Map(); + private _unsubscribers = new Map(); private _queues = new Map(); - private _getKeyFn: (request: R) => K; + private _getKeyCallback: GetKeyCallback; - constructor(getKeyFn: (request: R) => K) { - this._getKeyFn = getKeyFn; + constructor(getKeyCallback: GetKeyCallback) { + this._getKeyCallback = getKeyCallback; } - public async subscribe(request: R, subscribeFn: SubscribeFn): Promise { - const key = this._getKeyFn(request); + public async subscribe( + request: R, + subscribeCallback: SubscribeCallback, + ): Promise { + const key = this._getKeyCallback(request); await this._queueFor(key).add(async () => { this._requests.push(request); if (!this._unsubscribers.has(key)) { - const unsubscribe = await subscribeFn(); - this._unsubscribers.set(key, unsubscribe); + try { + this._unsubscribers.set(key, await subscribeCallback()); + } catch (e) { + // Roll back the orphan request so it doesn't sit in `_requests` + // dispatching against a connection that was never established. + this._requests = this._requests.filter((r) => r !== request); + throw e; + } } }); } public async unsubscribe(request: R): Promise { - const key = this._getKeyFn(request); + const key = this._getKeyCallback(request); await this._queueFor(key).add(async () => { this._requests = this._requests.filter((r) => r !== request); if (!this._hasSubscribers(key)) { @@ -47,7 +63,7 @@ export class KeyedSubscriptionManager { } public getRequestsForKey(key: K): readonly R[] { - return this._requests.filter((r) => this._getKeyFn(r) === key); + return this._requests.filter((r) => this._getKeyCallback(r) === key); } private _queueFor(key: K): PQueue { @@ -60,6 +76,6 @@ export class KeyedSubscriptionManager { } private _hasSubscribers(key: K): boolean { - return this._requests.some((r) => this._getKeyFn(r) === key); + return this._requests.some((r) => this._getKeyCallback(r) === key); } } diff --git a/src/utils/exponential-backoff.ts b/src/utils/exponential-backoff.ts new file mode 100644 index 00000000..3d960365 --- /dev/null +++ b/src/utils/exponential-backoff.ts @@ -0,0 +1,85 @@ +// Default jitter range applied to each computed delay: a random multiplier in +// [50%, 100%] of the pre-jitter value, avoiding thundering-herd retries when +// multiple instances back off in lockstep. +const DEFAULT_JITTER_MIN = 0.5; +const DEFAULT_JITTER_MAX = 1.0; + +export interface ExponentialBackoffOptions { + // Delay for the first retry (attempt 1). Subsequent attempts double the delay + // until `maxSeconds` is reached. + baseSeconds: number; + + // Upper bound on the delay after exponential growth. The delay never exceeds + // this regardless of attempt count. + maxSeconds: number; + + // Random multiplier applied to each computed delay. Defaults to + // [DEFAULT_JITTER_MIN, DEFAULT_JITTER_MAX]. + jitterMin?: number; + jitterMax?: number; +} + +/** + * Stateful exponential-backoff delay calculator. Holds an attempt counter, + * returns the next delay on each `next()` call, and can be `reset()` after a + * successful operation. + * + * Example: + * ```ts + * const backoff = new ExponentialBackoff({ baseSeconds: 1, maxSeconds: 300 }); + * // 1st failure -> backoff.next() returns ~1s (jittered). + * // 2nd failure -> ~2s. + * // 3rd failure -> ~4s. ... -> 300s ceiling. + * // After success: backoff.reset(). + * ``` + */ +export class ExponentialBackoff { + private _baseSeconds = 0; + private _maxSeconds = 0; + private _jitterMin = DEFAULT_JITTER_MIN; + private _jitterMax = DEFAULT_JITTER_MAX; + private _attempts = 0; + + constructor(options: ExponentialBackoffOptions) { + this.setOptions(options); + } + + public setOptions(options: ExponentialBackoffOptions): void { + this._baseSeconds = options.baseSeconds; + this._maxSeconds = options.maxSeconds; + this._jitterMin = options.jitterMin ?? DEFAULT_JITTER_MIN; + this._jitterMax = options.jitterMax ?? DEFAULT_JITTER_MAX; + } + + /** + * Returns the next delay in seconds and increments the attempt counter. The + * pre-jitter delay is `baseSeconds * 2^(attempts before increment)`, capped + * at `maxSeconds`. Jitter is a random multiplier in [jitterMin, jitterMax]. + */ + public next(): number { + const delay = this.peek(); + this._attempts += 1; + return delay; + } + + /** + * Returns what the next `next()` call would return WITHOUT incrementing the + * counter. Useful for "re-arm at the same backoff level" cases (a scheduled + * retry deferred for an unrelated reason; don't compound the backoff). Note + * jitter is re-rolled each call, so two consecutive `peek()`s may return + * slightly different values for the same attempt count. + */ + public peek(): number { + const exp = Math.min(this._maxSeconds, this._baseSeconds * 2 ** this._attempts); + const jitter = this._jitterMin + Math.random() * (this._jitterMax - this._jitterMin); + return exp * jitter; + } + + public reset(): void { + this._attempts = 0; + } + + public getAttempts(): number { + return this._attempts; + } +} diff --git a/src/utils/initializer/initializer.ts b/src/utils/initializer/initializer.ts index 95a0b0bf..f7197094 100644 --- a/src/utils/initializer/initializer.ts +++ b/src/utils/initializer/initializer.ts @@ -3,13 +3,22 @@ import { allPromises } from '../basic'; type InitializationCallback = () => Promise; /** - * Manages initialization state & calling initializers. There is no guarantee - * something will not be initialized twice unless there are concurrency controls - * applied to the usage of this class. + * Manages initialization state and runs initializers. + * + * Safe when `uninitialize()` is called while an (async) initializer is still + * running: that initializer's result is discarded instead of marking the aspect + * initialized again. (Two initializers running for the same aspect at once is + * still the caller's job to avoid.) */ export class Initializer { private _initialized: Set = new Set(); + // Bumped on every `uninitialize()`. An `initializeIfNecessary()` captures the + // generation before awaiting its initializer and, on completion, only records + // success if the generation is unchanged -- i.e. no `uninitialize()` for that + // aspect landed while it was running. + private _generation: Map = new Map(); + public async initializeMultipleIfNecessary( aspects: Record, ): Promise { @@ -26,14 +35,22 @@ export class Initializer { if (this._initialized.has(aspect)) { return; } + const generation = this._generation.get(aspect) ?? 0; if (initializer) { await initializer(); } + // If `uninitialize()` ran while we were awaiting, a newer attempt has taken + // over -- throw this result away (don't mark it initialized) so a stale + // result can't leave the card stuck, and a fresh attempt runs next time. + if ((this._generation.get(aspect) ?? 0) !== generation) { + return; + } this._initialized.add(aspect); } public uninitialize(aspect: string): void { this._initialized.delete(aspect); + this._generation.set(aspect, (this._generation.get(aspect) ?? 0) + 1); } public isInitialized(aspect: string): boolean { diff --git a/src/utils/retry-timer.ts b/src/utils/retry-timer.ts new file mode 100644 index 00000000..5d815baa --- /dev/null +++ b/src/utils/retry-timer.ts @@ -0,0 +1,98 @@ +import { ExponentialBackoff, ExponentialBackoffOptions } from './exponential-backoff'; +import { Timer } from './timer'; + +// Expand a plain `number` (fixed delay in seconds) into the equivalent +// `ExponentialBackoffOptions`: base = max so growth flattens, jitter pinned to +// 1.0 so the delay is exactly N every time. +const convertToBackoffOptions = ( + options: ExponentialBackoffOptions | number, +): ExponentialBackoffOptions => { + if (typeof options === 'number') { + return { + baseSeconds: options, + maxSeconds: options, + jitterMin: 1, + jitterMax: 1, + }; + } + return options; +}; + +/** + * Pairs an `ExponentialBackoff` with a `Timer` for retry scheduling. Each + * `schedule(...)` call fires after the current backoff delay; `advance()` + * bumps the counter for next time. + * + * Constructor and `setOptions` accept either `ExponentialBackoffOptions` + * (growth + jitter) or a plain `number` (fixed delay in seconds, no growth, + * no jitter). Internally a number expands to `{ baseSeconds: N, maxSeconds: + * N, jitterMin: 1, jitterMax: 1 }` so all methods behave uniformly -- callers + * never branch. + * + * Typical patterns: + * - "Failure happened, retry later, count this failure": `schedule(cb)`. + * - "Retry deferred for an unrelated reason; don't compound": + * `schedule(cb, { advance: false })` (re-arms at the current delay). + * - "Attempt happened, count it separately from scheduling": `advance()`. + * - "Operation succeeded": `reset()`. + * - "Caller going away": `cancel()`. + */ +export class RetryTimer { + private readonly _backoff: ExponentialBackoff; + private readonly _timer = new Timer(); + + constructor(options: ExponentialBackoffOptions | number) { + this._backoff = new ExponentialBackoff(convertToBackoffOptions(options)); + } + + /** + * Replace the backoff configuration. Cheap and idempotent: doesn't cancel + * pending callbacks or reset the attempt counter, so it's safe to call on + * every scheduling pass regardless of whether the options actually changed. + * Call `reset()` separately if zeroing the counter is desired (e.g. on a + * semantically distinct mode switch). + */ + public setOptions(options: ExponentialBackoffOptions | number): void { + this._backoff.setOptions(convertToBackoffOptions(options)); + } + + /** + * Schedule `callback` to fire after the current delay, then advance the + * attempt counter so the next schedule uses a longer delay. Pass + * `{ advance: false }` to re-arm at the current delay without counting it + * (e.g. a retry that may be gated and re-scheduled). Any pending callback + * is canceled before scheduling. + */ + public schedule(callback: () => void, options?: { advance?: boolean }): void { + this._timer.start(this._backoff.peek(), callback); + if (options?.advance !== false) { + this._backoff.next(); + } + } + + /** + * Bump the attempt counter without scheduling. For flows where the schedule + * call and the "attempt happened, count it" event are separate (e.g. the + * scheduled callback may or may not actually retry, depending on a gate). + */ + public advance(): void { + this._backoff.next(); + } + + public cancel(): void { + this._timer.stop(); + } + + public reset(): void { + this._timer.stop(); + this._backoff.reset(); + } + + public isRunning(): boolean { + return this._timer.isRunning(); + } + + public getAttempts(): number { + return this._backoff.getAttempts(); + } +} 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 55743fa9..52d169b8 100644 --- a/tests/camera-manager/browse-media/engine-browse-media.test.ts +++ b/tests/camera-manager/browse-media/engine-browse-media.test.ts @@ -7,21 +7,18 @@ import { CameraQuery, QueryType, } from '../../../src/camera-manager/types'; -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 { EntityRegistryManagerMock } from '../../ha/registry/entity/mock'; -import { createCameraConfig, createHASS } from '../../test-utils'; +import { createCameraConfig, createHASS, createHASSManager } from '../../test-utils'; const createEngine = (): BrowseMediaCameraManagerEngine => { return new BrowseMediaCameraManagerEngine( new EntityRegistryManagerMock(), - mock(), - mock(), + createHASSManager(), new BrowseMediaWalker(), new ResolvedMediaCache(), new CameraManagerRequestCache(), diff --git a/tests/camera-manager/camera.test.ts b/tests/camera-manager/camera.test.ts index c68db75e..40642ffb 100644 --- a/tests/camera-manager/camera.test.ts +++ b/tests/camera-manager/camera.test.ts @@ -13,6 +13,8 @@ import { createCameraConfig, createCapabilities, createHASS, + createHASSManager, + createHASSEvent, createInitializedCamera, createRegistryEntity, createStateEntity, @@ -25,10 +27,7 @@ describe('Camera', () => { const config = createCameraConfig(); const camera = new Camera( config, - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); expect(camera.getConfig()).toBe(config); }); @@ -38,10 +37,7 @@ describe('Camera', () => { const capabilities = createCapabilities(); const camera = await createInitializedCamera( createCameraConfig(), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), capabilities, ); expect(camera.getCapabilities()).toBe(capabilities); @@ -50,20 +46,14 @@ describe('Camera', () => { it('when unpopulated', async () => { const camera = new Camera( createCameraConfig(), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); expect(camera.getCapabilities()).toBeNull(); }); }); it('should get engine', async () => { - const engine = new GenericCameraManagerEngine( - mock(), - mock(), - ); + const engine = new GenericCameraManagerEngine(createHASSManager()); const camera = new Camera(createCameraConfig(), engine); expect(camera.getEngine()).toBe(engine); }); @@ -71,10 +61,7 @@ describe('Camera', () => { it('should set and get id', async () => { const camera = new Camera( createCameraConfig(), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); camera.setID('foo'); expect(camera.getID()).toBe('foo'); @@ -84,10 +71,7 @@ describe('Camera', () => { it('should throw without id', async () => { const camera = new Camera( createCameraConfig(), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); expect(() => camera.getID()).toThrowError( 'Could not determine camera id for the following ' + @@ -107,17 +91,12 @@ describe('Camera', () => { entities: ['camera.foo'], }, }), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); const stateWatcher = mock(); await camera.initialize({ - hass: createHASS(), - stateWatcher: stateWatcher, - eventWatcher: mock(), + hassManager: createHASSManager({ stateWatcher }), capabilityOptions: { capabilities: createCapabilities({ trigger: true }) }, }); @@ -130,6 +109,26 @@ describe('Camera', () => { expect(stateWatcher.unsubscribe).toBeCalled(); }); + it('should skip initialization when hass is unavailable', async () => { + const camera = new Camera( + createCameraConfig({ + triggers: { + entities: ['camera.foo'], + }, + }), + new GenericCameraManagerEngine(createHASSManager()), + ); + + const stateWatcher = mock(); + await camera.initialize({ + hassManager: createHASSManager({ hass: null, stateWatcher }), + capabilityOptions: { capabilities: createCapabilities({ trigger: true }) }, + }); + + expect(stateWatcher.subscribe).not.toBeCalled(); + expect(camera.getCapabilities()).toBeNull(); + }); + it('should set capabilities and use go2rtc metadata endpoint', async () => { const camera = new Camera( createCameraConfig({ @@ -138,18 +137,13 @@ describe('Camera', () => { stream: 'stream', }, }), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true); await camera.initialize({ - hass: createHASS(), - stateWatcher: mock(), - eventWatcher: mock(), + hassManager: createHASSManager(), }); expect(liveProviderSupports2WayAudio).toHaveBeenCalledWith( @@ -180,18 +174,13 @@ describe('Camera', () => { stream: 'stream', }, }), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(false); await camera.initialize({ - hass: createHASS(), - stateWatcher: mock(), - eventWatcher: mock(), + hassManager: createHASSManager(), }); expect(camera.getCapabilities()?.has('2-way-audio')).toBe(false); @@ -206,18 +195,13 @@ describe('Camera', () => { metadata_fetch_timeout_seconds: 20, }, }), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true); await camera.initialize({ - hass: createHASS(), - stateWatcher: mock(), - eventWatcher: mock(), + hassManager: createHASSManager(), }); expect(liveProviderSupports2WayAudio).toHaveBeenCalledWith( @@ -240,10 +224,7 @@ describe('Camera', () => { live: true, }, }), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true); @@ -252,9 +233,7 @@ describe('Camera', () => { hass.config.components = ['hass_web_proxy']; await camera.initialize({ - hass, - stateWatcher: mock(), - eventWatcher: mock(), + hassManager: createHASSManager({ hass }), }); expect(liveProviderSupports2WayAudio).toHaveBeenCalledWith( @@ -281,10 +260,7 @@ describe('Camera', () => { createCameraConfig({ proxy: { live: true }, }), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); expect(camera.getLiveProxyConfig()).toEqual( expect.objectContaining({ enabled: true, enforce: true }), @@ -296,10 +272,7 @@ describe('Camera', () => { createCameraConfig({ proxy: { media: true }, }), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); expect(camera.getMediaProxyConfig()).toEqual( expect.objectContaining({ enabled: true, enforce: true }), @@ -312,10 +285,7 @@ describe('Camera', () => { live_provider: 'go2rtc', go2rtc: { url: 'http://go2rtc' }, }), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); expect(camera.getLiveProxyConfig()).toEqual( expect.objectContaining({ enabled: true, enforce: false }), @@ -327,10 +297,7 @@ describe('Camera', () => { createCameraConfig({ proxy: { media: 'auto' }, }), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); expect(camera.getMediaProxyConfig()).toEqual( expect.objectContaining({ enabled: false, enforce: false }), @@ -344,16 +311,11 @@ describe('Camera', () => { force: ['2-way-audio'], }, }), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); await camera.initialize({ - hass: createHASS(), - stateWatcher: mock(), - eventWatcher: mock(), + hassManager: createHASSManager(), }); expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled(); @@ -368,16 +330,11 @@ describe('Camera', () => { force: ['2-way-audio'], }, }), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); await camera.initialize({ - hass: createHASS(), - stateWatcher: mock(), - eventWatcher: mock(), + hassManager: createHASSManager(), }); expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled(); @@ -392,16 +349,11 @@ describe('Camera', () => { force: ['2-way-audio'], }, }), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); await camera.initialize({ - hass: createHASS(), - stateWatcher: mock(), - eventWatcher: mock(), + hassManager: createHASSManager(), }); expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled(); @@ -415,16 +367,11 @@ describe('Camera', () => { disable: ['2-way-audio'], }, }), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); await camera.initialize({ - hass: createHASS(), - stateWatcher: mock(), - eventWatcher: mock(), + hassManager: createHASSManager(), }); expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled(); @@ -438,16 +385,11 @@ describe('Camera', () => { disable_except: ['substream'], }, }), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); await camera.initialize({ - hass: createHASS(), - stateWatcher: mock(), - eventWatcher: mock(), + hassManager: createHASSManager(), }); expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled(); @@ -461,17 +403,12 @@ describe('Camera', () => { disable_except: ['substream', '2-way-audio'], }, }), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true); await camera.initialize({ - hass: createHASS(), - stateWatcher: mock(), - eventWatcher: mock(), + hassManager: createHASSManager(), }); expect(liveProviderSupports2WayAudio).toHaveBeenCalled(); @@ -485,17 +422,12 @@ describe('Camera', () => { disable_except: [], }, }), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true); await camera.initialize({ - hass: createHASS(), - stateWatcher: mock(), - eventWatcher: mock(), + hassManager: createHASSManager(), }); expect(liveProviderSupports2WayAudio).toHaveBeenCalled(); @@ -510,16 +442,11 @@ describe('Camera', () => { }); const camera = new Camera( createCameraConfig({ camera_entity: 'camera.front_door' }), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); await camera.initialize({ - hass: createHASS(), - stateWatcher: mock(), - eventWatcher: mock(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]), }); @@ -529,16 +456,11 @@ describe('Camera', () => { it('should leave entity null when camera_entity is unset', async () => { const camera = new Camera( createCameraConfig(), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); await camera.initialize({ - hass: createHASS(), - stateWatcher: mock(), - eventWatcher: mock(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock(), }); @@ -548,16 +470,11 @@ 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(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); await camera.initialize({ - hass: createHASS(), - stateWatcher: mock(), - eventWatcher: mock(), + hassManager: createHASSManager(), }); expect(camera.getEntity()).toBeNull(); @@ -594,10 +511,7 @@ describe('Camera', () => { ...(options?.userEntities && { entities: options.userEntities }), }, }), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); const stateWatcher = mock(); const hass = createHASS( @@ -609,9 +523,7 @@ describe('Camera', () => { }, ); await camera.initialize({ - hass, - stateWatcher, - eventWatcher: mock(), + hassManager: createHASSManager({ hass, stateWatcher }), ...(!options?.omitRegistryManager && { entityRegistryManager: new EntityRegistryManagerMock( options?.registryEntities ?? [cameraEntity, doorbellEntity], @@ -737,10 +649,7 @@ describe('Camera', () => { entities: ['binary_sensor.foo'], }, }), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), { eventCallback: eventCallback, }, @@ -748,9 +657,7 @@ describe('Camera', () => { const stateWatcher = mock(); await camera.initialize({ - hass: createHASS(), - stateWatcher: stateWatcher, - eventWatcher: mock(), + hassManager: createHASSManager({ stateWatcher }), capabilityOptions: { capabilities: createCapabilities({ trigger: true }) }, }); @@ -780,27 +687,25 @@ describe('Camera', () => { events: [{ event_type: 'zha_event' }], }, }), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), { eventCallback }, ); const eventWatcher = mock(); await camera.initialize({ - hass: createHASS(), - stateWatcher: mock(), - eventWatcher, + hassManager: createHASSManager({ eventWatcher }), capabilityOptions: { capabilities: createCapabilities({ trigger: true }) }, }); expect(eventWatcher.subscribe).toBeCalledTimes(1); - const request = vi.mocked(eventWatcher.subscribe).mock.calls[0][1]; + const request = vi.mocked(eventWatcher.subscribe).mock.calls[0][0]; expect(request.event_type).toBe('zha_event'); expect(request.matcher).toBeUndefined(); - callEventWatcherCallback(eventWatcher, { command: 'press' }); + callEventWatcherCallback( + eventWatcher, + createHASSEvent('zha_event', { command: 'press' }), + ); expect(eventCallback).toBeCalledWith({ cameraID: 'camera_1', @@ -812,6 +717,63 @@ describe('Camera', () => { expect(eventWatcher.unsubscribe).toBeCalled(); }); + it('should attach a context-only matcher when only a context filter is set', async () => { + const camera = new Camera( + createCameraConfig({ + id: 'camera_1', + triggers: { + events: [{ event_type: 'zha_event', context: { user_id: 'u-1' } }], + }, + }), + new GenericCameraManagerEngine(createHASSManager()), + ); + + const eventWatcher = mock(); + await camera.initialize({ + hassManager: createHASSManager({ eventWatcher }), + capabilityOptions: { capabilities: createCapabilities({ trigger: true }) }, + }); + + const matcher = vi.mocked(eventWatcher.subscribe).mock.calls[0][0].matcher; + expect(matcher).toBeDefined(); + expect( + matcher?.( + createHASSEvent('zha_event', {}, { id: 'i', user_id: 'u-1', parent_id: null }), + ), + ).toBe(true); + expect( + matcher?.( + createHASSEvent('zha_event', {}, { id: 'i', user_id: 'u-2', parent_id: null }), + ), + ).toBe(false); + }); + + it('should expand list-form event_type into one subscription per type', async () => { + const camera = new Camera( + createCameraConfig({ + id: 'camera_1', + triggers: { + events: [{ event_type: ['zha_event', 'deconz_event'] }], + }, + }), + new GenericCameraManagerEngine(createHASSManager()), + ); + + const eventWatcher = mock(); + await camera.initialize({ + hassManager: createHASSManager({ eventWatcher }), + capabilityOptions: { capabilities: createCapabilities({ trigger: true }) }, + }); + + expect(eventWatcher.subscribe).toBeCalledTimes(2); + expect(vi.mocked(eventWatcher.subscribe).mock.calls[0][0].event_type).toBe( + 'zha_event', + ); + expect(vi.mocked(eventWatcher.subscribe).mock.calls[1][0].event_type).toBe( + 'deconz_event', + ); + }); + it('should attach a matcher when triggers.events entry has data filter', async () => { const camera = new Camera( createCameraConfig({ @@ -820,24 +782,23 @@ describe('Camera', () => { events: [{ event_type: 'zha_event', event_data: { command: 'press' } }], }, }), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); const eventWatcher = mock(); await camera.initialize({ - hass: createHASS(), - stateWatcher: mock(), - eventWatcher, + hassManager: createHASSManager({ eventWatcher }), capabilityOptions: { capabilities: createCapabilities({ trigger: true }) }, }); - const matcher = vi.mocked(eventWatcher.subscribe).mock.calls[0][1].matcher; + const matcher = vi.mocked(eventWatcher.subscribe).mock.calls[0][0].matcher; expect(matcher).toBeDefined(); - expect(matcher?.({ command: 'press', extra: 1 })).toBe(true); - expect(matcher?.({ command: 'release' })).toBe(false); + expect( + matcher?.(createHASSEvent('zha_event', { command: 'press', extra: 1 })), + ).toBe(true); + expect(matcher?.(createHASSEvent('zha_event', { command: 'release' }))).toBe( + false, + ); }); it('should not subscribe to events when trigger capability is disabled', async () => { @@ -848,17 +809,12 @@ describe('Camera', () => { events: [{ event_type: 'zha_event' }], }, }), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); const eventWatcher = mock(); await camera.initialize({ - hass: createHASS(), - stateWatcher: mock(), - eventWatcher, + hassManager: createHASSManager({ eventWatcher }), capabilityOptions: { capabilities: createCapabilities({ trigger: false }) }, }); @@ -876,10 +832,7 @@ describe('Camera', () => { entities: ['event.front_door_doorbell'], }, }), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), { eventCallback: eventCallback, }, @@ -887,9 +840,7 @@ describe('Camera', () => { const stateWatcher = mock(); await camera.initialize({ - hass: createHASS(), - stateWatcher: stateWatcher, - eventWatcher: mock(), + hassManager: createHASSManager({ stateWatcher }), capabilityOptions: { capabilities: createCapabilities({ trigger: true }) }, }); @@ -913,10 +864,7 @@ describe('Camera', () => { entities: ['event.front_door_doorbell'], }, }), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), { eventCallback: eventCallback, }, @@ -924,9 +872,7 @@ describe('Camera', () => { const stateWatcher = mock(); await camera.initialize({ - hass: createHASS(), - stateWatcher: stateWatcher, - eventWatcher: mock(), + hassManager: createHASSManager({ stateWatcher }), capabilityOptions: { capabilities: createCapabilities({ trigger: true }) }, }); @@ -953,10 +899,7 @@ describe('Camera', () => { entities: ['binary_sensor.foo'], }, }), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), { eventCallback: eventCallback, }, @@ -964,9 +907,7 @@ describe('Camera', () => { const stateWatcher = mock(); await camera.initialize({ - hass: createHASS(), - stateWatcher: stateWatcher, - eventWatcher: mock(), + hassManager: createHASSManager({ stateWatcher }), capabilityOptions: { capabilities: createCapabilities({ trigger: false }) }, }); @@ -1128,10 +1069,7 @@ describe('Camera', () => { (_name: string, cameraConfig: unknown, expectedResult: CameraProxyConfig) => { const camera = new Camera( createCameraConfig(cameraConfig), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); expect(camera.getProxyConfig()).toEqual(expectedResult); }, @@ -1145,10 +1083,7 @@ describe('Camera', () => { go2rtc: { stream: '' }, camera_entity: '', }), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); expect(camera.getEndpoints()).toBeNull(); }); @@ -1162,10 +1097,7 @@ describe('Camera', () => { }, camera_entity: 'camera.foo', }), - new GenericCameraManagerEngine( - mock(), - mock(), - ), + new GenericCameraManagerEngine(createHASSManager()), ); expect(camera.getEndpoints()).toEqual({ diff --git a/tests/camera-manager/engine-factory.test.ts b/tests/camera-manager/engine-factory.test.ts index dbe00e84..41923e2e 100644 --- a/tests/camera-manager/engine-factory.test.ts +++ b/tests/camera-manager/engine-factory.test.ts @@ -7,8 +7,6 @@ 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 { DeviceRegistryManager } from '../../src/ha/registry/device'; import { EntityRegistryManager } from '../../src/ha/registry/entity/types.js'; @@ -17,6 +15,7 @@ import { EntityRegistryManagerMock } from '../ha/registry/entity/mock.js'; import { createCameraConfig, createHASS, + createHASSManager, createRegistryEntity, createStateEntity, } from '../test-utils'; @@ -232,8 +231,7 @@ describe('createEngine()', () => { it('should create generic engine', async () => { expect( await createFactory().createEngine(Engine.Generic, { - stateWatcher: mock(), - eventWatcher: mock(), + hassManager: createHASSManager(), resolvedMediaCache: mock(), }), ).toBeInstanceOf(GenericCameraManagerEngine); @@ -241,8 +239,7 @@ describe('createEngine()', () => { it('should create frigate engine', async () => { expect( await createFactory().createEngine(Engine.Frigate, { - stateWatcher: mock(), - eventWatcher: mock(), + hassManager: createHASSManager(), resolvedMediaCache: mock(), }), ).toBeInstanceOf(FrigateCameraManagerEngine); @@ -250,8 +247,7 @@ describe('createEngine()', () => { it('should create motioneye engine', async () => { expect( await createFactory().createEngine(Engine.MotionEye, { - stateWatcher: mock(), - eventWatcher: mock(), + hassManager: createHASSManager(), resolvedMediaCache: mock(), }), ).toBeInstanceOf(MotionEyeCameraManagerEngine); @@ -259,8 +255,7 @@ describe('createEngine()', () => { it('should create reolink engine', async () => { expect( await createFactory().createEngine(Engine.Reolink, { - stateWatcher: mock(), - eventWatcher: mock(), + hassManager: createHASSManager(), resolvedMediaCache: mock(), }), ).toBeInstanceOf(ReolinkCameraManagerEngine); @@ -268,8 +263,7 @@ describe('createEngine()', () => { it('should create tplink engine', async () => { expect( await createFactory().createEngine(Engine.TPLink, { - stateWatcher: mock(), - eventWatcher: mock(), + hassManager: createHASSManager(), resolvedMediaCache: mock(), }), ).toBeInstanceOf(TPLinkCameraManagerEngine); diff --git a/tests/camera-manager/frigate/camera.test.ts b/tests/camera-manager/frigate/camera.test.ts index aff8debc..642d3689 100644 --- a/tests/camera-manager/frigate/camera.test.ts +++ b/tests/camera-manager/frigate/camera.test.ts @@ -1,5 +1,5 @@ import { format } from 'date-fns'; -import { assert, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { CameraManagerEngine } from '../../../src/camera-manager/engine'; import { FrigateCamera } from '../../../src/camera-manager/frigate/camera'; @@ -18,8 +18,6 @@ import { FrigateReviewWatcher, } from '../../../src/camera-manager/frigate/watcher'; import { ActionsExecutor } from '../../../src/card-controller/actions/types'; -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 { CameraTriggerMediaEventType } from '../../../src/config/schema/cameras'; import { Entity, EntityRegistryManager } from '../../../src/ha/registry/entity/types'; @@ -29,6 +27,7 @@ import { createCameraConfig, createCapabilities, createHASS, + createHASSManager, createRegistryEntity, createStateEntity, } from '../../test-utils'; @@ -42,7 +41,7 @@ const callEventWatcherCallback = ( ): void => { const mock = vi.mocked(eventWatcher.subscribe).mock; expect(mock.calls.length).greaterThan(n); - mock.calls[n][1].callback(event); + mock.calls[n][0].callback(event); }; const callReviewWatcherCallback = ( @@ -52,7 +51,7 @@ const callReviewWatcherCallback = ( ): void => { const mock = vi.mocked(reviewWatcher.subscribe).mock; expect(mock.calls.length).greaterThan(n); - mock.calls[n][1].callback(review); + mock.calls[n][0].callback(review); }; describe('FrigateCamera', () => { @@ -69,10 +68,8 @@ describe('FrigateCamera', () => { const beforeConfig = { ...config }; await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -92,10 +89,8 @@ describe('FrigateCamera', () => { expect( async () => await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: entityRegistryManager, - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }), @@ -118,10 +113,8 @@ describe('FrigateCamera', () => { ]); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: entityRegistryManager, - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -144,10 +137,8 @@ describe('FrigateCamera', () => { ]); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: entityRegistryManager, - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -170,10 +161,8 @@ describe('FrigateCamera', () => { ]); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: entityRegistryManager, - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -191,15 +180,15 @@ describe('FrigateCamera', () => { mock(), ); await camera.initialize({ - hass: createHASS({ - 'camera.front_door': createStateEntity({ - entity_id: 'camera.front_door', - attributes: { client_id: 'remote_frigate' }, + hassManager: createHASSManager({ + hass: createHASS({ + 'camera.front_door': createStateEntity({ + entity_id: 'camera.front_door', + attributes: { client_id: 'remote_frigate' }, + }), }), }), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -215,15 +204,15 @@ describe('FrigateCamera', () => { mock(), ); await camera.initialize({ - hass: createHASS({ - 'camera.front_door': createStateEntity({ - entity_id: 'camera.front_door', - attributes: {}, + hassManager: createHASSManager({ + hass: createHASS({ + 'camera.front_door': createStateEntity({ + entity_id: 'camera.front_door', + attributes: {}, + }), }), }), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -238,10 +227,8 @@ describe('FrigateCamera', () => { mock(), ); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -257,15 +244,15 @@ describe('FrigateCamera', () => { mock(), ); await camera.initialize({ - hass: createHASS({ - 'camera.front_door': createStateEntity({ - entity_id: 'camera.front_door', - attributes: { client_id: 'something_else' }, + hassManager: createHASSManager({ + hass: createHASS({ + 'camera.front_door': createStateEntity({ + entity_id: 'camera.front_door', + attributes: { client_id: 'something_else' }, + }), }), }), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -281,16 +268,16 @@ describe('FrigateCamera', () => { mock(), ); await camera.initialize({ - hass: createHASS({ - 'camera.front_door': createStateEntity({ - entity_id: 'camera.front_door', - state: 'unavailable', - attributes: {}, + hassManager: createHASSManager({ + hass: createHASS({ + 'camera.front_door': createStateEntity({ + entity_id: 'camera.front_door', + state: 'unavailable', + attributes: {}, + }), }), }), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -311,10 +298,8 @@ describe('FrigateCamera', () => { ); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -340,10 +325,8 @@ describe('FrigateCamera', () => { ); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -373,10 +356,8 @@ describe('FrigateCamera', () => { vi.mocked(getPTZInfo).mockRejectedValue(new Error()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -404,10 +385,8 @@ describe('FrigateCamera', () => { }); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -442,10 +421,8 @@ describe('FrigateCamera', () => { }); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -475,10 +452,8 @@ describe('FrigateCamera', () => { }); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -512,10 +487,8 @@ describe('FrigateCamera', () => { }); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -842,15 +815,12 @@ describe('FrigateCamera', () => { const eventWatcher = mock(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: eventWatcher, frigateReviewWatcher: mock(), }); expect(eventWatcher.subscribe).toBeCalledWith( - hass, expect.objectContaining({ instanceID: 'CLIENT_ID', }), @@ -874,10 +844,8 @@ describe('FrigateCamera', () => { const eventWatcher = mock(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: eventWatcher, frigateReviewWatcher: mock(), }); @@ -901,10 +869,8 @@ describe('FrigateCamera', () => { const eventWatcher = mock(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: eventWatcher, frigateReviewWatcher: mock(), }); @@ -927,10 +893,8 @@ describe('FrigateCamera', () => { const eventWatcher = mock(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: eventWatcher, frigateReviewWatcher: mock(), }); @@ -953,10 +917,8 @@ describe('FrigateCamera', () => { const eventWatcher = mock(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: eventWatcher, frigateReviewWatcher: mock(), }); @@ -966,9 +928,10 @@ describe('FrigateCamera', () => { expect(eventWatcher.unsubscribe).toBeCalled(); }); - it('should unsubscribe on destroy while event subscription is pending', async () => { + it('should not subscribe when destroyed while base initialization is pending', async () => { const camera = new FrigateCamera( createCameraConfig({ + camera_entity: 'camera.front_door', frigate: { client_id: 'CLIENT_ID', camera_name: 'front_door' }, triggers: { media_events: ['events'], @@ -978,45 +941,40 @@ describe('FrigateCamera', () => { mock(), ); const hass = createHASS(); - let resolveSubscribe: () => void = () => {}; const eventWatcher = mock(); const reviewWatcher = mock(); - vi.mocked(eventWatcher.subscribe).mockReturnValue( - new Promise((resolve) => { - resolveSubscribe = resolve; + + // Pend base initialization on entity resolution so destroy() can flip + // `_destroyed` before initialize() reaches the subscribe calls. + let resolveEntity: () => void = () => {}; + const entityRegistryManager = mock(); + vi.mocked(entityRegistryManager.getEntity).mockReturnValue( + new Promise((resolve) => { + resolveEntity = () => resolve(createRegistryEntity()); }), ); const initializePromise = camera.initialize({ - hass: hass, - entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), + hassManager: createHASSManager({ hass }), + entityRegistryManager: entityRegistryManager, frigateEventWatcher: eventWatcher, frigateReviewWatcher: reviewWatcher, - - // Pre-built so `_buildCapabilities` (which calls the un-mocked - // `liveProviderSupports2WayAudio`) is skipped and init reaches the - // pending Frigate event subscribe. capabilityOptions: { capabilities: createCapabilities({ trigger: true }) }, }); - await vi.waitFor(() => expect(eventWatcher.subscribe).toBeCalled()); + await vi.waitFor(() => expect(entityRegistryManager.getEntity).toBeCalled()); await camera.destroy(); - // Destroy iterated `_destroyCallbacks` and called the unsubscribe that - // was registered before the (still pending) event subscribe. - const subscribeCall = vi.mocked(eventWatcher.subscribe).mock.calls[0]; - assert(subscribeCall); - expect(eventWatcher.unsubscribe).toBeCalledWith(subscribeCall[1]); + // `_destroyed` short-circuits initialize() after the pending await, so + // neither watcher is ever subscribed. + expect(eventWatcher.subscribe).not.toBeCalled(); + expect(reviewWatcher.subscribe).not.toBeCalled(); - resolveSubscribe(); + resolveEntity(); await initializePromise; - // The subsequent `_subscribeToReviews` short-circuited on `_destroyed`, - // so the review watcher was never subscribed (and so never needs an - // unsubscribe -- which would otherwise be ordered before the subscribe - // in the per-key PQueue and leak the resulting subscription). + expect(eventWatcher.subscribe).not.toBeCalled(); + expect(eventWatcher.unsubscribe).not.toBeCalled(); expect(reviewWatcher.subscribe).not.toBeCalled(); expect(reviewWatcher.unsubscribe).not.toBeCalled(); }); @@ -1106,10 +1064,8 @@ describe('FrigateCamera', () => { const hass = createHASS(); const eventWatcher = mock(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: eventWatcher, frigateReviewWatcher: mock(), }); @@ -1152,6 +1108,75 @@ describe('FrigateCamera', () => { ); }); + describe('should always forward end events to clear the trigger', () => { + it.each([ + ['with media still present', true, ['front_steps']], + ['with no media present at end', false, ['front_steps']], + ['even after the object left the configured zone', true, []], + ])('%s', async (_name: string, hasClip: boolean, currentZones: string[]) => { + const eventCallback = vi.fn(); + const camera = new FrigateCamera( + createCameraConfig({ + id: 'CAMERA_1', + frigate: { + camera_name: 'camera.front_door', + zones: ['front_steps'], + }, + triggers: { + media_events: ['clips'], + }, + }), + mock(), + { + eventCallback: eventCallback, + }, + ); + + const hass = createHASS(); + const eventWatcher = mock(); + await camera.initialize({ + hassManager: createHASSManager({ hass }), + entityRegistryManager: mock(), + frigateEventWatcher: eventWatcher, + frigateReviewWatcher: mock(), + }); + + // An 'end' clears the trigger regardless of the start criteria: the + // media may be unchanged or absent, and the object may have left the + // zone by now. + callEventWatcherCallback(eventWatcher, { + type: 'end', + before: { + id: 'event-1', + camera: 'camera.front_door', + snapshot: null, + has_clip: hasClip, + has_snapshot: false, + label: 'person', + current_zones: currentZones, + }, + after: { + id: 'event-1', + camera: 'camera.front_door', + snapshot: null, + has_clip: hasClip, + has_snapshot: false, + label: 'person', + current_zones: currentZones, + }, + }); + + expect(eventCallback).toBeCalledWith({ + type: 'end', + cameraID: 'CAMERA_1', + id: 'event-1', + clip: false, + snapshot: false, + fidelity: 'high', + }); + }); + }); + describe('should handle zones correctly', () => { it.each([ ['has no zone', [], false], @@ -1179,10 +1204,8 @@ describe('FrigateCamera', () => { const hass = createHASS(); const eventWatcher = mock(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: eventWatcher, frigateReviewWatcher: mock(), }); @@ -1239,10 +1262,8 @@ describe('FrigateCamera', () => { const hass = createHASS(); const eventWatcher = mock(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: eventWatcher, frigateReviewWatcher: mock(), }); @@ -1296,10 +1317,8 @@ describe('FrigateCamera', () => { const hass = createHASS(); const eventWatcher = mock(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: eventWatcher, frigateReviewWatcher: mock(), }); @@ -1350,15 +1369,12 @@ describe('FrigateCamera', () => { const reviewWatcher = mock(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: reviewWatcher, }); expect(reviewWatcher.subscribe).toBeCalledWith( - hass, expect.objectContaining({ instanceID: 'CLIENT_ID', }), @@ -1384,10 +1400,8 @@ describe('FrigateCamera', () => { const reviewWatcher = mock(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: reviewWatcher, }); @@ -1414,10 +1428,8 @@ describe('FrigateCamera', () => { const reviewWatcher = mock(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: reviewWatcher, }); @@ -1449,10 +1461,8 @@ describe('FrigateCamera', () => { const hass = createHASS(); const reviewWatcher = mock(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: reviewWatcher, }); @@ -1514,10 +1524,8 @@ describe('FrigateCamera', () => { const hass = createHASS(); const reviewWatcher = mock(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: reviewWatcher, }); @@ -1587,10 +1595,8 @@ describe('FrigateCamera', () => { const hass = createHASS(); const reviewWatcher = mock(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: reviewWatcher, }); @@ -1660,10 +1666,8 @@ describe('FrigateCamera', () => { const hass = createHASS(); const reviewWatcher = mock(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: reviewWatcher, }); @@ -1719,10 +1723,8 @@ describe('FrigateCamera', () => { const hass = createHASS(); const reviewWatcher = mock(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: reviewWatcher, }); @@ -1777,10 +1779,8 @@ describe('FrigateCamera', () => { const hass = createHASS(); const reviewWatcher = mock(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: reviewWatcher, }); @@ -1839,10 +1839,8 @@ describe('FrigateCamera', () => { const hass = createHASS(); const reviewWatcher = mock(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: reviewWatcher, }); @@ -1898,10 +1896,8 @@ describe('FrigateCamera', () => { const hass = createHASS(); const reviewWatcher = mock(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: reviewWatcher, }); @@ -1965,10 +1961,8 @@ describe('FrigateCamera', () => { const hass = createHASS(); const reviewWatcher = mock(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: reviewWatcher, }); @@ -2044,10 +2038,8 @@ describe('FrigateCamera', () => { const hass = createHASS(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: entityRegistryManager, - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -2075,10 +2067,8 @@ describe('FrigateCamera', () => { const hass = createHASS(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: entityRegistryManager, - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -2100,10 +2090,8 @@ describe('FrigateCamera', () => { ); const hass = createHASS(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: new EntityRegistryManagerMock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -2126,10 +2114,8 @@ describe('FrigateCamera', () => { ); await expect( camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }), @@ -2153,10 +2139,8 @@ describe('FrigateCamera', () => { ); const hass = createHASS(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: entityRegistryManager, - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -2179,10 +2163,8 @@ describe('FrigateCamera', () => { ); const hass = createHASS(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: entityRegistryManager, - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -2204,10 +2186,8 @@ describe('FrigateCamera', () => { ); const hass = createHASS(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: new EntityRegistryManagerMock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -2238,10 +2218,8 @@ describe('FrigateCamera', () => { ); const hass = createHASS(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: entityRegistryManager, - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -2274,10 +2252,8 @@ describe('FrigateCamera', () => { ); const hass = createHASS(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: entityRegistryManager, - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -2295,10 +2271,8 @@ describe('FrigateCamera', () => { const hass = createHASS(); await camera.initialize({ - hass: hass, + hassManager: createHASSManager({ hass }), entityRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -2327,12 +2301,10 @@ describe('FrigateCamera', () => { ); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([ createRegistryEntity({ entity_id: 'camera.office_frigate' }), ]), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -2366,12 +2338,10 @@ describe('FrigateCamera', () => { ); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([ createRegistryEntity({ entity_id: 'camera.office_frigate' }), ]), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -2420,12 +2390,10 @@ describe('FrigateCamera', () => { ); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([ createRegistryEntity({ entity_id: 'camera.office_frigate' }), ]), - stateWatcher: mock(), - eventWatcher: mock(), frigateEventWatcher: mock(), frigateReviewWatcher: mock(), }); @@ -2457,12 +2425,10 @@ describe('FrigateCamera', () => { ); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([ createRegistryEntity({ entity_id: 'camera.office_frigate' }), ]), - 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 54b503e2..611cb469 100644 --- a/tests/camera-manager/frigate/engine-frigate.test.ts +++ b/tests/camera-manager/frigate/engine-frigate.test.ts @@ -1,5 +1,4 @@ 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 { @@ -33,8 +32,6 @@ 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'; import { QuerySource } from '../../../src/query-source'; @@ -47,6 +44,7 @@ import { createFrigateRecording, createFrigateReview, createHASS, + createHASSManager, createStore, TestViewMedia, } from '../../test-utils'; @@ -59,8 +57,7 @@ const createEngine = (options?: { }): FrigateCameraManagerEngine => { return new FrigateCameraManagerEngine( new EntityRegistryManagerMock(), - new StateWatcher(), - mock(), + createHASSManager(), options?.cache ?? new RecordingSegmentsCache(), options?.requestCache ?? new CameraManagerRequestCache(), ); @@ -223,10 +220,7 @@ describe('FrigateCameraManagerEngine', () => { const engine = createEngine(); vi.mocked(getPTZInfo).mockResolvedValue({ features: [], presets: [] }); - const camera = await engine.createCamera( - createHASS(), - createFrigateCameraConfig(), - ); + const camera = await engine.createCamera(createFrigateCameraConfig()); expect(camera).toBeInstanceOf(Camera); }); diff --git a/tests/camera-manager/frigate/watcher.test.ts b/tests/camera-manager/frigate/watcher.test.ts index 2e7e81b4..d05a2605 100644 --- a/tests/camera-manager/frigate/watcher.test.ts +++ b/tests/camera-manager/frigate/watcher.test.ts @@ -1,4 +1,6 @@ -import { afterEach, assert, describe, expect, it, vi } from 'vitest'; +import { Connection } from 'home-assistant-js-websocket'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; import { FrigateEventChange, FrigateReviewChange, @@ -8,7 +10,7 @@ import { FrigateReviewWatcher, } from '../../../src/camera-manager/frigate/watcher.js'; import { HomeAssistant } from '../../../src/ha/types.js'; -import { createHASS } from '../../test-utils.js'; +import { createHASS, createHASSSource, flushPromises } from '../../test-utils.js'; const createEventChange = (): FrigateEventChange => { return { @@ -68,110 +70,91 @@ const createReviewChange = (): FrigateReviewChange => { }, }; }; -const callHASubscribeMessageCallback = ( - hass: HomeAssistant, - data: unknown, - n = 0, -): void => { + +// Drive the dispatcher registered with `hass.connection.subscribeMessage` to +// simulate a Frigate WS message arriving over the bus. +const fireMessage = (hass: HomeAssistant, data: unknown, n = 0): void => { const mock = vi.mocked(hass.connection.subscribeMessage).mock; expect(mock.calls.length).greaterThan(n); mock.calls[n][0](data); }; +// @vitest-environment jsdom describe('FrigateEventWatcher', () => { - it('should subscribe to a given topic once', async () => { - const stateWatcher = new FrigateEventWatcher(); - const hass = createHASS(); - - await stateWatcher.subscribe(hass, { - instanceID: 'frigate', - callback: vi.fn(), - }); - - await stateWatcher.subscribe(hass, { - instanceID: 'frigate', - callback: vi.fn(), - }); - - expect(hass.connection.subscribeMessage).toBeCalledTimes(1); + afterEach(() => { + vi.restoreAllMocks(); }); - it('should only subscribe from a given topic once', async () => { - const stateWatcher = new FrigateEventWatcher(); + it('should open a WS subscription with the frigate event type and instance id', async () => { const hass = createHASS(); + const { source } = createHASSSource(hass); + const watcher = new FrigateEventWatcher(source); - const unsubscribeCallback = vi.fn(); - vi.mocked(hass.connection.subscribeMessage).mockResolvedValue(unsubscribeCallback); + watcher.subscribe({ instanceID: 'frigate', callback: vi.fn() }); + await flushPromises(); - const request_1 = { - instanceID: 'frigate', - callback: vi.fn(), - }; - const request_2 = { ...request_1 }; - - await stateWatcher.subscribe(hass, request_1); - await stateWatcher.subscribe(hass, request_2); - - await stateWatcher.unsubscribe(request_1); - expect(unsubscribeCallback).not.toBeCalled(); - - await stateWatcher.unsubscribe(request_2); - expect(unsubscribeCallback).toBeCalledTimes(1); + expect(hass.connection.subscribeMessage).toBeCalledWith( + expect.any(Function), + expect.objectContaining({ + type: 'frigate/events/subscribe', + instance_id: 'frigate', + }), + ); }); - it('should handle unsubscribe during pending subscription', async () => { - const stateWatcher = new FrigateEventWatcher(); + it('should unsubscribe from the WS subscription', async () => { const hass = createHASS(); + const unsub = vi.fn(); + vi.mocked(hass.connection.subscribeMessage).mockResolvedValue(unsub); + const { source } = createHASSSource(hass); + const watcher = new FrigateEventWatcher(source); + const request = { instanceID: 'frigate', callback: vi.fn() }; - let resolveSubscription: ((callback: () => Promise) => void) | undefined; - const subscriptionPromise = new Promise<() => Promise>((resolve) => { - resolveSubscription = resolve; - }); - vi.mocked(hass.connection.subscribeMessage).mockReturnValue(subscriptionPromise); + watcher.subscribe(request); + await flushPromises(); - const request = { - instanceID: 'frigate', - callback: vi.fn(), - }; + watcher.unsubscribe(request); + await flushPromises(); - // Start subscription (doesn't complete yet as not awaited). - const subscribePromise = stateWatcher.subscribe(hass, request); + expect(unsub).toBeCalledTimes(1); + }); - // Unsubscribe while subscription is still pending. - const unsubscribePromise = stateWatcher.unsubscribe(request); + it('should drop messages from an old-connection subscription after a swap', async () => { + const oldHass = createHASS(); + const { source, push } = createHASSSource(oldHass); + const watcher = new FrigateEventWatcher(source); + const callback = vi.fn(); - // Complete the subscription: both subscribe and unsubscribe await the same - // pending promise, and unsubscribe then invokes the resolved unsub. - const unsubscribeCallback = vi.fn(); - assert(resolveSubscription); - resolveSubscription(unsubscribeCallback); - await subscribePromise; - await unsubscribePromise; + watcher.subscribe({ instanceID: 'frigate', callback }); + await flushPromises(); - expect(unsubscribeCallback).toBeCalledTimes(1); - callHASubscribeMessageCallback(hass, JSON.stringify(createEventChange())); - expect(request.callback).not.toBeCalled(); + // Capture the dispatcher registered against the OLD connection before the + // swap, so it still points at the now-stale era guard. + const oldDispatcher = vi.mocked(oldHass.connection.subscribeMessage).mock + .calls[0][0]; + + const newHass = createHASS(); + newHass.connection = mock(); + vi.mocked(newHass.connection.subscribeMessage).mockResolvedValue(vi.fn()); + push(newHass); + await flushPromises(); + + oldDispatcher(JSON.stringify(createEventChange())); + expect(callback).not.toBeCalled(); }); describe('should call handler', () => { - afterEach(() => { - vi.resetAllMocks(); - }); - it('with invalid JSON', async () => { const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => true); - const stateWatcher = new FrigateEventWatcher(); const hass = createHASS(); + const { source } = createHASSSource(hass); + const watcher = new FrigateEventWatcher(source); const callback = vi.fn(); - const request = { - instanceID: 'frigate', - callback: callback, - }; - - await stateWatcher.subscribe(hass, request); - callHASubscribeMessageCallback(hass, 'NOT_JSON'); + watcher.subscribe({ instanceID: 'frigate', callback }); + await flushPromises(); + fireMessage(hass, 'NOT_JSON'); expect(callback).not.toBeCalled(); expect(spy).toBeCalledWith( @@ -183,18 +166,15 @@ describe('FrigateEventWatcher', () => { it('with malformed event', async () => { const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => true); - const stateWatcher = new FrigateEventWatcher(); const hass = createHASS(); + const { source } = createHASSSource(hass); + const watcher = new FrigateEventWatcher(source); const callback = vi.fn(); - const request = { - instanceID: 'frigate', - callback: callback, - }; - - await stateWatcher.subscribe(hass, request); + watcher.subscribe({ instanceID: 'frigate', callback }); + await flushPromises(); const data = JSON.stringify({}); - callHASubscribeMessageCallback(hass, data); + fireMessage(hass, data); expect(callback).not.toBeCalled(); expect(spy).toBeCalledWith( @@ -204,71 +184,40 @@ describe('FrigateEventWatcher', () => { }); it('without a matcher', async () => { - const stateWatcher = new FrigateEventWatcher(); const hass = createHASS(); + const { source } = createHASSSource(hass); + const watcher = new FrigateEventWatcher(source); const callback = vi.fn(); - const request = { - instanceID: 'frigate', - callback: callback, - }; - - await stateWatcher.subscribe(hass, request); + watcher.subscribe({ instanceID: 'frigate', callback }); + await flushPromises(); const eventChange = createEventChange(); - callHASubscribeMessageCallback(hass, JSON.stringify(eventChange)); + fireMessage(hass, JSON.stringify(eventChange)); expect(callback).toBeCalledWith(eventChange); }); - it('with a non-matching instance_id', async () => { - const stateWatcher = new FrigateEventWatcher(); - const hass = createHASS(); - - const callback_1 = vi.fn(); - const request_1 = { - instanceID: 'frigate_1', - callback: callback_1, - }; - - const callback_2 = vi.fn(); - const request_2 = { - instanceID: 'frigate_2', - callback: callback_2, - }; - - await stateWatcher.subscribe(hass, request_1); - await stateWatcher.subscribe(hass, request_2); - - const eventChange = createEventChange(); - callHASubscribeMessageCallback(hass, JSON.stringify(eventChange), 1); - - expect(callback_1).not.toBeCalledWith(eventChange); - expect(callback_2).toBeCalledWith(eventChange); - }); - it('with a matcher', async () => { - const stateWatcher = new FrigateEventWatcher(); const hass = createHASS(); + const { source } = createHASSSource(hass); + const watcher = new FrigateEventWatcher(source); const matching_callback = vi.fn(); - const matching_request = { + const non_matching_callback = vi.fn(); + watcher.subscribe({ instanceID: 'frigate', callback: matching_callback, matcher: (event: FrigateEventChange) => event.after.camera === 'front_door', - }; - - const non_matching_callback = vi.fn(); - const non_matching_request = { + }); + watcher.subscribe({ instanceID: 'frigate', callback: non_matching_callback, matcher: (event: FrigateEventChange) => event.after.camera === 'back_door', - }; - - await stateWatcher.subscribe(hass, matching_request); - await stateWatcher.subscribe(hass, non_matching_request); + }); + await flushPromises(); const eventChange = createEventChange(); - callHASubscribeMessageCallback(hass, JSON.stringify(eventChange)); + fireMessage(hass, JSON.stringify(eventChange)); expect(non_matching_callback).not.toBeCalledWith(eventChange); expect(matching_callback).toBeCalledWith(eventChange); @@ -276,20 +225,20 @@ describe('FrigateEventWatcher', () => { }); }); +// @vitest-environment jsdom describe('FrigateReviewWatcher', () => { - it('should subscribe to a given topic once', async () => { - const stateWatcher = new FrigateReviewWatcher(); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should subscribe to the frigate reviews channel and dispatch review changes', async () => { const hass = createHASS(); + const { source } = createHASSSource(hass); + const watcher = new FrigateReviewWatcher(source); - await stateWatcher.subscribe(hass, { - instanceID: 'frigate', - callback: vi.fn(), - }); - - await stateWatcher.subscribe(hass, { - instanceID: 'frigate', - callback: vi.fn(), - }); + const callback = vi.fn(); + watcher.subscribe({ instanceID: 'frigate', callback }); + await flushPromises(); expect(hass.connection.subscribeMessage).toBeCalledWith( expect.any(Function), @@ -297,73 +246,10 @@ describe('FrigateReviewWatcher', () => { type: 'frigate/reviews/subscribe', }), ); - expect(hass.connection.subscribeMessage).toBeCalledTimes(1); - }); - describe('should call handler', () => { - afterEach(() => { - vi.resetAllMocks(); - }); + const reviewChange = createReviewChange(); + fireMessage(hass, JSON.stringify(reviewChange)); - it('with a review change', async () => { - const stateWatcher = new FrigateReviewWatcher(); - const hass = createHASS(); - - const callback = vi.fn(); - const request = { - instanceID: 'frigate', - callback: callback, - }; - - await stateWatcher.subscribe(hass, request); - - const reviewChange = createReviewChange(); - - callHASubscribeMessageCallback(hass, JSON.stringify(reviewChange)); - - expect(callback).toBeCalledWith(reviewChange); - }); - - it('with a genai review change', async () => { - const stateWatcher = new FrigateReviewWatcher(); - const hass = createHASS(); - - const callback = vi.fn(); - const request = { - instanceID: 'frigate', - callback: callback, - }; - - await stateWatcher.subscribe(hass, request); - - const reviewChange = createReviewChange(); - reviewChange.type = 'genai'; - - callHASubscribeMessageCallback(hass, JSON.stringify(reviewChange)); - - expect(callback).toBeCalledWith(reviewChange); - }); - - it('with invalid JSON', async () => { - const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => true); - - const stateWatcher = new FrigateReviewWatcher(); - const hass = createHASS(); - - const callback = vi.fn(); - const request = { - instanceID: 'frigate', - callback: callback, - }; - - await stateWatcher.subscribe(hass, request); - callHASubscribeMessageCallback(hass, 'NOT_JSON'); - - expect(callback).not.toBeCalled(); - expect(spy).toBeCalledWith( - 'Received non-JSON payload from subscription: frigate/reviews/subscribe', - 'NOT_JSON', - ); - }); + expect(callback).toBeCalledWith(reviewChange); }); }); diff --git a/tests/camera-manager/generic/engine-generic.test.ts b/tests/camera-manager/generic/engine-generic.test.ts index 6cea6b66..32fca822 100644 --- a/tests/camera-manager/generic/engine-generic.test.ts +++ b/tests/camera-manager/generic/engine-generic.test.ts @@ -1,9 +1,6 @@ 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 { 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'; @@ -11,15 +8,13 @@ import { TestViewMedia, createCameraConfig, createHASS, + createHASSManager, createStateEntity, createStore, } from '../../test-utils'; const createEngine = (): GenericCameraManagerEngine => { - return new GenericCameraManagerEngine( - mock(), - mock(), - ); + return new GenericCameraManagerEngine(createHASSManager()); }; const createGenericCameraConfig = ( @@ -35,7 +30,7 @@ describe('GenericCameraManagerEngine', () => { it('should initialize camera', async () => { const config = createGenericCameraConfig(); - const camera = await createEngine().createCamera(createHASS(), config); + const camera = await createEngine().createCamera(config); expect(camera.getConfig()).toEqual(config); expect(camera.getCapabilities()).toBeTruthy(); @@ -50,7 +45,7 @@ describe('GenericCameraManagerEngine', () => { it('should get default query parameters', async () => { const config = createGenericCameraConfig(); - const camera = await createEngine().createCamera(createHASS(), config); + const camera = await createEngine().createCamera(config); expect(createEngine().getDefaultQueryParameters(camera, QueryType.Event)).toEqual( {}, ); @@ -375,16 +370,12 @@ describe('GenericCameraManagerEngine', () => { describe('should get camera endpoints', () => { it('default', async () => { - const camera = await createEngine().createCamera( - createHASS(), - createGenericCameraConfig(), - ); + const camera = await createEngine().createCamera(createGenericCameraConfig()); expect(camera.getEndpoints()).toBeNull(); }); it('for go2rtc', async () => { const camera = await createEngine().createCamera( - createHASS(), createGenericCameraConfig({ go2rtc: { stream: 'stream', @@ -403,7 +394,6 @@ describe('GenericCameraManagerEngine', () => { it('for webrtc-card', async () => { const camera = await createEngine().createCamera( - createHASS(), createGenericCameraConfig({ camera_entity: 'camera.office', }), diff --git a/tests/camera-manager/manager.test.ts b/tests/camera-manager/manager.test.ts index f3adf07e..f540b3d8 100644 --- a/tests/camera-manager/manager.test.ts +++ b/tests/camera-manager/manager.test.ts @@ -31,7 +31,6 @@ import { StateWatcherSubscriptionInterface } from '../../src/card-controller/has import { sortItems } from '../../src/card-controller/view/sort.js'; import { CameraConfig } from '../../src/config/schema/cameras.js'; import { advancedCameraCardConfigSchema } from '../../src/config/schema/types.js'; -import { HomeAssistant } from '../../src/ha/types.js'; import { QuerySource } from '../../src/query-source.js'; import { Endpoint, PTZMovementType } from '../../src/types.js'; import { ViewFolder, ViewItem, ViewMedia } from '../../src/view/item.js'; @@ -297,7 +296,7 @@ describe('CameraManager', () => { camera.engineType === undefined ? Engine.Generic : camera.engineType; if (engineType) { vi.mocked(mockEngine.createCamera).mockImplementationOnce( - async (_hass: HomeAssistant, cameraConfig: CameraConfig): Promise => + async (cameraConfig: CameraConfig): Promise => await createInitializedCamera( cameraConfig, mockEngine, diff --git a/tests/camera-manager/motioneye/camera.test.ts b/tests/camera-manager/motioneye/camera.test.ts index 7ad6ab26..eee90b9b 100644 --- a/tests/camera-manager/motioneye/camera.test.ts +++ b/tests/camera-manager/motioneye/camera.test.ts @@ -2,10 +2,12 @@ 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 { 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'; +import { + createCameraConfig, + createHASSManager, + createRegistryEntity, +} from '../../test-utils'; const cameraEntity = createRegistryEntity({ entity_id: 'camera.motioneye', @@ -47,10 +49,8 @@ describe('MotionEyeCamera', () => { }); const camera = new MotionEyeCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]), - stateWatcher: mock(), - eventWatcher: mock(), }); const endpoints = camera.getEndpoints(); @@ -65,10 +65,8 @@ describe('MotionEyeCamera', () => { }); const camera = new MotionEyeCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]), - stateWatcher: mock(), - eventWatcher: mock(), }); const endpoints = camera.getEndpoints(); @@ -83,10 +81,8 @@ describe('MotionEyeCamera', () => { }); const camera = new MotionEyeCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]), - stateWatcher: mock(), - eventWatcher: mock(), }); const capabilities = camera.getCapabilities(); @@ -109,10 +105,8 @@ describe('MotionEyeCamera', () => { }); const camera = new MotionEyeCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]), - 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 3468af7d..562513d5 100644 --- a/tests/camera-manager/motioneye/engine-motioneye.test.ts +++ b/tests/camera-manager/motioneye/engine-motioneye.test.ts @@ -14,8 +14,6 @@ import { QueryResultsType, QueryType, } from '../../../src/camera-manager/types'; -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'; @@ -25,6 +23,7 @@ import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock'; import { createCameraConfig, createHASS, + createHASSManager, createRegistryEntity, createRichBrowseMedia, } from '../../test-utils'; @@ -51,8 +50,7 @@ const createEngine = (options?: { }): MotionEyeCameraManagerEngine => { return new MotionEyeCameraManagerEngine( new EntityRegistryManagerMock(options?.entities ?? [createEntity()]), - mock(), - mock(), + createHASSManager(), options?.walker ?? new BrowseMediaWalker(), new ResolvedMediaCache(), options?.requestCache ?? new CameraManagerRequestCache(), @@ -73,10 +71,8 @@ const createMotionEyeStore = async ( }); const camera = new MotionEyeCamera(config, engine); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([entity]), - stateWatcher: mock(), - eventWatcher: mock(), }); camera.setID(options?.cameraID ?? 'camera-1'); const store = new CameraManagerStore(); @@ -136,7 +132,7 @@ describe('MotionEyeCameraManagerEngine', () => { camera_entity: CAMERA_ENTITY_ID, }); - const camera = await engine.createCamera(createHASS(), config); + const camera = await engine.createCamera(config); expect(camera).toBeInstanceOf(Camera); expect(camera).toBeInstanceOf(MotionEyeCamera); @@ -498,10 +494,8 @@ describe('MotionEyeCameraManagerEngine', () => { const engine = createEngine({ walker }); const camera = new MotionEyeCamera(config, engine); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([createEntity()]), - stateWatcher: mock(), - eventWatcher: mock(), }); camera.setID('camera-1'); const store = new CameraManagerStore(); @@ -547,10 +541,8 @@ describe('MotionEyeCameraManagerEngine', () => { const engine = createEngine({ walker }); const camera = new MotionEyeCamera(config, engine); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([createEntity()]), - stateWatcher: mock(), - eventWatcher: mock(), }); camera.setID('camera-1'); const store = new CameraManagerStore(); @@ -602,10 +594,8 @@ describe('MotionEyeCameraManagerEngine', () => { const engine = createEngine({ walker }); const camera = new MotionEyeCamera(config, engine); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([createEntity()]), - 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 8d6f21bf..3234270e 100644 --- a/tests/camera-manager/reolink/camera.test.ts +++ b/tests/camera-manager/reolink/camera.test.ts @@ -4,14 +4,13 @@ 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 { 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'; import { createCameraConfig, createHASS, + createHASSManager, createRegistryEntity, createStateEntity, } from '../../test-utils'; @@ -103,11 +102,9 @@ describe('ReolinkCamera', () => { expect( async () => await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: mock(), deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }), ).rejects.toThrowError('Could not find camera entity'); }); @@ -128,11 +125,9 @@ describe('ReolinkCamera', () => { expect( async () => await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }), ).rejects.toThrowError('Could not initialize Reolink camera'); }); @@ -153,11 +148,9 @@ describe('ReolinkCamera', () => { expect( async () => await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }), ).rejects.toThrowError('Could not initialize Reolink camera'); }); @@ -170,11 +163,9 @@ describe('ReolinkCamera', () => { const entityRegistryManager = new EntityRegistryManagerMock([cameraEntity]); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }); expect(camera.getChannel()).toBe(0); @@ -203,11 +194,9 @@ describe('ReolinkCamera', () => { }); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager, deviceRegistryManager, - stateWatcher: mock(), - eventWatcher: mock(), }); expect(camera.getChannel()).toBe(3); @@ -227,11 +216,9 @@ describe('ReolinkCamera', () => { ]); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }); expect(camera.getChannel()).toBe(7); @@ -251,11 +238,9 @@ describe('ReolinkCamera', () => { ]); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }); expect(camera.getChannel()).toBe(0); @@ -275,11 +260,9 @@ describe('ReolinkCamera', () => { ]); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }); expect(camera.getChannel()).toBe(7); @@ -301,11 +284,9 @@ describe('ReolinkCamera', () => { ]); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }); expect(camera.getChannel()).toBe(0); @@ -334,11 +315,9 @@ describe('ReolinkCamera', () => { }); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager, deviceRegistryManager, - stateWatcher: mock(), - eventWatcher: mock(), }); expect(camera.getChannel()).toBe(0); @@ -367,11 +346,9 @@ describe('ReolinkCamera', () => { }); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager, deviceRegistryManager, - stateWatcher: mock(), - eventWatcher: mock(), }); expect(camera.getChannel()).toBe(0); @@ -400,11 +377,9 @@ describe('ReolinkCamera', () => { }); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager, deviceRegistryManager, - stateWatcher: mock(), - eventWatcher: mock(), }); expect(camera.getChannel()).toBe(0); @@ -420,11 +395,9 @@ describe('ReolinkCamera', () => { const camera = new ReolinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: ptzPopulatedEntityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }); expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ @@ -444,7 +417,7 @@ describe('ReolinkCamera', () => { const camera = new ReolinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([ createRegistryEntity({ entity_id: 'camera.office_reolink', @@ -463,8 +436,6 @@ describe('ReolinkCamera', () => { }), ]), deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }); expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ @@ -480,18 +451,18 @@ describe('ReolinkCamera', () => { const camera = new ReolinkCamera(config, mock()); await camera.initialize({ - hass: createHASS({ - 'select.office_reolink_ptz_preset': createStateEntity({ - state: 'foo', - attributes: { - options: ['preset-one', 'preset-two'], - }, + hassManager: createHASSManager({ + hass: createHASS({ + 'select.office_reolink_ptz_preset': createStateEntity({ + state: 'foo', + attributes: { + options: ['preset-one', 'preset-two'], + }, + }), }), }), entityRegistryManager: ptzPopulatedEntityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }); expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ @@ -521,11 +492,9 @@ describe('ReolinkCamera', () => { const camera = new ReolinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: ptzPopulatedEntityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }); expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ @@ -709,11 +678,9 @@ describe('ReolinkCamera', () => { const camera = new ReolinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]), deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }); const executor = mock(); @@ -739,14 +706,12 @@ describe('ReolinkCamera', () => { const camera = new ReolinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([ cameraEntity, buttonEntityPTZLeft, ]), deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }); const executor = mock(); await camera.executePTZAction(executor, 'left', { phase: 'start' }); @@ -770,11 +735,9 @@ describe('ReolinkCamera', () => { const camera = new ReolinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: ptzPopulatedEntityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }); const executor = mock(); @@ -812,11 +775,9 @@ describe('ReolinkCamera', () => { const camera = new ReolinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: ptzPopulatedEntityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }); const executor = mock(); @@ -832,18 +793,18 @@ describe('ReolinkCamera', () => { const camera = new ReolinkCamera(config, mock()); await camera.initialize({ - hass: createHASS({ - 'select.office_reolink_ptz_preset': createStateEntity({ - state: 'foo', - attributes: { - options: ['preset-one', 'preset-two'], - }, + hassManager: createHASSManager({ + hass: createHASS({ + 'select.office_reolink_ptz_preset': createStateEntity({ + state: 'foo', + attributes: { + options: ['preset-one', 'preset-two'], + }, + }), }), }), entityRegistryManager: ptzPopulatedEntityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }); const executor = mock(); @@ -871,11 +832,9 @@ describe('ReolinkCamera', () => { const camera = new ReolinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: ptzPopulatedEntityRegistryManager, deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }); const executor = mock(); @@ -892,14 +851,12 @@ describe('ReolinkCamera', () => { const camera = new ReolinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([ cameraEntity, numberEntityZoom, ]), deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }); expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ @@ -915,7 +872,7 @@ describe('ReolinkCamera', () => { const camera = new ReolinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([ cameraEntity, createRegistryEntity({ @@ -926,8 +883,6 @@ describe('ReolinkCamera', () => { }), ]), deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }); expect(camera.getCapabilities()?.getPTZCapabilities()).toBeNull(); @@ -940,7 +895,7 @@ describe('ReolinkCamera', () => { const camera = new ReolinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([ cameraEntity, buttonEntityPTZZoomIn, @@ -949,8 +904,6 @@ describe('ReolinkCamera', () => { numberEntityZoom, ]), deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }); expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ @@ -966,14 +919,12 @@ describe('ReolinkCamera', () => { const camera = new ReolinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([ cameraEntity, numberEntityZoom, ]), deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }); const executor = mock(); @@ -1005,14 +956,12 @@ describe('ReolinkCamera', () => { const camera = new ReolinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([ cameraEntity, numberEntityZoom, ]), deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }); const executor = mock(); @@ -1044,14 +993,12 @@ describe('ReolinkCamera', () => { const camera = new ReolinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([ cameraEntity, numberEntityZoom, ]), deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }); const executor = mock(); @@ -1083,14 +1030,12 @@ describe('ReolinkCamera', () => { const camera = new ReolinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([ cameraEntity, numberEntityZoom, ]), deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }); const executor = mock(); @@ -1122,14 +1067,12 @@ describe('ReolinkCamera', () => { const camera = new ReolinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([ cameraEntity, numberEntityZoom, ]), deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }); const executor = mock(); @@ -1152,14 +1095,12 @@ describe('ReolinkCamera', () => { const camera = new ReolinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([ cameraEntity, numberEntityZoom, ]), deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }); const executor = mock(); @@ -1181,14 +1122,12 @@ describe('ReolinkCamera', () => { const camera = new ReolinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([ cameraEntity, numberEntityZoom, ]), deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }); const executor = mock(); @@ -1204,7 +1143,7 @@ describe('ReolinkCamera', () => { const camera = new ReolinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([ cameraEntity, buttonEntityPTZZoomIn, @@ -1213,8 +1152,6 @@ describe('ReolinkCamera', () => { numberEntityZoom, ]), deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }); const executor = mock(); @@ -1238,15 +1175,13 @@ describe('ReolinkCamera', () => { const camera = new ReolinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([ cameraEntity, buttonEntityPTZLeft, buttonEntityPTZStop, ]), deviceRegistryManager: mock(), - stateWatcher: mock(), - eventWatcher: mock(), }); const executor = mock(); @@ -1262,14 +1197,12 @@ describe('ReolinkCamera', () => { const camera = new ReolinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([ cameraEntity, numberEntityZoom, ]), deviceRegistryManager: 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 a3f0adb7..a3066d27 100644 --- a/tests/camera-manager/reolink/engine-reolink.test.ts +++ b/tests/camera-manager/reolink/engine-reolink.test.ts @@ -23,8 +23,6 @@ import { QueryReturnType, QueryType, } from '../../../src/camera-manager/types'; -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'; @@ -36,6 +34,7 @@ import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock'; import { createCameraConfig, createHASS, + createHASSManager, createInitializedCamera, createRegistryEntity, createStore, @@ -187,8 +186,7 @@ const createEngine = (options?: { return new ReolinkCameraManagerEngine( options?.entityRegistryManager ?? new EntityRegistryManagerMock(), mock(), - mock(), - mock(), + createHASSManager(), options?.browseMediaManager ?? new BrowseMediaWalker(), new ResolvedMediaCache(), new CameraManagerRequestCache(), @@ -212,7 +210,6 @@ const createStoreWithReolinkCamera = async ( ): Promise => { const store = new CameraManagerStore(); const camera = await engine.createCamera( - createHASS(), createCameraConfig({ camera_entity: 'camera.office', id: 'office' }), ); store.addCamera(camera); @@ -252,7 +249,7 @@ describe('ReolinkCameraManagerEngine', () => { unique_id: 'office', }); - const camera = await engine.createCamera(createHASS(), config); + const camera = await engine.createCamera(config); expect(camera.getConfig()).toBe(config); expect(camera.getEngine()).toBe(engine); @@ -289,7 +286,6 @@ describe('ReolinkCameraManagerEngine', () => { it('should return ui endpoint', async () => { const engine = createPopulatedEngine(); const camera = await engine.createCamera( - createHASS(), createCameraConfig({ camera_entity: 'camera.office', reolink: { @@ -308,7 +304,6 @@ describe('ReolinkCameraManagerEngine', () => { it('should return go2rtc endpoint', async () => { const engine = createPopulatedEngine(); const camera = await engine.createCamera( - createHASS(), createCameraConfig({ camera_entity: 'camera.office', go2rtc: { @@ -497,7 +492,6 @@ describe('ReolinkCameraManagerEngine', () => { it('should request high resolution if configured', async () => { const engine = createPopulatedEngine(); const camera = await engine.createCamera( - createHASS(), createCameraConfig({ camera_entity: 'camera.office', id: 'office', diff --git a/tests/camera-manager/store.test.ts b/tests/camera-manager/store.test.ts index 7122571b..2c314f4e 100644 --- a/tests/camera-manager/store.test.ts +++ b/tests/camera-manager/store.test.ts @@ -5,14 +5,13 @@ 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'; import { ResolvedMediaCache } from '../../src/ha/resolved-media.js'; import { TestViewMedia, createCameraConfig, + createHASSManager, createInitializedCamera, } from '../test-utils.js'; @@ -31,13 +30,11 @@ describe('CameraManagerStore', async () => { ); const engineGeneric = await engineFactory.createEngine(Engine.Generic, { - stateWatcher: mock(), - eventWatcher: mock(), + hassManager: createHASSManager(), resolvedMediaCache: mock(), }); const engineFrigate = await engineFactory.createEngine(Engine.Frigate, { - stateWatcher: mock(), - eventWatcher: mock(), + hassManager: createHASSManager(), resolvedMediaCache: mock(), }); diff --git a/tests/camera-manager/tplink/camera.test.ts b/tests/camera-manager/tplink/camera.test.ts index 2bf3270c..44683f30 100644 --- a/tests/camera-manager/tplink/camera.test.ts +++ b/tests/camera-manager/tplink/camera.test.ts @@ -3,10 +3,12 @@ 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 { 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'; +import { + createCameraConfig, + createHASSManager, + createRegistryEntity, +} from '../../test-utils'; describe('TPLinkCamera', () => { // Entity patterns from: https://github.com/dermotduffy/advanced-camera-card/issues/2183 @@ -77,10 +79,8 @@ describe('TPLinkCamera', () => { expect( async () => await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock(), - stateWatcher: mock(), - eventWatcher: mock(), }), ).rejects.toThrowError('Could not find camera entity'); }); @@ -94,10 +94,8 @@ describe('TPLinkCamera', () => { expect( async () => await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock(), - stateWatcher: mock(), - eventWatcher: mock(), }), ).rejects.toThrowError('Could not find camera entity'); }); @@ -113,10 +111,8 @@ describe('TPLinkCamera', () => { const entityRegistryManager = new EntityRegistryManagerMock([cameraEntity]); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager, - stateWatcher: mock(), - eventWatcher: mock(), }); expect(camera.getEntity()).toBe(cameraEntity); @@ -131,10 +127,8 @@ describe('TPLinkCamera', () => { const entityRegistryManager = new EntityRegistryManagerMock([cameraEntity]); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager, - stateWatcher: mock(), - eventWatcher: mock(), }); expect(camera.getEntity()).toBe(cameraEntity); @@ -149,10 +143,8 @@ describe('TPLinkCamera', () => { const camera = new TPLinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: ptzPopulatedEntityRegistryManager, - stateWatcher: mock(), - eventWatcher: mock(), }); expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ @@ -179,10 +171,8 @@ describe('TPLinkCamera', () => { const camera = new TPLinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: ptzPopulatedEntityRegistryManager, - stateWatcher: mock(), - eventWatcher: mock(), }); expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ @@ -203,10 +193,8 @@ describe('TPLinkCamera', () => { const camera = new TPLinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]), - stateWatcher: mock(), - eventWatcher: mock(), }); const executor = mock(); @@ -225,10 +213,8 @@ describe('TPLinkCamera', () => { const camera = new TPLinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: ptzPopulatedEntityRegistryManager, - stateWatcher: mock(), - eventWatcher: mock(), }); const executor = mock(); @@ -249,10 +235,8 @@ describe('TPLinkCamera', () => { const camera = new TPLinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: ptzPopulatedEntityRegistryManager, - stateWatcher: mock(), - eventWatcher: mock(), }); const executor = mock(); @@ -270,10 +254,8 @@ describe('TPLinkCamera', () => { const camera = new TPLinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: ptzPopulatedEntityRegistryManager, - stateWatcher: mock(), - eventWatcher: mock(), }); const executor = mock(); @@ -299,10 +281,8 @@ describe('TPLinkCamera', () => { const camera = new TPLinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: ptzPopulatedEntityRegistryManager, - stateWatcher: mock(), - eventWatcher: mock(), }); const executor = mock(); @@ -328,10 +308,8 @@ describe('TPLinkCamera', () => { const camera = new TPLinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: ptzPopulatedEntityRegistryManager, - stateWatcher: mock(), - eventWatcher: mock(), }); const executor = mock(); @@ -365,13 +343,11 @@ describe('TPLinkCamera', () => { const camera = new TPLinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([ cameraEntity, buttonEntityPanLeft, ]), - stateWatcher: mock(), - eventWatcher: mock(), }); const executor = mock(); await camera.executePTZAction(executor, 'left', { phase: 'start' }); @@ -404,13 +380,11 @@ describe('TPLinkCamera', () => { const camera = new TPLinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([ cameraWithDifferentUniqueId, buttonEntityPanLeft, ]), - stateWatcher: mock(), - eventWatcher: mock(), }); // Should not find PTZ entities since unique_id doesn't end with _live_view @@ -430,13 +404,11 @@ describe('TPLinkCamera', () => { const camera = new TPLinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([ cameraWithoutUniqueId, buttonEntityPanLeft, ]), - stateWatcher: mock(), - eventWatcher: mock(), }); // Should not find PTZ entities since camera has no unique_id @@ -453,13 +425,11 @@ describe('TPLinkCamera', () => { const camera = new TPLinkCamera(config, mock()); await camera.initialize({ - hass: createHASS(), + hassManager: createHASSManager(), entityRegistryManager: new EntityRegistryManagerMock([ cameraEntity, buttonEntityPanLeft, // Only left button available ]), - 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 edb01929..368fcbfa 100644 --- a/tests/camera-manager/tplink/engine-tplink.test.ts +++ b/tests/camera-manager/tplink/engine-tplink.test.ts @@ -1,19 +1,20 @@ 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 { 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'; +import { + createCameraConfig, + createHASS, + createHASSManager, + createRegistryEntity, +} from '../../test-utils'; const createEngine = (options?: { entityRegistryManager?: EntityRegistryManagerMock; }): TPLinkCameraManagerEngine => { return new TPLinkCameraManagerEngine( options?.entityRegistryManager ?? new EntityRegistryManagerMock(), - mock(), - mock(), + createHASSManager(), ); }; @@ -42,7 +43,7 @@ describe('TPLinkCameraManagerEngine', () => { id: 'tapo_office', }); - const camera = await engine.createCamera(createHASS(), config); + const camera = await engine.createCamera(config); expect(camera.getConfig()).toBe(config); expect(camera.getEngine()).toBe(engine); diff --git a/tests/card-controller/automations-manager.test.ts b/tests/card-controller/automations-manager.test.ts index 6e950c83..7c28d334 100644 --- a/tests/card-controller/automations-manager.test.ts +++ b/tests/card-controller/automations-manager.test.ts @@ -1,8 +1,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; import { ActionsExecutionRequest } from '../../src/card-controller/actions/types.js'; import { AutomationsManager } from '../../src/card-controller/automations-manager.js'; +import { EventWatcherSubscriptionInterface } from '../../src/card-controller/hass/event-watcher.js'; import { ConditionStateManager } from '../../src/condition-trigger/conditions/state-manager.js'; -import { createCardAPI, flushPromises } from '../test-utils.js'; +import { + createCardAPI, + createHASS, + createHASSEvent, + flushPromises, +} from '../test-utils.js'; describe('AutomationsManager', () => { const actions = [ @@ -264,6 +271,40 @@ describe('AutomationsManager', () => { expect(api.getActionsManager().executeActions).toBeCalledTimes(10); }); + it('should execute actions on a matching HA bus event trigger', () => { + const api = createCardAPI(); + const eventWatcher = mock(); + vi.mocked(eventWatcher.subscribe).mockResolvedValue(); + vi.mocked(eventWatcher.unsubscribe).mockResolvedValue(); + vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + vi.mocked(api.getHASSManager().getEventWatcher).mockReturnValue(eventWatcher); + vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue( + true, + ); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + + const automationsManager = new AutomationsManager(api); + automationsManager.addAutomations([ + { + triggers: [{ trigger: 'event' as const, event_type: 'zha_event' }], + actions: actions, + }, + ]); + + expect(eventWatcher.subscribe).toBeCalledTimes(1); + + // Simulate an event arrival. + const event = createHASSEvent('zha_event', { command: 'press' }); + vi.mocked(eventWatcher.subscribe).mock.calls[0][0].callback(event); + + expect(api.getActionsManager().executeActions).toBeCalledTimes(1); + expect( + vi.mocked(api.getActionsManager().executeActions).mock.calls[0][0].triggerData, + ).toEqual({ platform: 'event', event }); + }); + it('should delete automations', () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true); diff --git a/tests/card-controller/card-element-manager.test.ts b/tests/card-controller/card-element-manager.test.ts index 27ad3500..58f70859 100644 --- a/tests/card-controller/card-element-manager.test.ts +++ b/tests/card-controller/card-element-manager.test.ts @@ -32,6 +32,24 @@ describe('CardElementManager', () => { expect(manager.getElement()).toBe(element); }); + it('should report whether the element is connected', () => { + const element = createCardHTMLElement(); + const manager = new CardElementManager( + createCardAPI(), + element, + () => undefined, + () => undefined, + ); + + expect(manager.isConnected()).toBe(false); + + document.body.append(element); + expect(manager.isConnected()).toBe(true); + + element.remove(); + expect(manager.isConnected()).toBe(false); + }); + it('should reset scroll', () => { const callback = vi.fn(); const manager = new CardElementManager( diff --git a/tests/card-controller/controller.test.ts b/tests/card-controller/controller.test.ts index 733e1d40..d688a6fb 100644 --- a/tests/card-controller/controller.test.ts +++ b/tests/card-controller/controller.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock, MockProxy } from 'vitest-mock-extended'; import { CameraManager } from '../../src/camera-manager/manager'; import { ActionsManager } from '../../src/card-controller/actions/actions-manager'; import { AutomationsManager } from '../../src/card-controller/automations-manager'; @@ -15,6 +16,7 @@ import { DefaultManager } from '../../src/card-controller/default-manager'; import { ExpandManager } from '../../src/card-controller/expand-manager'; import { FoldersManager } from '../../src/card-controller/folders/manager'; import { FullscreenManager } from '../../src/card-controller/fullscreen/fullscreen-manager'; +import { EventWatcherSubscriptionInterface } from '../../src/card-controller/hass/event-watcher'; import { HASSManager } from '../../src/card-controller/hass/hass-manager'; import { InitializationManager } from '../../src/card-controller/initialization-manager'; import { InteractionManager } from '../../src/card-controller/interaction-manager'; @@ -36,6 +38,7 @@ import { AdvancedCameraCardEditor } from '../../src/editor'; import { DeviceRegistryManager } from '../../src/ha/registry/device'; import { EntityRegistryManagerLive } from '../../src/ha/registry/entity'; import { ResolvedMediaCache } from '../../src/ha/resolved-media'; +import { createSubscriptionHealth } from './test-utils'; vi.mock('../../src/camera-manager/manager'); vi.mock('../../src/card-controller/actions/actions-manager'); @@ -49,7 +52,6 @@ vi.mock('../../src/card-controller/download-manager'); vi.mock('../../src/card-controller/expand-manager'); vi.mock('../../src/card-controller/folders/manager'); vi.mock('../../src/card-controller/fullscreen/fullscreen-manager'); -vi.mock('../../src/card-controller/hass/hass-manager'); vi.mock('../../src/card-controller/initialization-manager'); vi.mock('../../src/card-controller/interaction-manager'); vi.mock('../../src/card-controller/keyboard-state-manager'); @@ -78,8 +80,21 @@ const createCardElement = (): CardHTMLElement => { return element; }; -const createController = (): CardController => { - return new CardController(createCardElement(), vi.fn(), vi.fn()); +// Full HASSManager mock for CardController ctor injection (wires +// getEventWatcher().getHealth() so construction resolves). Distinct from the +// readonly-interface `createHASSManager` helper in tests/test-utils.ts. +const createMockHASSManager = (): MockProxy => { + const hassManager = mock(); + hassManager.getEventWatcher.mockReturnValue( + mock({ + getHealth: () => createSubscriptionHealth(), + }), + ); + return hassManager; +}; + +const createController = (hassManager = createMockHASSManager()): CardController => { + return new CardController(createCardElement(), vi.fn(), vi.fn(), hassManager); }; // @vitest-environment jsdom @@ -104,6 +119,26 @@ describe('CardController', () => { expect(controller.getEffectsManager()).toBeTruthy(); }); + it('should wire ConditionStateManager as the first hass listener so semantic state is fresh before any other listener runs', () => { + const hassManager = createMockHASSManager(); + createController(hassManager); + + const calls = vi.mocked(hassManager.addListener).mock.calls; + + // ConditionStateManager (CSM) first ordering is load-bearing: StateWatcher + // lazy-attaches later; its diff handlers can synchronously write to CSM, so + // CSM.hass must be fresh before any listener whose dispatch path reads + // condition state. + expect(calls).toHaveLength(1); + + const csmListener = calls[0][0]; + const hass = {} as Parameters[0]; + csmListener(hass, null); + expect(vi.mocked(ConditionStateManager).mock.instances[0].setState).toBeCalledWith({ + hass, + }); + }); + describe('accessors', () => { it('should return getActionsManager', () => { expect(createController().getActionsManager()).toBe( @@ -196,9 +231,8 @@ describe('CardController', () => { }); it('should return getHASSManager', () => { - expect(createController().getHASSManager()).toBe( - vi.mocked(HASSManager).mock.instances[0], - ); + const hassManager = createMockHASSManager(); + expect(createController(hassManager).getHASSManager()).toBe(hassManager); }); it('should return getInitializationManager', () => { diff --git a/tests/card-controller/hass/event-watcher.test.ts b/tests/card-controller/hass/event-watcher.test.ts index b521c533..2732843a 100644 --- a/tests/card-controller/hass/event-watcher.test.ts +++ b/tests/card-controller/hass/event-watcher.test.ts @@ -1,32 +1,38 @@ -import { HassEvent } from 'home-assistant-js-websocket'; -import { describe, expect, it, vi } from 'vitest'; +import { Connection, HassEvent } from 'home-assistant-js-websocket'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; import { EventWatcher } from '../../../src/card-controller/hass/event-watcher'; import { HomeAssistant } from '../../../src/ha/types'; -import { createHASS } from '../../test-utils'; +import { + createHASS, + createHASSEvent, + createHASSSource, + flushPromises, + useDeterministicTimers, +} from '../../test-utils'; +// Drive the dispatcher registered with `hass.connection.subscribeEvents` to +// simulate an event arriving over the WS bus. 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 }, -}); - +// @vitest-environment jsdom describe('EventWatcher', () => { - it('opens a single WS subscription per event_type regardless of subscribers', async () => { - const watcher = new EventWatcher(); - const hass = createHASS(); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); - await watcher.subscribe(hass, { event_type: 'zha_event', callback: vi.fn() }); - await watcher.subscribe(hass, { event_type: 'zha_event', callback: vi.fn() }); + it('should open a WS subscription keyed by event_type', async () => { + const hass = createHASS(); + const { source } = createHASSSource(hass); + const watcher = new EventWatcher(source); + + watcher.subscribe({ event_type: 'zha_event', callback: vi.fn() }); + await flushPromises(); expect(hass.connection.subscribeEvents).toBeCalledTimes(1); expect(vi.mocked(hass.connection.subscribeEvents).mock.calls[0][1]).toBe( @@ -34,100 +40,160 @@ describe('EventWatcher', () => { ); }); - it('opens separate WS subscriptions for distinct event_types', async () => { - const watcher = new EventWatcher(); + it('should share one WS subscription across subscribers with the same event_type', async () => { const hass = createHASS(); + const { source } = createHASSSource(hass); + const watcher = new EventWatcher(source); - await watcher.subscribe(hass, { event_type: 'zha_event', callback: vi.fn() }); - await watcher.subscribe(hass, { event_type: 'deconz_event', callback: vi.fn() }); + watcher.subscribe({ event_type: 'zha_event', callback: vi.fn() }); + watcher.subscribe({ event_type: 'zha_event', callback: vi.fn() }); + await flushPromises(); + + expect(hass.connection.subscribeEvents).toBeCalledTimes(1); + }); + + it('should open separate WS subscriptions for distinct event_types', async () => { + const hass = createHASS(); + const { source } = createHASSSource(hass); + const watcher = new EventWatcher(source); + + watcher.subscribe({ event_type: 'zha_event', callback: vi.fn() }); + watcher.subscribe({ event_type: 'deconz_event', callback: vi.fn() }); + await flushPromises(); 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(); + it('should dispatch to every subscriber whose event_type matches', async () => { const hass = createHASS(); + const { source } = createHASSSource(hass); + const watcher = new EventWatcher(source); const cb1 = vi.fn(); const cb2 = vi.fn(); + const event = createHASSEvent('zha_event', { command: 'press' }); - await watcher.subscribe(hass, { event_type: 'zha_event', callback: cb1 }); - await watcher.subscribe(hass, { event_type: 'zha_event', callback: cb2 }); + watcher.subscribe({ event_type: 'zha_event', callback: cb1 }); + watcher.subscribe({ event_type: 'zha_event', callback: cb2 }); + await flushPromises(); - fireEvent(hass, createHassEvent('zha_event', { command: 'press' })); + fireEvent(hass, event); - expect(cb1).toBeCalledWith({ command: 'press' }); - expect(cb2).toBeCalledWith({ command: 'press' }); + expect(cb1).toBeCalledWith(event); + expect(cb2).toBeCalledWith(event); }); - it('drops events whose event_type does not match the request', async () => { - const watcher = new EventWatcher(); + it('should gate dispatch on the request matcher when provided', async () => { const hass = createHASS(); + const { source } = createHASSSource(hass); + const watcher = new EventWatcher(source); const cb = vi.fn(); + const matcher = vi.fn((event: HassEvent) => (event.data as { x?: number }).x === 1); - 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 })); + const matching = createHASSEvent('zha_event', { x: 1 }); + const nonMatching = createHASSEvent('zha_event', { x: 2 }); + watcher.subscribe({ event_type: 'zha_event', matcher, callback: cb }); + await flushPromises(); - 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 })); + fireEvent(hass, matching); + fireEvent(hass, nonMatching); expect(matcher).toBeCalledTimes(2); expect(cb).toBeCalledTimes(1); - expect(cb).toBeCalledWith({ x: 1 }); + expect(cb).toBeCalledWith(matching); }); - it('handles unsubscribe during a still-pending subscribe without leaking', async () => { - const watcher = new EventWatcher(); + it('should tear down the WS subscription only when the last subscriber unsubscribes', async () => { const hass = createHASS(); const unsub = vi.fn(); + vi.mocked(hass.connection.subscribeEvents).mockResolvedValue(unsub); + const { source } = createHASSSource(hass); + const watcher = new EventWatcher(source); + const req1 = { event_type: 'zha_event', callback: vi.fn() }; + const req2 = { event_type: 'zha_event', callback: vi.fn() }; - let resolveSubscription: ((cb: () => Promise) => void) | undefined; - const subscriptionPromise = new Promise<() => Promise>((resolve) => { - resolveSubscription = resolve; - }); - vi.mocked(hass.connection.subscribeEvents).mockReturnValue(subscriptionPromise); + watcher.subscribe(req1); + watcher.subscribe(req2); + await flushPromises(); - 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; + watcher.unsubscribe(req1); + await flushPromises(); + expect(unsub).not.toBeCalled(); + watcher.unsubscribe(req2); + await flushPromises(); expect(unsub).toBeCalledTimes(1); }); + + it('should drop events from an old-connection subscription after a swap', async () => { + const oldHass = createHASS(); + const { source, push } = createHASSSource(oldHass); + const watcher = new EventWatcher(source); + const cb = vi.fn(); + + watcher.subscribe({ event_type: 'zha_event', callback: cb }); + await flushPromises(); + + // Capture the dispatcher registered against the OLD connection BEFORE + // the swap, so it still points at the source-bound guard. + const oldDispatcher = vi.mocked(oldHass.connection.subscribeEvents).mock.calls[0][0]; + + const newHass = createHASS(); + newHass.connection = mock(); + vi.mocked(newHass.connection.subscribeEvents).mockResolvedValue(vi.fn()); + push(newHass); + await flushPromises(); + + // Old dispatcher fires: guard.isConnected() is now false, callback must NOT + // receive the event. + oldDispatcher?.(createHASSEvent('zha_event', { command: 'press' })); + expect(cb).not.toBeCalled(); + }); + + it('should not dispatch to a subscriber that registers mid-dispatch', async () => { + const hass = createHASS(); + const { source } = createHASSSource(hass); + const watcher = new EventWatcher(source); + const lateCallback = vi.fn(); + const reentrantCallback = vi.fn(() => { + watcher.subscribe({ event_type: 'zha_event', callback: lateCallback }); + }); + + watcher.subscribe({ event_type: 'zha_event', callback: reentrantCallback }); + await flushPromises(); + + fireEvent(hass, createHASSEvent('zha_event', { command: 'press' })); + + expect(reentrantCallback).toBeCalledTimes(1); + expect(lateCallback).not.toBeCalled(); + }); + + describe('subscription health monitoring', () => { + it('should surface and retry failing subscriptions through getHealth', async () => { + useDeterministicTimers(); + + const hass = createHASS(); + vi.mocked(hass.connection.subscribeEvents).mockRejectedValue(new Error('boom')); + const { source } = createHASSSource(hass); + const watcher = new EventWatcher(source); + + watcher.subscribe({ event_type: 'zha_event', callback: vi.fn() }); + await flushPromises(); + + expect( + watcher + .getHealth() + .getFailures() + .map((failure) => failure.key), + ).toEqual(['zha_event']); + + const before = vi.mocked(hass.connection.subscribeEvents).mock.calls.length; + + watcher.getHealth().retry(); + + await flushPromises(); + expect(vi.mocked(hass.connection.subscribeEvents).mock.calls.length).toBe( + before + 1, + ); + }); + }); }); diff --git a/tests/card-controller/hass/hass-manager.test.ts b/tests/card-controller/hass/hass-manager.test.ts index bc44e10b..ae467694 100644 --- a/tests/card-controller/hass/hass-manager.test.ts +++ b/tests/card-controller/hass/hass-manager.test.ts @@ -44,27 +44,62 @@ describe('HASSManager', () => { expect(manager.hasHASS()).toBeTruthy(); }); - it('should update theme upon setting hass', () => { - const api = createCardAPI(); - const manager = new HASSManager(api); + describe('as a HASS source', () => { + it('should fan out to registered listeners with (hass, oldHass)', () => { + const manager = new HASSManager(createCardAPI()); + const listener = vi.fn(); + manager.addListener(listener); - manager.setHASS(createHASS()); + const hass1 = createHASS(); + manager.setHASS(hass1); + expect(listener).toBeCalledWith(hass1, null); - expect(api.getStyleManager().applyTheme).toBeCalled(); - }); + const hass2 = createHASS(); + manager.setHASS(hass2); + expect(listener).toBeCalledWith(hass2, hass1); + }); - it('should set condition manager state', () => { - const api = createCardAPI(); - const manager = new HASSManager(api); - const hass = createHASS(); + it('should call listeners in insertion order on every fan-out', () => { + const manager = new HASSManager(createCardAPI()); + const order: string[] = []; + manager.addListener(() => order.push('first')); + manager.addListener(() => order.push('second')); - manager.setHASS(hass); + manager.setHASS(createHASS()); - expect(api.getConditionStateManager().setState).toBeCalledWith( - expect.objectContaining({ - hass: hass, - }), - ); + expect(order).toEqual(['first', 'second']); + }); + + it('should detach a listener via the returned unlisten callback', () => { + const manager = new HASSManager(createCardAPI()); + const listener = vi.fn(); + const unlisten = manager.addListener(listener); + + unlisten(); + manager.setHASS(createHASS()); + + expect(listener).not.toBeCalled(); + }); + + it('should not fan out on null/undefined hass', () => { + const manager = new HASSManager(createCardAPI()); + const listener = vi.fn(); + manager.addListener(listener); + + manager.setHASS(null); + manager.setHASS(); + + expect(listener).not.toBeCalled(); + }); + + it('should expose current hass via getHASS for source consumers', () => { + const manager = new HASSManager(createCardAPI()); + expect(manager.getHASS()).toBeNull(); + + const hass = createHASS(); + manager.setHASS(hass); + expect(manager.getHASS()).toBe(hass); + }); }); describe('should handle connection state change when', () => { diff --git a/tests/card-controller/hass/state-watcher.test.ts b/tests/card-controller/hass/state-watcher.test.ts index 2e6ff4e4..7cb066b2 100644 --- a/tests/card-controller/hass/state-watcher.test.ts +++ b/tests/card-controller/hass/state-watcher.test.ts @@ -1,35 +1,61 @@ import { describe, expect, it, vi } from 'vitest'; import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; -import { createHASS, createStateEntity } from '../../test-utils'; +import { createHASS, createHASSSource, createStateEntity } from '../../test-utils'; describe('StateWatcher', () => { it('should not subscribe with no entities', () => { - const stateWatcher = new StateWatcher(); + const { source } = createHASSSource(); + const stateWatcher = new StateWatcher(source); + expect(stateWatcher.subscribe(vi.fn(), [])).toBeFalsy(); + }); + + it('should attach to the source lazily on first subscriber', () => { + const { source, getListenerCount } = createHASSSource(createHASS()); + const stateWatcher = new StateWatcher(source); + expect(getListenerCount()).toBe(0); + stateWatcher.subscribe(vi.fn(), ['binary_sensor.foo']); + expect(getListenerCount()).toBe(1); + }); + + it('should stay attached while other subscribers remain', () => { + const { source, getListenerCount } = createHASSSource(createHASS()); + const stateWatcher = new StateWatcher(source); + const cb1 = vi.fn(); + const cb2 = vi.fn(); + + stateWatcher.subscribe(cb1, ['binary_sensor.foo']); + stateWatcher.subscribe(cb2, ['binary_sensor.bar']); + expect(getListenerCount()).toBe(1); + + stateWatcher.unsubscribe(cb1); + expect(getListenerCount()).toBe(1); + }); + + it('should detach from the source when the last subscriber leaves', () => { + const { source, getListenerCount } = createHASSSource(createHASS()); + const stateWatcher = new StateWatcher(source); const callback = vi.fn(); - expect(stateWatcher.subscribe(callback, [])).toBeFalsy(); + + stateWatcher.subscribe(callback, ['binary_sensor.foo']); + expect(getListenerCount()).toBe(1); + + stateWatcher.unsubscribe(callback); + expect(getListenerCount()).toBe(0); }); it('should call back with state change', () => { - const stateWatcher = new StateWatcher(); + const initial = createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'on' }), + 'binary_sensor.bar': createStateEntity({ state: 'off' }), + }); + const { source, push } = createHASSSource(initial); + const stateWatcher = new StateWatcher(source); const callback = vi.fn(); + expect(stateWatcher.subscribe(callback, ['binary_sensor.foo'])).toBeTruthy(); expect(stateWatcher.subscribe(callback, ['binary_sensor.bar'])).toBeTruthy(); - stateWatcher.setHASS( - null, - createHASS({ - 'binary_sensor.foo': createStateEntity({ state: 'on' }), - 'binary_sensor.bar': createStateEntity({ state: 'off' }), - }), - ); - - expect(callback).not.toBeCalled(); - - stateWatcher.setHASS( - createHASS({ - 'binary_sensor.foo': createStateEntity({ state: 'on' }), - 'binary_sensor.bar': createStateEntity({ state: 'off' }), - }), + push( createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'on' }), 'binary_sensor.bar': createStateEntity({ state: 'on' }), @@ -46,24 +72,33 @@ describe('StateWatcher', () => { ); }); - it('should not call back without state change', () => { - const stateWatcher = new StateWatcher(); + it('should not call back when oldHass is null on first observed push', () => { + const { source, push } = createHASSSource(null); + const stateWatcher = new StateWatcher(source); const callback = vi.fn(); - expect(stateWatcher.subscribe(callback, ['binary_sensor.foo'])).toBeTruthy(); - stateWatcher.setHASS( - null, + stateWatcher.subscribe(callback, ['binary_sensor.foo']); + + push( createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'on' }), }), ); expect(callback).not.toBeCalled(); + }); - stateWatcher.setHASS( - createHASS({ - 'binary_sensor.foo': createStateEntity({ state: 'on' }), - }), + it('should not call back without state change', () => { + const initial = createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'on' }), + }); + const { source, push } = createHASSSource(initial); + const stateWatcher = new StateWatcher(source); + const callback = vi.fn(); + + expect(stateWatcher.subscribe(callback, ['binary_sensor.foo'])).toBeTruthy(); + + push( createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'on' }), }), @@ -73,24 +108,17 @@ describe('StateWatcher', () => { }); it('should not call back when unsubscribed', () => { - const stateWatcher = new StateWatcher(); + const initial = createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'on' }), + }); + const { source, push } = createHASSSource(initial); + const stateWatcher = new StateWatcher(source); const callback = vi.fn(); + expect(stateWatcher.subscribe(callback, ['binary_sensor.foo'])).toBeTruthy(); - expect(stateWatcher.unsubscribe(callback)); + stateWatcher.unsubscribe(callback); - stateWatcher.setHASS( - null, - createHASS({ - 'binary_sensor.foo': createStateEntity({ state: 'on' }), - }), - ); - - expect(callback).not.toBeCalled(); - - stateWatcher.setHASS( - createHASS({ - 'binary_sensor.foo': createStateEntity({ state: 'on' }), - }), + push( createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'off' }), }), diff --git a/tests/card-controller/initialization-manager.test.ts b/tests/card-controller/initialization-manager.test.ts index 12c3862f..f4599a42 100644 --- a/tests/card-controller/initialization-manager.test.ts +++ b/tests/card-controller/initialization-manager.test.ts @@ -1,4 +1,4 @@ -import { STATE_STARTING } from 'home-assistant-js-websocket'; +import { STATE_RUNNING, STATE_STARTING } from 'home-assistant-js-websocket'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { @@ -293,4 +293,89 @@ describe('InitializationManager', () => { expect(initializer.uninitialize).toBeCalledWith(InitializationAspect.CAMERAS); }); + + describe('should decide whether to trigger initialization', () => { + const createReadyAPI = () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().hasConfig).mockReturnValue(true); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + vi.mocked(api.getCardElementManager().isConnected).mockReturnValue(true); + const hass = createHASS(); + hass.connected = true; + hass.config.state = STATE_RUNNING; + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + vi.mocked( + api.getIssueManager().getStateManager().hasFullCardIssue, + ).mockReturnValue(false); + return api; + }; + + it('should initialize when all conditions are met', () => { + const initializer = mock(); + const manager = new InitializationManager(createReadyAPI(), initializer); + + manager.triggerInitialization(); + + expect(initializer.initializeMultipleIfNecessary).toBeCalled(); + }); + + it('should not initialize without config', () => { + const api = createReadyAPI(); + vi.mocked(api.getConfigManager().hasConfig).mockReturnValue(false); + const initializer = mock(); + const manager = new InitializationManager(api, initializer); + + manager.triggerInitialization(); + + expect(initializer.initializeMultipleIfNecessary).not.toBeCalled(); + }); + + it('should not initialize when the element is disconnected', () => { + const api = createReadyAPI(); + vi.mocked(api.getCardElementManager().isConnected).mockReturnValue(false); + const initializer = mock(); + const manager = new InitializationManager(api, initializer); + + manager.triggerInitialization(); + + expect(initializer.initializeMultipleIfNecessary).not.toBeCalled(); + }); + + it('should not initialize when hass is not ready', () => { + const api = createReadyAPI(); + const hass = createHASS(); + hass.connected = true; + hass.config.state = STATE_STARTING; + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + const initializer = mock(); + const manager = new InitializationManager(api, initializer); + + manager.triggerInitialization(); + + expect(initializer.initializeMultipleIfNecessary).not.toBeCalled(); + }); + + it('should not initialize when already initialized', () => { + const initializer = mock(); + initializer.isInitializedMultiple.mockReturnValue(true); + const manager = new InitializationManager(createReadyAPI(), initializer); + + manager.triggerInitialization(); + + expect(initializer.initializeMultipleIfNecessary).not.toBeCalled(); + }); + + it('should not initialize while a full-card issue is shown', () => { + const api = createReadyAPI(); + vi.mocked( + api.getIssueManager().getStateManager().hasFullCardIssue, + ).mockReturnValue(true); + const initializer = mock(); + const manager = new InitializationManager(api, initializer); + + manager.triggerInitialization(); + + expect(initializer.initializeMultipleIfNecessary).not.toBeCalled(); + }); + }); }); diff --git a/tests/card-controller/issues/factory.test.ts b/tests/card-controller/issues/factory.test.ts index 1c1ae345..dc7bf157 100644 --- a/tests/card-controller/issues/factory.test.ts +++ b/tests/card-controller/issues/factory.test.ts @@ -4,6 +4,7 @@ import { createIssueManager } from '../../../src/card-controller/issues/factory' import { IssueManager } from '../../../src/card-controller/issues/issue-manager'; import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager'; import { createCardAPI } from '../../test-utils'; +import { createSubscriptionHealth } from '../test-utils'; describe('createIssueManager', () => { beforeEach(() => { @@ -15,12 +16,15 @@ describe('createIssueManager', () => { }); it('should return a IssueManager instance', () => { - const manager = createIssueManager(createCardAPI()); + const manager = createIssueManager(createCardAPI(), createSubscriptionHealth()); expect(manager).toBeInstanceOf(IssueManager); }); it('should register all expected issues', () => { - const manager = createIssueManager(createCardAPI()).getStateManager(); + const manager = createIssueManager( + createCardAPI(), + createSubscriptionHealth(), + ).getStateManager(); expect(manager.getIssueDescriptions()).toHaveLength(0); @@ -29,6 +33,7 @@ describe('createIssueManager', () => { 'config_upgrade', 'config_upgrade_failure', 'connection', + 'event_subscription', 'initialization', 'legacy_resource', 'media_query', @@ -51,7 +56,7 @@ describe('createIssueManager', () => { const api = createCardAPI(); const stateManager = new ConditionStateManager(); vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); - const manager = createIssueManager(api); + const manager = createIssueManager(api, createSubscriptionHealth()); manager.trigger('media_query', { error: new Error('x') }); manager.trigger('initialization', { error: new Error('x') }); @@ -76,7 +81,7 @@ describe('createIssueManager', () => { const stateManager = new ConditionStateManager(); vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); - const manager = createIssueManager(api); + const manager = createIssueManager(api, createSubscriptionHealth()); // Setting view starts the media_load timer (via the condition state // listener → evaluate → detectDynamic). diff --git a/tests/card-controller/issues/issues/event-subscription.test.ts b/tests/card-controller/issues/issues/event-subscription.test.ts new file mode 100644 index 00000000..c0e7006a --- /dev/null +++ b/tests/card-controller/issues/issues/event-subscription.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest'; +import { EventSubscriptionIssue } from '../../../../src/card-controller/issues/issues/event-subscription'; +import { Issue } from '../../../../src/card-controller/issues/types'; +import { SubscriptionFailure } from '../../../../src/ha/connection/subscription-health-monitor'; +import { localize } from '../../../../src/localize/localize'; +import { createSubscriptionHealth } from '../../test-utils'; + +const createSubscriptionFailure = (key: string): SubscriptionFailure => ({ + key, + error: new Error(key), + failureCount: 1, +}); + +describe('EventSubscriptionIssue', () => { + it('should register the change callback as a health listener', () => { + const health = createSubscriptionHealth(); + const changeCallback = vi.fn(); + + new EventSubscriptionIssue(health, changeCallback); + + expect(health.addListener).toBeCalledWith(changeCallback); + }); + + it('should have no issue when there are no failures', () => { + const health = createSubscriptionHealth(); + const issue = new EventSubscriptionIssue(health, vi.fn()); + + expect(issue.hasIssue()).toBe(false); + expect(issue.getIssue()).toBeNull(); + expect(issue.getNotification()).toBeNull(); + }); + + it('should describe the failing event types sorted, at medium severity', () => { + const health = createSubscriptionHealth(); + health.getFailures.mockReturnValue([ + createSubscriptionFailure('zebra_event'), + createSubscriptionFailure('alpha_event'), + ]); + const issue = new EventSubscriptionIssue(health, vi.fn()); + + expect(issue.hasIssue()).toBe(true); + + const description = issue.getIssue(); + expect(description?.severity).toBe('medium'); + expect(description?.notification.heading?.text).toBe( + localize('issues.event_subscription.heading'), + ); + expect(description?.notification.metadata?.map((detail) => detail.text)).toEqual([ + 'alpha_event', + 'zebra_event', + ]); + }); + + it('should offer a retry control on the notification', () => { + const health = createSubscriptionHealth(); + health.getFailures.mockReturnValue([createSubscriptionFailure('zha_event')]); + const issue = new EventSubscriptionIssue(health, vi.fn()); + + expect(issue.getNotification()?.controls?.[0].icon).toBe('mdi:refresh'); + }); + + it('should re-drive the failing subscriptions on retry', () => { + const health = createSubscriptionHealth(); + const issue = new EventSubscriptionIssue(health, vi.fn()); + + expect(issue.retry()).toBe(true); + expect(health.retry).toBeCalledTimes(1); + }); + + it('should not opt into IssueManager-scheduled retries', () => { + // No `needsRetry()` means the subscription manager stays the sole auto-retry + // loop; the IssueManager never schedules this issue. + const issue: Issue = new EventSubscriptionIssue(createSubscriptionHealth(), vi.fn()); + + expect(issue.needsRetry).toBeUndefined(); + }); + + it('should remove its health listener on destroy', () => { + const health = createSubscriptionHealth(); + const unsubscribe = vi.fn(); + health.addListener.mockReturnValue(unsubscribe); + const issue = new EventSubscriptionIssue(health, vi.fn()); + + issue.destroy(); + + expect(unsubscribe).toBeCalledTimes(1); + }); +}); diff --git a/tests/card-controller/issues/state-manager.test.ts b/tests/card-controller/issues/state-manager.test.ts index 29111624..b279fdd1 100644 --- a/tests/card-controller/issues/state-manager.test.ts +++ b/tests/card-controller/issues/state-manager.test.ts @@ -539,7 +539,7 @@ describe('IssueStateManager', () => { }); describe('destroy', () => { - it('should destroy all issues and clear', () => { + it('should clear, reset and destroy all issues', () => { const manager = createManager(); manager.destroy(); @@ -549,6 +549,14 @@ describe('IssueStateManager', () => { expect(mockConfigUpgrade.reset).toBeCalled(); expect(mockLegacyResource.reset).toBeCalled(); expect(mockMediaLoad.reset).toBeCalled(); + + assert(mockConfigUpgrade.destroy); + assert(mockLegacyResource.destroy); + assert(mockMediaLoad.destroy); + expect(mockConfigUpgrade.destroy).toBeCalled(); + expect(mockLegacyResource.destroy).toBeCalled(); + expect(mockMediaLoad.destroy).toBeCalled(); + expect(manager.getIssuePresence().size).toBe(0); }); }); diff --git a/tests/card-controller/test-utils.ts b/tests/card-controller/test-utils.ts new file mode 100644 index 00000000..00272abf --- /dev/null +++ b/tests/card-controller/test-utils.ts @@ -0,0 +1,14 @@ +import { vi } from 'vitest'; +import { mock, MockProxy } from 'vitest-mock-extended'; +import { SubscriptionHealthInterface } from '../../src/ha/connection/subscription-health-monitor'; + +// A benign mocked event-subscription health surface: no failures, listeners +// return a no-op unsubscribe. Tests configure `getFailures`/`retry` as needed. +export const createSubscriptionHealth = (): MockProxy< + SubscriptionHealthInterface +> => { + const health = mock>(); + health.getFailures.mockReturnValue([]); + health.addListener.mockReturnValue(vi.fn()); + return health; +}; diff --git a/tests/condition-trigger/triggers/factory.test.ts b/tests/condition-trigger/triggers/factory.test.ts index 5b7b7bcb..411eeee5 100644 --- a/tests/condition-trigger/triggers/factory.test.ts +++ b/tests/condition-trigger/triggers/factory.test.ts @@ -6,6 +6,7 @@ import { CallTrigger } from '../../../src/condition-trigger/triggers/triggers/ca import { CameraTrigger } from '../../../src/condition-trigger/triggers/triggers/camera'; import { ConfigTrigger } from '../../../src/condition-trigger/triggers/triggers/config'; import { DisplayModeTrigger } from '../../../src/condition-trigger/triggers/triggers/display-mode'; +import { EventTrigger } from '../../../src/condition-trigger/triggers/triggers/event'; import { ExpandTrigger } from '../../../src/condition-trigger/triggers/triggers/expand'; import { FullscreenTrigger } from '../../../src/condition-trigger/triggers/triggers/fullscreen'; import { InitializedTrigger } from '../../../src/condition-trigger/triggers/triggers/initialized'; @@ -24,6 +25,7 @@ import { } from '../../../src/condition-trigger/triggers/triggers/types'; import { ViewTrigger } from '../../../src/condition-trigger/triggers/triggers/view'; import { Trigger } from '../../../src/config/schema/condition-trigger/triggers/types'; +import { createHASSManager } from '../../test-utils'; type TriggerEvaluatorConstructor = new (...args: never[]) => TriggerEvaluator; @@ -32,9 +34,11 @@ describe('createTriggerEvaluator', () => { const context = (): TriggerEvaluatorContext => ({ stateManager: new ConditionStateManager(), templateRenderer: new TemplateRenderer(), + hassManager: createHASSManager(), }); it.each<[Trigger, TriggerEvaluatorConstructor]>([ + [{ trigger: 'event', event_type: 'zha_event' }, EventTrigger], [{ trigger: 'state', entity_id: 'binary_sensor.x' }, StateTrigger], [{ trigger: 'numeric_state', entity_id: 'sensor.x', above: 5 }, NumericStateTrigger], [{ trigger: 'template', value_template: '{{ true }}' }, TemplateTrigger], diff --git a/tests/condition-trigger/triggers/manager.test.ts b/tests/condition-trigger/triggers/manager.test.ts index c2e7c0e4..4253ae30 100644 --- a/tests/condition-trigger/triggers/manager.test.ts +++ b/tests/condition-trigger/triggers/manager.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, Mock, vi } from 'vitest'; import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager'; import { TriggersManager } from '../../../src/condition-trigger/triggers/manager'; import { Trigger } from '../../../src/config/schema/condition-trigger/triggers/types'; -import { createHASS, createStateEntity } from '../../test-utils'; +import { createHASS, createHASSManager, createStateEntity } from '../../test-utils'; // @vitest-environment jsdom describe('TriggersManager', () => { @@ -14,7 +14,7 @@ describe('TriggersManager', () => { listener: Mock; } => { const stateManager = new ConditionStateManager(); - const manager = new TriggersManager(triggers, stateManager); + const manager = new TriggersManager(triggers, stateManager, createHASSManager()); const listener = vi.fn(); return { manager, stateManager, listener }; }; @@ -107,6 +107,7 @@ describe('TriggersManager', () => { const manager = new TriggersManager( [{ trigger: 'camera', cameras: ['front'], enabled }], stateManager, + createHASSManager(), ); const listener = vi.fn(); manager.addListener(listener); @@ -169,6 +170,7 @@ describe('TriggersManager', () => { const manager = new TriggersManager( [{ trigger: 'camera', cameras: ['front', 'back'], enabled: ENABLED_TEMPLATE }], stateManager, + createHASSManager(), ); const listener = vi.fn(); manager.addListener(listener); diff --git a/tests/condition-trigger/triggers/triggers/call.test.ts b/tests/condition-trigger/triggers/triggers/call.test.ts index 82c1cb6b..dd0d737f 100644 --- a/tests/condition-trigger/triggers/triggers/call.test.ts +++ b/tests/condition-trigger/triggers/triggers/call.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, Mock, vi } from 'vitest'; -import { TemplateRenderer } from '../../../../src/card-controller/templates'; import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; import { CallTrigger } from '../../../../src/condition-trigger/triggers/triggers/call'; import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; +import { createTriggerEvaluatorContext } from './test-utils'; // @vitest-environment jsdom describe('CallTrigger', () => { @@ -11,10 +11,9 @@ describe('CallTrigger', () => { ): { stateManager: ConditionStateManager; callback: Mock } => { const stateManager = new ConditionStateManager(); const callback = vi.fn(); - new CallTrigger(trigger, { - stateManager, - templateRenderer: new TemplateRenderer(), - }).subscribe(callback); + new CallTrigger(trigger, createTriggerEvaluatorContext({ stateManager })).subscribe( + callback, + ); return { stateManager, callback }; }; diff --git a/tests/condition-trigger/triggers/triggers/camera.test.ts b/tests/condition-trigger/triggers/triggers/camera.test.ts index 8a5e9b3e..7db77a86 100644 --- a/tests/condition-trigger/triggers/triggers/camera.test.ts +++ b/tests/condition-trigger/triggers/triggers/camera.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, Mock, vi } from 'vitest'; -import { TemplateRenderer } from '../../../../src/card-controller/templates'; import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; import { CameraTrigger } from '../../../../src/condition-trigger/triggers/triggers/camera'; import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; import { createConfig } from '../../../test-utils'; +import { createTriggerEvaluatorContext } from './test-utils'; // @vitest-environment jsdom describe('CameraTrigger', () => { @@ -16,10 +16,10 @@ describe('CameraTrigger', () => { } => { const stateManager = new ConditionStateManager(); const callback = vi.fn(); - const cameraTrigger = new CameraTrigger(trigger, { - stateManager, - templateRenderer: new TemplateRenderer(), - }); + const cameraTrigger = new CameraTrigger( + trigger, + createTriggerEvaluatorContext({ stateManager }), + ); return { cameraTrigger, stateManager, callback }; }; diff --git a/tests/condition-trigger/triggers/triggers/config.test.ts b/tests/condition-trigger/triggers/triggers/config.test.ts index bcf01edd..565c170a 100644 --- a/tests/condition-trigger/triggers/triggers/config.test.ts +++ b/tests/condition-trigger/triggers/triggers/config.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, Mock, vi } from 'vitest'; -import { TemplateRenderer } from '../../../../src/card-controller/templates'; import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; import { ConfigTrigger } from '../../../../src/condition-trigger/triggers/triggers/config'; import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; import { createConfig } from '../../../test-utils'; +import { createTriggerEvaluatorContext } from './test-utils'; // @vitest-environment jsdom describe('ConfigTrigger', () => { @@ -16,10 +16,10 @@ describe('ConfigTrigger', () => { } => { const stateManager = new ConditionStateManager(); const callback = vi.fn(); - const configTrigger = new ConfigTrigger(trigger, { - stateManager, - templateRenderer: new TemplateRenderer(), - }); + const configTrigger = new ConfigTrigger( + trigger, + createTriggerEvaluatorContext({ stateManager }), + ); return { configTrigger, stateManager, callback }; }; diff --git a/tests/condition-trigger/triggers/triggers/display-mode.test.ts b/tests/condition-trigger/triggers/triggers/display-mode.test.ts index da99e0cb..d6fa02d6 100644 --- a/tests/condition-trigger/triggers/triggers/display-mode.test.ts +++ b/tests/condition-trigger/triggers/triggers/display-mode.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, Mock, vi } from 'vitest'; -import { TemplateRenderer } from '../../../../src/card-controller/templates'; import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; import { DisplayModeTrigger } from '../../../../src/condition-trigger/triggers/triggers/display-mode'; import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; +import { createTriggerEvaluatorContext } from './test-utils'; // @vitest-environment jsdom describe('DisplayModeTrigger', () => { @@ -11,10 +11,10 @@ describe('DisplayModeTrigger', () => { ): { stateManager: ConditionStateManager; callback: Mock } => { const stateManager = new ConditionStateManager(); const callback = vi.fn(); - new DisplayModeTrigger(trigger, { - stateManager, - templateRenderer: new TemplateRenderer(), - }).subscribe(callback); + new DisplayModeTrigger( + trigger, + createTriggerEvaluatorContext({ stateManager }), + ).subscribe(callback); return { stateManager, callback }; }; diff --git a/tests/condition-trigger/triggers/triggers/event.test.ts b/tests/condition-trigger/triggers/triggers/event.test.ts new file mode 100644 index 00000000..67ef1729 --- /dev/null +++ b/tests/condition-trigger/triggers/triggers/event.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it, Mock, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { + EventSubscriptionRequest, + EventWatcherSubscriptionInterface, +} from '../../../../src/card-controller/hass/event-watcher'; +import { EventTrigger } from '../../../../src/condition-trigger/triggers/triggers/event'; +import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; +import { createHASSEvent, createHASSManager } from '../../../test-utils'; +import { createTriggerEvaluatorContext } from './test-utils'; + +interface Harness { + trigger: EventTrigger; + eventWatcher: EventWatcherSubscriptionInterface; + callback: Mock; +} + +const create = (config: TriggerOfType<'event'>): Harness => { + const eventWatcher = mock(); + const hassManager = createHASSManager({ eventWatcher }); + + const callback = vi.fn(); + const trigger = new EventTrigger( + config, + createTriggerEvaluatorContext({ hassManager }), + ); + return { trigger, eventWatcher, callback }; +}; + +const getLastMatcher = ( + eventWatcher: EventWatcherSubscriptionInterface, + n = 0, +): EventSubscriptionRequest['matcher'] => + vi.mocked(eventWatcher.subscribe).mock.calls[n][0].matcher; + +const callEventCallback = ( + eventWatcher: EventWatcherSubscriptionInterface, + event: ReturnType, + n = 0, +): void => { + vi.mocked(eventWatcher.subscribe).mock.calls[n][0].callback(event); +}; + +describe('EventTrigger', () => { + it('should register one EventWatcher request per event_type', () => { + const { trigger, eventWatcher, callback } = create({ + trigger: 'event', + event_type: 'zha_event', + }); + trigger.subscribe(callback); + expect(eventWatcher.subscribe).toBeCalledTimes(1); + expect(vi.mocked(eventWatcher.subscribe).mock.calls[0][0].event_type).toBe( + 'zha_event', + ); + }); + + it('should dedupe duplicate event_types in list form', () => { + // A repeated entry would otherwise produce two requests and fire the + // callback twice for every matching event. + const { trigger, eventWatcher, callback } = create({ + trigger: 'event', + event_type: ['zha_event', 'zha_event'], + }); + trigger.subscribe(callback); + expect(eventWatcher.subscribe).toBeCalledTimes(1); + }); + + it('should expand list-form event_type into one request per type', () => { + const { trigger, eventWatcher, callback } = create({ + trigger: 'event', + event_type: ['zha_event', 'deconz_event'], + }); + trigger.subscribe(callback); + expect(eventWatcher.subscribe).toBeCalledTimes(2); + expect(vi.mocked(eventWatcher.subscribe).mock.calls[0][0].event_type).toBe( + 'zha_event', + ); + expect(vi.mocked(eventWatcher.subscribe).mock.calls[1][0].event_type).toBe( + 'deconz_event', + ); + }); + + it('should fire with the full HA event on dispatch', () => { + const { trigger, eventWatcher, callback } = create({ + trigger: 'event', + event_type: 'zha_event', + }); + trigger.subscribe(callback); + const event = createHASSEvent('zha_event', { command: 'press' }); + callEventCallback(eventWatcher, event); + expect(callback).toBeCalledWith({ platform: 'event', event }); + }); + + it('should omit the matcher when neither event_data nor context is set', () => { + const { trigger, eventWatcher, callback } = create({ + trigger: 'event', + event_type: 'zha_event', + }); + trigger.subscribe(callback); + expect(vi.mocked(eventWatcher.subscribe).mock.calls[0][0].matcher).toBeUndefined(); + }); + + it('should attach an event_data matcher', () => { + const { trigger, eventWatcher, callback } = create({ + trigger: 'event', + event_type: 'zha_event', + event_data: { command: 'press' }, + }); + trigger.subscribe(callback); + const matcher = getLastMatcher(eventWatcher); + expect(matcher?.(createHASSEvent('zha_event', { command: 'press' }))).toBe(true); + expect(matcher?.(createHASSEvent('zha_event', { command: 'release' }))).toBe(false); + }); + + it('should attach a context matcher', () => { + const { trigger, eventWatcher, callback } = create({ + trigger: 'event', + event_type: 'zha_event', + context: { user_id: 'u-1' }, + }); + trigger.subscribe(callback); + const matcher = getLastMatcher(eventWatcher); + expect( + matcher?.( + createHASSEvent('zha_event', {}, { id: 'i', user_id: 'u-1', parent_id: null }), + ), + ).toBe(true); + expect( + matcher?.( + createHASSEvent('zha_event', {}, { id: 'i', user_id: 'u-2', parent_id: null }), + ), + ).toBe(false); + }); + + it('should AND event_data and context filters', () => { + const { trigger, eventWatcher, callback } = create({ + trigger: 'event', + event_type: 'zha_event', + event_data: { command: 'press' }, + context: { user_id: 'u-1' }, + }); + trigger.subscribe(callback); + const matcher = getLastMatcher(eventWatcher); + + const matchingContext = { id: 'i', user_id: 'u-1', parent_id: null }; + const nonMatchingContext = { id: 'i', user_id: 'u-2', parent_id: null }; + + expect( + matcher?.(createHASSEvent('zha_event', { command: 'press' }, matchingContext)), + ).toBe(true); + expect( + matcher?.(createHASSEvent('zha_event', { command: 'press' }, nonMatchingContext)), + ).toBe(false); + expect( + matcher?.(createHASSEvent('zha_event', { command: 'release' }, matchingContext)), + ).toBe(false); + }); + + it('should unsubscribe every request on destroy', () => { + const { trigger, eventWatcher, callback } = create({ + trigger: 'event', + event_type: ['zha_event', 'deconz_event'], + }); + trigger.subscribe(callback); + trigger.destroy(); + expect(eventWatcher.unsubscribe).toBeCalledTimes(2); + }); + + it('should be a no-op when destroyed without subscribing', () => { + const { trigger, eventWatcher } = create({ + trigger: 'event', + event_type: 'zha_event', + }); + trigger.destroy(); + expect(eventWatcher.unsubscribe).not.toBeCalled(); + }); +}); diff --git a/tests/condition-trigger/triggers/triggers/expand.test.ts b/tests/condition-trigger/triggers/triggers/expand.test.ts index ab067737..efb55b76 100644 --- a/tests/condition-trigger/triggers/triggers/expand.test.ts +++ b/tests/condition-trigger/triggers/triggers/expand.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, Mock, vi } from 'vitest'; -import { TemplateRenderer } from '../../../../src/card-controller/templates'; import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; import { ExpandTrigger } from '../../../../src/condition-trigger/triggers/triggers/expand'; import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; +import { createTriggerEvaluatorContext } from './test-utils'; // @vitest-environment jsdom describe('ExpandTrigger', () => { @@ -11,10 +11,10 @@ describe('ExpandTrigger', () => { ): { stateManager: ConditionStateManager; callback: Mock } => { const stateManager = new ConditionStateManager(); const callback = vi.fn(); - new ExpandTrigger(trigger, { - stateManager, - templateRenderer: new TemplateRenderer(), - }).subscribe(callback); + new ExpandTrigger( + trigger, + createTriggerEvaluatorContext({ stateManager }), + ).subscribe(callback); return { stateManager, callback }; }; diff --git a/tests/condition-trigger/triggers/triggers/fullscreen.test.ts b/tests/condition-trigger/triggers/triggers/fullscreen.test.ts index 34c54757..b641cdf3 100644 --- a/tests/condition-trigger/triggers/triggers/fullscreen.test.ts +++ b/tests/condition-trigger/triggers/triggers/fullscreen.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, Mock, vi } from 'vitest'; -import { TemplateRenderer } from '../../../../src/card-controller/templates'; import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; import { FullscreenTrigger } from '../../../../src/condition-trigger/triggers/triggers/fullscreen'; import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; +import { createTriggerEvaluatorContext } from './test-utils'; // @vitest-environment jsdom describe('FullscreenTrigger', () => { @@ -15,10 +15,10 @@ describe('FullscreenTrigger', () => { } => { const stateManager = new ConditionStateManager(); const callback = vi.fn(); - const fullscreenTrigger = new FullscreenTrigger(trigger, { - stateManager, - templateRenderer: new TemplateRenderer(), - }); + const fullscreenTrigger = new FullscreenTrigger( + trigger, + createTriggerEvaluatorContext({ stateManager }), + ); return { fullscreenTrigger, stateManager, callback }; }; diff --git a/tests/condition-trigger/triggers/triggers/interaction.test.ts b/tests/condition-trigger/triggers/triggers/interaction.test.ts index 2e33fe84..846f45d7 100644 --- a/tests/condition-trigger/triggers/triggers/interaction.test.ts +++ b/tests/condition-trigger/triggers/triggers/interaction.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, Mock, vi } from 'vitest'; -import { TemplateRenderer } from '../../../../src/card-controller/templates'; import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; import { InteractionTrigger } from '../../../../src/condition-trigger/triggers/triggers/interaction'; import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; +import { createTriggerEvaluatorContext } from './test-utils'; // @vitest-environment jsdom describe('InteractionTrigger', () => { @@ -11,10 +11,10 @@ describe('InteractionTrigger', () => { ): { stateManager: ConditionStateManager; callback: Mock } => { const stateManager = new ConditionStateManager(); const callback = vi.fn(); - new InteractionTrigger(trigger, { - stateManager, - templateRenderer: new TemplateRenderer(), - }).subscribe(callback); + new InteractionTrigger( + trigger, + createTriggerEvaluatorContext({ stateManager }), + ).subscribe(callback); return { stateManager, callback }; }; diff --git a/tests/condition-trigger/triggers/triggers/media-loaded.test.ts b/tests/condition-trigger/triggers/triggers/media-loaded.test.ts index a377bc2f..a2e7ee94 100644 --- a/tests/condition-trigger/triggers/triggers/media-loaded.test.ts +++ b/tests/condition-trigger/triggers/triggers/media-loaded.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, Mock, vi } from 'vitest'; -import { TemplateRenderer } from '../../../../src/card-controller/templates'; import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; import { MediaLoadedTrigger } from '../../../../src/condition-trigger/triggers/triggers/media-loaded'; import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; import { createMediaLoadedInfo } from '../../../test-utils'; +import { createTriggerEvaluatorContext } from './test-utils'; // @vitest-environment jsdom describe('MediaLoadedTrigger', () => { @@ -12,10 +12,10 @@ describe('MediaLoadedTrigger', () => { ): { stateManager: ConditionStateManager; callback: Mock } => { const stateManager = new ConditionStateManager(); const callback = vi.fn(); - new MediaLoadedTrigger(trigger, { - stateManager, - templateRenderer: new TemplateRenderer(), - }).subscribe(callback); + new MediaLoadedTrigger( + trigger, + createTriggerEvaluatorContext({ stateManager }), + ).subscribe(callback); return { stateManager, callback }; }; diff --git a/tests/condition-trigger/triggers/triggers/microphone.test.ts b/tests/condition-trigger/triggers/triggers/microphone.test.ts index 555add54..ace13237 100644 --- a/tests/condition-trigger/triggers/triggers/microphone.test.ts +++ b/tests/condition-trigger/triggers/triggers/microphone.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, Mock, vi } from 'vitest'; -import { TemplateRenderer } from '../../../../src/card-controller/templates'; import { MicrophoneState } from '../../../../src/card-controller/types'; import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; import { MicrophoneTrigger } from '../../../../src/condition-trigger/triggers/triggers/microphone'; import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; +import { createTriggerEvaluatorContext } from './test-utils'; // @vitest-environment jsdom describe('MicrophoneTrigger', () => { @@ -19,10 +19,10 @@ describe('MicrophoneTrigger', () => { ): { stateManager: ConditionStateManager; callback: Mock } => { const stateManager = new ConditionStateManager(); const callback = vi.fn(); - new MicrophoneTrigger(trigger, { - stateManager, - templateRenderer: new TemplateRenderer(), - }).subscribe(callback); + new MicrophoneTrigger( + trigger, + createTriggerEvaluatorContext({ stateManager }), + ).subscribe(callback); return { stateManager, callback }; }; diff --git a/tests/condition-trigger/triggers/triggers/numeric-state.test.ts b/tests/condition-trigger/triggers/triggers/numeric-state.test.ts index 69da7f2f..60e9d171 100644 --- a/tests/condition-trigger/triggers/triggers/numeric-state.test.ts +++ b/tests/condition-trigger/triggers/triggers/numeric-state.test.ts @@ -1,10 +1,10 @@ import { HassEntities, HassEntity } from 'home-assistant-js-websocket'; import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { TemplateRenderer } from '../../../../src/card-controller/templates'; import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; import { NumericStateTrigger } from '../../../../src/condition-trigger/triggers/triggers/numeric-state'; import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; import { createHASS, createStateEntity } from '../../../test-utils'; +import { createTriggerEvaluatorContext } from './test-utils'; const SENSOR = 'sensor.temperature'; const SENSOR_TWO = 'sensor.humidity'; @@ -21,10 +21,10 @@ describe('NumericStateTrigger', () => { } => { const stateManager = new ConditionStateManager(); const callback = vi.fn(); - const trigger = new NumericStateTrigger(config, { - stateManager, - templateRenderer: new TemplateRenderer(), - }); + const trigger = new NumericStateTrigger( + config, + createTriggerEvaluatorContext({ stateManager }), + ); return { trigger, stateManager, callback }; }; diff --git a/tests/condition-trigger/triggers/triggers/state.test.ts b/tests/condition-trigger/triggers/triggers/state.test.ts index b49e6660..908adea3 100644 --- a/tests/condition-trigger/triggers/triggers/state.test.ts +++ b/tests/condition-trigger/triggers/triggers/state.test.ts @@ -1,10 +1,10 @@ import { HassEntities, HassEntity } from 'home-assistant-js-websocket'; import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { TemplateRenderer } from '../../../../src/card-controller/templates'; import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; import { StateTrigger } from '../../../../src/condition-trigger/triggers/triggers/state'; import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; import { createHASS, createStateEntity } from '../../../test-utils'; +import { createTriggerEvaluatorContext } from './test-utils'; const ENTITY = 'binary_sensor.door'; const ENTITY_TWO = 'binary_sensor.window'; @@ -20,10 +20,10 @@ describe('StateTrigger', () => { } => { const stateManager = new ConditionStateManager(); const callback = vi.fn(); - const trigger = new StateTrigger(config, { - stateManager, - templateRenderer: new TemplateRenderer(), - }); + const trigger = new StateTrigger( + config, + createTriggerEvaluatorContext({ stateManager }), + ); return { trigger, stateManager, callback }; }; diff --git a/tests/condition-trigger/triggers/triggers/template.test.ts b/tests/condition-trigger/triggers/triggers/template.test.ts index 562f5e9f..b19dfe8e 100644 --- a/tests/condition-trigger/triggers/triggers/template.test.ts +++ b/tests/condition-trigger/triggers/triggers/template.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { TemplateRenderer } from '../../../../src/card-controller/templates'; import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; import { TemplateTrigger } from '../../../../src/condition-trigger/triggers/triggers/template'; import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; import { createHASS, createStateEntity } from '../../../test-utils'; +import { createTriggerEvaluatorContext } from './test-utils'; const ENTITY_ONE = 'sensor.foo'; const ENTITY_TWO = 'sensor.bar'; @@ -21,10 +21,10 @@ describe('TemplateTrigger', () => { } => { const stateManager = new ConditionStateManager(); const callback = vi.fn(); - const trigger = new TemplateTrigger(config, { - stateManager, - templateRenderer: new TemplateRenderer(), - }); + const trigger = new TemplateTrigger( + config, + createTriggerEvaluatorContext({ stateManager }), + ); return { trigger, stateManager, callback }; }; diff --git a/tests/condition-trigger/triggers/triggers/test-utils.ts b/tests/condition-trigger/triggers/triggers/test-utils.ts new file mode 100644 index 00000000..899c2285 --- /dev/null +++ b/tests/condition-trigger/triggers/triggers/test-utils.ts @@ -0,0 +1,13 @@ +import { TemplateRenderer } from '../../../../src/card-controller/templates'; +import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; +import { TriggerEvaluatorContext } from '../../../../src/condition-trigger/triggers/triggers/types'; +import { createHASSManager } from '../../../test-utils'; + +export const createTriggerEvaluatorContext = ( + context?: Partial, +): TriggerEvaluatorContext => ({ + stateManager: new ConditionStateManager(), + templateRenderer: new TemplateRenderer(), + hassManager: createHASSManager(), + ...context, +}); diff --git a/tests/condition-trigger/triggers/triggers/triggered.test.ts b/tests/condition-trigger/triggers/triggers/triggered.test.ts index 57f57201..0039463e 100644 --- a/tests/condition-trigger/triggers/triggers/triggered.test.ts +++ b/tests/condition-trigger/triggers/triggers/triggered.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, Mock, vi } from 'vitest'; -import { TemplateRenderer } from '../../../../src/card-controller/templates'; import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; import { TriggeredTrigger } from '../../../../src/condition-trigger/triggers/triggers/triggered'; import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; +import { createTriggerEvaluatorContext } from './test-utils'; // @vitest-environment jsdom describe('TriggeredTrigger', () => { @@ -11,10 +11,10 @@ describe('TriggeredTrigger', () => { ): { stateManager: ConditionStateManager; callback: Mock } => { const stateManager = new ConditionStateManager(); const callback = vi.fn(); - new TriggeredTrigger(trigger, { - stateManager, - templateRenderer: new TemplateRenderer(), - }).subscribe(callback); + new TriggeredTrigger( + trigger, + createTriggerEvaluatorContext({ stateManager }), + ).subscribe(callback); return { stateManager, callback }; }; diff --git a/tests/condition-trigger/triggers/triggers/view.test.ts b/tests/condition-trigger/triggers/triggers/view.test.ts index 313e4fe4..a377dded 100644 --- a/tests/condition-trigger/triggers/triggers/view.test.ts +++ b/tests/condition-trigger/triggers/triggers/view.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, Mock, vi } from 'vitest'; -import { TemplateRenderer } from '../../../../src/card-controller/templates'; import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; +import { createTriggerEvaluatorContext } from './test-utils'; import { ViewTrigger } from '../../../../src/condition-trigger/triggers/triggers/view'; // @vitest-environment jsdom @@ -15,10 +15,10 @@ describe('ViewTrigger', () => { } => { const stateManager = new ConditionStateManager(); const callback = vi.fn(); - const viewTrigger = new ViewTrigger(trigger, { - stateManager, - templateRenderer: new TemplateRenderer(), - }); + const viewTrigger = new ViewTrigger( + trigger, + createTriggerEvaluatorContext({ stateManager }), + ); return { viewTrigger, stateManager, callback }; }; diff --git a/tests/config/schema/condition-trigger/parity.test.ts b/tests/config/schema/condition-trigger/parity.test.ts index b2367371..263aad41 100644 --- a/tests/config/schema/condition-trigger/parity.test.ts +++ b/tests/config/schema/condition-trigger/parity.test.ts @@ -28,8 +28,9 @@ const getTypes = ( // composites. const COMPOSITES = ['or', 'and', 'not']; -// `config` only ever detects a change, so it is a trigger but not a condition. -const TRIGGER_ONLY = ['config']; +// `config` only ever detects a change, and `event` is HA-side trigger-only (HA +// has no `condition: event` -- events are momentary). +const TRIGGER_ONLY = ['config', 'event']; // `user`/`user_agent` are static per session, so they are conditions but not // triggers. diff --git a/tests/ha/connection/subscription-health-monitor.test.ts b/tests/ha/connection/subscription-health-monitor.test.ts new file mode 100644 index 00000000..100df8c3 --- /dev/null +++ b/tests/ha/connection/subscription-health-monitor.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, vi } from 'vitest'; +import { SubscriptionHealthMonitor } from '../../../src/ha/connection/subscription-health-monitor'; +import { HASSWebSocketSubscriptionStatus } from '../../../src/ha/connection/subscription-manager'; + +interface TestRequest { + id: string; +} + +type State = HASSWebSocketSubscriptionStatus['state']; + +const status = ( + state: State, + request: TestRequest, + key: string, + extra?: { error?: unknown; failureCount?: number }, +): HASSWebSocketSubscriptionStatus => ({ + state, + request, + key, + ...extra, +}); + +describe('SubscriptionHealthMonitor', () => { + it('should report a failing key with its error and failure count', () => { + const monitor = new SubscriptionHealthMonitor(vi.fn()); + const error = new Error('boom'); + + monitor.update( + status('failing', { id: 'a' }, 'zha_event', { error, failureCount: 2 }), + ); + + expect(monitor.getFailures()).toEqual([ + { key: 'zha_event', error, failureCount: 2 }, + ]); + }); + + it('should ignore waiting so it never clears a failing key', () => { + const monitor = new SubscriptionHealthMonitor(vi.fn()); + const request = { id: 'a' }; + + monitor.update(status('failing', request, 'zha_event', { failureCount: 1 })); + monitor.update(status('waiting', request, 'zha_event')); + + expect(monitor.getFailures().map((f) => f.key)).toEqual(['zha_event']); + }); + + it('should clear a key once it subscribes', () => { + const monitor = new SubscriptionHealthMonitor(vi.fn()); + const request = { id: 'a' }; + + monitor.update(status('failing', request, 'zha_event', { failureCount: 1 })); + monitor.update(status('subscribed', request, 'zha_event')); + + expect(monitor.getFailures()).toEqual([]); + }); + + it('should clear a key once its request unsubscribes', () => { + const monitor = new SubscriptionHealthMonitor(vi.fn()); + const request = { id: 'a' }; + + monitor.update(status('failing', request, 'zha_event', { failureCount: 1 })); + monitor.update(status('unsubscribed', request, 'zha_event')); + + expect(monitor.getFailures()).toEqual([]); + }); + + it('should report each failing key once regardless of subscriber count', () => { + const monitor = new SubscriptionHealthMonitor(vi.fn()); + + monitor.update(status('failing', { id: 'a' }, 'zha_event', { failureCount: 1 })); + monitor.update(status('failing', { id: 'b' }, 'zha_event', { failureCount: 1 })); + + expect(monitor.getFailures().map((f) => f.key)).toEqual(['zha_event']); + }); + + it('should notify listeners only when a key changes failing state', () => { + const monitor = new SubscriptionHealthMonitor(vi.fn()); + const listener = vi.fn(); + monitor.addListener(listener); + const request = { id: 'a' }; + + // Healthy -> failing: one notification. + monitor.update(status('failing', request, 'zha_event', { failureCount: 1 })); + expect(listener).toBeCalledTimes(1); + + // Still failing (next attempt, same key): no membership change, no notify. + monitor.update(status('failing', request, 'zha_event', { failureCount: 2 })); + expect(listener).toBeCalledTimes(1); + + // Failing -> healthy: one more notification. + monitor.update(status('subscribed', request, 'zha_event')); + expect(listener).toBeCalledTimes(2); + }); + + it('should stop notifying after the returned unsubscribe is called', () => { + const monitor = new SubscriptionHealthMonitor(vi.fn()); + const listener = vi.fn(); + const remove = monitor.addListener(listener); + + remove(); + monitor.update(status('failing', { id: 'a' }, 'zha_event', { failureCount: 1 })); + + expect(listener).not.toBeCalled(); + }); + + it('should retry one request per failing key and leave healthy keys alone', () => { + const retry = vi.fn(); + const monitor = new SubscriptionHealthMonitor(retry); + const failingA = { id: 'a' }; + const failingB = { id: 'b' }; + const healthy = { id: 'c' }; + + // Two requests on a failing key, plus a separate healthy key. + monitor.update(status('failing', failingA, 'zha_event', { failureCount: 1 })); + monitor.update(status('failing', failingB, 'zha_event', { failureCount: 1 })); + monitor.update(status('subscribed', healthy, 'deconz_event')); + + monitor.retry(); + + // Exactly one retry, for one of the failing key's requests; never the + // healthy key. + expect(retry).toBeCalledTimes(1); + expect([failingA, failingB]).toContainEqual(retry.mock.calls[0][0]); + }); + + it('should retry the failing request even when a subscribed sibling is stored first', () => { + const retry = vi.fn(); + const monitor = new SubscriptionHealthMonitor(retry); + const subscribed = { id: 'a' }; + const failing = { id: 'b' }; + + // Subscribed sibling recorded before the failing one on the same key. + monitor.update(status('subscribed', subscribed, 'zha_event')); + monitor.update(status('failing', failing, 'zha_event', { failureCount: 1 })); + + monitor.retry(); + + expect(retry).toBeCalledTimes(1); + expect(retry).toBeCalledWith(failing); + }); + + it('should retry one request for each distinct failing key', () => { + const retry = vi.fn(); + const monitor = new SubscriptionHealthMonitor(retry); + const a = { id: 'a' }; + const b = { id: 'b' }; + + monitor.update(status('failing', a, 'zha_event', { failureCount: 1 })); + monitor.update(status('failing', b, 'deconz_event', { failureCount: 1 })); + + monitor.retry(); + + expect(retry).toBeCalledTimes(2); + expect(retry.mock.calls.map((c) => c[0])).toEqual(expect.arrayContaining([a, b])); + }); + + it('should not notify when a never-failed request unsubscribes', () => { + const monitor = new SubscriptionHealthMonitor(vi.fn()); + const listener = vi.fn(); + monitor.addListener(listener); + + monitor.update(status('unsubscribed', { id: 'a' }, 'zha_event')); + + expect(listener).not.toBeCalled(); + expect(monitor.getFailures()).toEqual([]); + }); +}); diff --git a/tests/ha/connection/subscription-manager.test.ts b/tests/ha/connection/subscription-manager.test.ts new file mode 100644 index 00000000..87b37c81 --- /dev/null +++ b/tests/ha/connection/subscription-manager.test.ts @@ -0,0 +1,797 @@ +import { Connection, STATE_RUNNING, STATE_STARTING } from 'home-assistant-js-websocket'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { + HASSConnectionSubscriptionManager, + HASSWebSocketStatusCallback, + HASSWebSocketSubscriptionStatus, +} from '../../../src/ha/connection/subscription-manager'; +import { + HASSWebSocketLiveness, + HASSWebSocketOpenCallback, +} from '../../../src/ha/connection/types'; +import { HASSSource } from '../../../src/ha/source'; +import { + createHASS, + createHASSSource, + flushPromises, + useDeterministicTimers, +} from '../../test-utils'; + +interface TestRequest { + key: string; + label?: string; +} + +interface CapturedOpenCall { + request: TestRequest; + connection: Connection; + guard: HASSWebSocketLiveness; + unsub: () => Promise; +} + +// Captures every open-callback invocation so tests can drive its dispatcher and +// observe the guard state. +const createRecordingOpenCallback = (): { + openCallback: HASSWebSocketOpenCallback; + calls: CapturedOpenCall[]; +} => { + const calls: CapturedOpenCall[] = []; + const openCallback: HASSWebSocketOpenCallback = async (connection, guard) => { + const unsub = vi.fn().mockResolvedValue(undefined); + calls.push({ request: { key: 'unused' }, connection, guard, unsub }); + return unsub; + }; + return { openCallback, calls }; +}; + +// An open callback that always rejects, driving the retry/backoff machinery. +const createFailingOpenCallback = (): HASSWebSocketOpenCallback => + vi.fn().mockRejectedValue(new Error('boom')); + +const createManager = ( + source: HASSSource, +): HASSConnectionSubscriptionManager => + new HASSConnectionSubscriptionManager((r) => r.key, source); + +// Wires a source (seeded with `initial`) to a fresh manager, returning the +// source's drivers alongside it. +const setup = (initial: Parameters[0] = createHASS()) => { + const { source, push, getListenerCount } = createHASSSource(initial); + return { manager: createManager(source), push, getListenerCount }; +}; + +// A HASS on a brand-new connection, so pushing it forces an era swap. +const createSwappedHASS = (): ReturnType => { + const hass = createHASS(); + hass.connection = mock(); + vi.mocked(hass.connection.subscribeEvents).mockResolvedValue(vi.fn()); + return hass; +}; + +// @vitest-environment jsdom +describe('HASSConnectionSubscriptionManager', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('attach lifecycle', () => { + it('should not attach to source until first subscribe', () => { + const { getListenerCount } = setup(); + expect(getListenerCount()).toBe(0); + }); + + it('should attach on first subscribe and detach on last unsubscribe', () => { + const { manager, getListenerCount } = setup(); + const req = { key: 'a' }; + const { openCallback } = createRecordingOpenCallback(); + + manager.subscribe(req, openCallback); + expect(getListenerCount()).toBe(1); + + manager.unsubscribe(req); + expect(getListenerCount()).toBe(0); + }); + + it('should bootstrap from source on first attach', async () => { + const hass = createHASS(); + const { manager } = setup(hass); + const { openCallback, calls } = createRecordingOpenCallback(); + + manager.subscribe({ key: 'a' }, openCallback); + await flushPromises(); + + expect(calls).toHaveLength(1); + expect(calls[0].connection).toBe(hass.connection); + }); + + it('should defer when source has no HASS yet', async () => { + const { manager, push } = setup(null); + const { openCallback, calls } = createRecordingOpenCallback(); + + manager.subscribe({ key: 'a' }, openCallback); + await flushPromises(); + expect(calls).toHaveLength(0); + + push(createHASS()); + await flushPromises(); + expect(calls).toHaveLength(1); + }); + + it('should defer when initial HASS is not ready', async () => { + const notReady = createHASS(); + notReady.config.state = STATE_STARTING; + const { manager, push } = setup(notReady); + const { openCallback, calls } = createRecordingOpenCallback(); + + manager.subscribe({ key: 'a' }, openCallback); + await flushPromises(); + expect(calls).toHaveLength(0); + + const ready = createHASS(); + ready.config.state = STATE_RUNNING; + push(ready); + await flushPromises(); + expect(calls).toHaveLength(1); + }); + }); + + describe('era transitions', () => { + it('should replace KSM on connection swap and re-submit requests', async () => { + const hass1 = createHASS(); + const { manager, push } = setup(hass1); + const { openCallback, calls } = createRecordingOpenCallback(); + + manager.subscribe({ key: 'a' }, openCallback); + await flushPromises(); + expect(calls).toHaveLength(1); + expect(calls[0].connection).toBe(hass1.connection); + + const hass2 = createSwappedHASS(); + push(hass2); + await flushPromises(); + + expect(calls).toHaveLength(2); + expect(calls[1].connection).toBe(hass2.connection); + }); + + it('should flip old-era guards to dead on swap', async () => { + const { manager, push } = setup(); + const { openCallback, calls } = createRecordingOpenCallback(); + + manager.subscribe({ key: 'a' }, openCallback); + await flushPromises(); + const era1Guard = calls[0].guard; + expect(era1Guard.isConnected()).toBe(true); + + push(createSwappedHASS()); + await flushPromises(); + + expect(era1Guard.isConnected()).toBe(false); + expect(calls[1].guard.isConnected()).toBe(true); + }); + + it('should close old-era subscriptions on a connection swap', async () => { + const { manager, push } = setup(); + const { openCallback, calls } = createRecordingOpenCallback(); + + manager.subscribe({ key: 'a' }, openCallback); + await flushPromises(); + expect(calls).toHaveLength(1); + + push(createSwappedHASS()); + await flushPromises(); + + // The old era's subscription is closed, not abandoned. + expect(calls[0].unsub).toBeCalledTimes(1); + }); + + it('should close old-era subscriptions when HA goes not-ready', async () => { + const ready = createHASS(); + const { manager, push } = setup(ready); + const { openCallback, calls } = createRecordingOpenCallback(); + + manager.subscribe({ key: 'a' }, openCallback); + await flushPromises(); + + const notReady = createHASS(); + notReady.config.state = STATE_STARTING; + notReady.connection = ready.connection; + push(notReady); + await flushPromises(); + + expect(calls[0].unsub).toBeCalledTimes(1); + }); + + it('should mint a fresh era when reanimating from a dead not-ready era, even with the same Connection', async () => { + const ready = createHASS(); + const { manager, push } = setup(ready); + const { openCallback, calls } = createRecordingOpenCallback(); + + manager.subscribe({ key: 'a' }, openCallback); + await flushPromises(); + const oldGuard = calls[0].guard; + expect(oldGuard.isConnected()).toBe(true); + + // Go not-ready, keeping the same connection identity. + const notReady = createHASS(); + notReady.config.state = STATE_STARTING; + notReady.connection = ready.connection; + push(notReady); + expect(oldGuard.isConnected()).toBe(false); + + // Re-ready with the SAME Connection: a fresh era must be minted so old + // guards stay dead. + push(ready); + await flushPromises(); + + expect(oldGuard.isConnected()).toBe(false); + expect(calls).toHaveLength(2); + expect(calls[1].guard.isConnected()).toBe(true); + }); + + it('should not replace KSM on a same-connection HASS push', async () => { + const hass = createHASS(); + const { manager, push } = setup(hass); + const { openCallback, calls } = createRecordingOpenCallback(); + + manager.subscribe({ key: 'a' }, openCallback); + await flushPromises(); + expect(calls).toHaveLength(1); + + push(hass); + push(hass); + await flushPromises(); + + // No retry triggered because nothing failed; same era. + expect(calls).toHaveLength(1); + }); + + it('should drop the live era on not-ready and re-establish on the next ready push', async () => { + const ready = createHASS(); + const { manager, push } = setup(ready); + const { openCallback, calls } = createRecordingOpenCallback(); + + manager.subscribe({ key: 'a' }, openCallback); + await flushPromises(); + expect(calls).toHaveLength(1); + + const notReady = createHASS(); + notReady.config.state = STATE_STARTING; + push(notReady); + + // Old guard should be dead. + expect(calls[0].guard.isConnected()).toBe(false); + + push(ready); + await flushPromises(); + + // Fresh era, fresh submit. + expect(calls).toHaveLength(2); + }); + }); + + describe('dispatch', () => { + it('should return current subscribers from getRequestsForKey synchronously', () => { + const { manager } = setup(); + const { openCallback } = createRecordingOpenCallback(); + const r1 = { key: 'a', label: 'r1' }; + const r2 = { key: 'a', label: 'r2' }; + const r3 = { key: 'b', label: 'r3' }; + + manager.subscribe(r1, openCallback); + manager.subscribe(r2, openCallback); + manager.subscribe(r3, openCallback); + + expect(manager.getRequestsForKey('a')).toEqual([r1, r2]); + expect(manager.getRequestsForKey('b')).toEqual([r3]); + + manager.unsubscribe(r1); + expect(manager.getRequestsForKey('a')).toEqual([r2]); + }); + }); + + describe('verify refcount and serialization are delegated to KSM', () => { + it('should refcount per key: a single WS sub per key regardless of subscribers', async () => { + const { manager } = setup(); + const { openCallback, calls } = createRecordingOpenCallback(); + + manager.subscribe({ key: 'a', label: 'first' }, openCallback); + manager.subscribe({ key: 'a', label: 'second' }, openCallback); + await flushPromises(); + + expect(calls).toHaveLength(1); + }); + + it('should open distinct WS subs for distinct keys', async () => { + const { manager } = setup(); + const { openCallback, calls } = createRecordingOpenCallback(); + + manager.subscribe({ key: 'a' }, openCallback); + manager.subscribe({ key: 'b' }, openCallback); + await flushPromises(); + + expect(calls).toHaveLength(2); + }); + }); + + describe('time-spaced retries', () => { + it('should not retry on subsequent HASS pushes; retries fire on the timer', async () => { + useDeterministicTimers(); + const hass = createHASS(); + const { manager, push } = setup(hass); + const failing = createFailingOpenCallback(); + + manager.subscribe({ key: 'a' }, failing); + await vi.advanceTimersByTimeAsync(0); + expect(failing).toBeCalledTimes(1); + + // HASS pushes do not retry while the retry-timer is scheduled. + for (let i = 0; i < 10; i++) { + push(hass); + await vi.advanceTimersByTimeAsync(0); + } + expect(failing).toBeCalledTimes(1); + }); + + it('should retry after the backoff delay elapses', async () => { + useDeterministicTimers(); + const { manager } = setup(); + const failing = createFailingOpenCallback(); + + manager.subscribe({ key: 'a' }, failing); + await vi.advanceTimersByTimeAsync(0); + expect(failing).toBeCalledTimes(1); + + // 1st retry: ~1s. + await vi.advanceTimersByTimeAsync(1000); + expect(failing).toBeCalledTimes(2); + + // 2nd retry: ~2s. + await vi.advanceTimersByTimeAsync(2000); + expect(failing).toBeCalledTimes(3); + + // 3rd retry: ~4s. + await vi.advanceTimersByTimeAsync(4000); + expect(failing).toBeCalledTimes(4); + }); + + it('should reset the backoff on a connection swap', async () => { + useDeterministicTimers(); + const { manager, push } = setup(); + const failing = createFailingOpenCallback(); + + manager.subscribe({ key: 'a' }, failing); + await vi.advanceTimersByTimeAsync(0); + + // Burn through a few backoff steps so the next would be a longer delay. + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(2000); + await vi.advanceTimersByTimeAsync(4000); + const callsBeforeSwap = vi.mocked(failing).mock.calls.length; + + // Swap. The fresh era should retry immediately on the new connection. + push(createSwappedHASS()); + await vi.advanceTimersByTimeAsync(0); + expect(vi.mocked(failing).mock.calls.length).toBe(callsBeforeSwap + 1); + + // First retry on the new era is back at ~1s, not whatever the previous + // era's accumulated delay was. + await vi.advanceTimersByTimeAsync(1000); + expect(vi.mocked(failing).mock.calls.length).toBe(callsBeforeSwap + 2); + }); + + it('should reset the backoff on a readiness transition (not-ready -> ready)', async () => { + useDeterministicTimers(); + const ready = createHASS(); + const { manager, push } = setup(ready); + const failing = createFailingOpenCallback(); + + manager.subscribe({ key: 'a' }, failing); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(2000); + const callsBefore = vi.mocked(failing).mock.calls.length; + + const notReady = createHASS(); + notReady.config.state = STATE_STARTING; + push(notReady); + + push(ready); + await vi.advanceTimersByTimeAsync(0); + + // Re-ready triggers a fresh era and a fresh submission. + expect(vi.mocked(failing).mock.calls.length).toBe(callsBefore + 1); + + // Next retry is back to ~1s (fresh backoff). + await vi.advanceTimersByTimeAsync(1000); + expect(vi.mocked(failing).mock.calls.length).toBe(callsBefore + 2); + }); + }); + + describe('stale catch token guard', () => { + it('should not let a stale rejection from a swapped-out KSM clear the new submission marker', async () => { + const { manager, push } = setup(); + + let rejectFirst: ((e: Error) => void) | undefined; + const openCallback: HASSWebSocketOpenCallback = vi + .fn() + // First call (on the initial connection) rejects later. + .mockImplementationOnce( + () => + new Promise<() => Promise>((_, reject) => { + rejectFirst = reject; + }), + ) + // Subsequent calls succeed. + .mockResolvedValue(vi.fn()); + + manager.subscribe({ key: 'a' }, openCallback); + await flushPromises(); + expect(openCallback).toBeCalledTimes(1); + + // Swap to a new connection BEFORE the first call settles. + const hass2 = createSwappedHASS(); + push(hass2); + await flushPromises(); + expect(openCallback).toBeCalledTimes(2); + + // Now reject the stale first call. The catch must NOT wipe the marker for + // the in-flight second submission, so the next same-connection push must + // NOT submit again. + rejectFirst?.(new Error('boom')); + await flushPromises(); + + push(hass2); + await flushPromises(); + expect(openCallback).toBeCalledTimes(2); + }); + }); + + describe('destroy', () => { + it('should be a no-op when called without any subscribers', () => { + const { manager, getListenerCount } = setup(); + manager.destroy(); + expect(getListenerCount()).toBe(0); + }); + + it('should swallow a failed KSM unsubscribe during drain', async () => { + const { manager } = setup(); + const failingUnsub = vi.fn().mockRejectedValue(new Error('boom')); + const openCallback: HASSWebSocketOpenCallback = vi + .fn() + .mockResolvedValue(failingUnsub); + + manager.subscribe({ key: 'a' }, openCallback); + await flushPromises(); + + manager.destroy(); + await flushPromises(); + + expect(failingUnsub).toBeCalled(); + }); + + it('should detach source listener, drain KSM, clear state, and flip guards dead', async () => { + const { manager, getListenerCount } = setup(); + const { openCallback, calls } = createRecordingOpenCallback(); + + manager.subscribe({ key: 'a' }, openCallback); + manager.subscribe({ key: 'b' }, openCallback); + await flushPromises(); + + const guardA = calls[0].guard; + const guardB = calls[1].guard; + expect(guardA.isConnected()).toBe(true); + expect(guardB.isConnected()).toBe(true); + + manager.destroy(); + + expect(getListenerCount()).toBe(0); + expect(guardA.isConnected()).toBe(false); + expect(guardB.isConnected()).toBe(false); + expect(manager.getRequestsForKey('a')).toEqual([]); + expect(manager.getRequestsForKey('b')).toEqual([]); + }); + + it('should close a subscription still queued in KSM when destroy is called', async () => { + const { manager } = setup(); + const { openCallback, calls } = createRecordingOpenCallback(); + + // Destroy before KSM admits the subscribe task: the durable mirror still + // drives teardown, so the subscription is closed once it opens rather + // than leaking. + manager.subscribe({ key: 'a' }, openCallback); + manager.destroy(); + await flushPromises(); + + expect(calls).toHaveLength(1); + expect(calls[0].unsub).toBeCalledTimes(1); + }); + }); + + describe('unsubscribe', () => { + it('should not flip guards dead on unsubscribe as era still live for other subscribers', async () => { + const { manager } = setup(); + const { openCallback, calls } = createRecordingOpenCallback(); + + const r1 = { key: 'a' }; + const r2 = { key: 'a' }; + manager.subscribe(r1, openCallback); + manager.subscribe(r2, openCallback); + await flushPromises(); + + const guard = calls[0].guard; + manager.unsubscribe(r1); + expect(guard.isConnected()).toBe(true); + }); + + it('should detach from source when the last subscriber leaves', async () => { + const { manager, getListenerCount } = setup(); + const { openCallback } = createRecordingOpenCallback(); + + const req = { key: 'a' }; + manager.subscribe(req, openCallback); + expect(getListenerCount()).toBe(1); + + manager.unsubscribe(req); + expect(getListenerCount()).toBe(0); + }); + + it('should swallow a failed unsubscribe', async () => { + const { manager } = setup(); + const failingUnsub = vi.fn().mockRejectedValue(new Error('boom')); + const openCallback: HASSWebSocketOpenCallback = vi + .fn() + .mockResolvedValue(failingUnsub); + + const req = { key: 'a' }; + manager.subscribe(req, openCallback); + await flushPromises(); + manager.unsubscribe(req); + await flushPromises(); + + expect(failingUnsub).toBeCalled(); + }); + }); + + describe('status callback', () => { + const collectStatuses = (): { + statusCallback: HASSWebSocketStatusCallback; + events: HASSWebSocketSubscriptionStatus[]; + } => { + const events: HASSWebSocketSubscriptionStatus[] = []; + return { statusCallback: (s) => events.push(s), events }; + }; + + it('should emit waiting then subscribed on a successful subscribe', async () => { + const { manager } = setup(); + const { openCallback } = createRecordingOpenCallback(); + const { statusCallback, events } = collectStatuses(); + + manager.subscribe({ key: 'a' }, openCallback, statusCallback); + await flushPromises(); + + expect(events.map((e) => e.state)).toEqual(['waiting', 'subscribed']); + expect(events[1].failureCount).toBeUndefined(); + expect(events[1].error).toBeUndefined(); + expect(events[1].key).toBe('a'); + }); + + it('should emit waiting when subscribed before HA is ready', () => { + const { manager } = setup(null); + const { openCallback } = createRecordingOpenCallback(); + const { statusCallback, events } = collectStatuses(); + + manager.subscribe({ key: 'a' }, openCallback, statusCallback); + + expect(events).toHaveLength(1); + expect(events[0].state).toBe('waiting'); + }); + + it('should emit failing with the error and incremented failureCount', async () => { + useDeterministicTimers(); + const { manager } = setup(); + const failing = createFailingOpenCallback(); + const { statusCallback, events } = collectStatuses(); + + manager.subscribe({ key: 'a' }, failing, statusCallback); + await vi.advanceTimersByTimeAsync(0); + + // waiting, failing(1). + const failures = events.filter((e) => e.state === 'failing'); + expect(failures).toHaveLength(1); + expect(String(failures[0].error)).toMatch(/boom/); + expect(failures[0].failureCount).toBe(1); + + // Second attempt also fails -> failing(2). + await vi.advanceTimersByTimeAsync(1000); + const failures2 = events.filter((e) => e.state === 'failing'); + expect(failures2).toHaveLength(2); + expect(failures2[1].failureCount).toBe(2); + }); + + it('should emit subscribed without a failureCount after eventual success', async () => { + useDeterministicTimers(); + const { manager } = setup(); + const openCallback: HASSWebSocketOpenCallback = vi + .fn() + .mockRejectedValueOnce(new Error('boom')) + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce(vi.fn().mockResolvedValue(undefined)); + const { statusCallback, events } = collectStatuses(); + + manager.subscribe({ key: 'a' }, openCallback, statusCallback); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(2000); + + const subscribed = events.filter((e) => e.state === 'subscribed'); + expect(subscribed).toHaveLength(1); + expect(subscribed[0].failureCount).toBeUndefined(); + }); + + it('should emit unsubscribed on unsubscribe', async () => { + const { manager } = setup(); + const { openCallback } = createRecordingOpenCallback(); + const { statusCallback, events } = collectStatuses(); + const req = { key: 'a' }; + + manager.subscribe(req, openCallback, statusCallback); + await flushPromises(); + const before = events.length; + + manager.unsubscribe(req); + + expect(events.length).toBe(before + 1); + expect(events[events.length - 1].state).toBe('unsubscribed'); + }); + + it('should not emit on unsubscribe for an unknown request', () => { + const { manager } = setup(); + const { events } = collectStatuses(); + + manager.unsubscribe({ key: 'never-subscribed' }); + + expect(events).toHaveLength(0); + }); + + it('should emit waiting for all requests when HA goes not-ready', async () => { + const { manager, push } = setup(); + const { openCallback } = createRecordingOpenCallback(); + const a = collectStatuses(); + const b = collectStatuses(); + + manager.subscribe({ key: 'a' }, openCallback, a.statusCallback); + manager.subscribe({ key: 'b' }, openCallback, b.statusCallback); + await flushPromises(); + a.events.length = 0; + b.events.length = 0; + + const notReady = createHASS(); + notReady.config.state = STATE_STARTING; + push(notReady); + + expect(a.events.map((e) => e.state)).toEqual(['waiting']); + expect(b.events.map((e) => e.state)).toEqual(['waiting']); + }); + + it('should emit subscribed after an era reanimation', async () => { + const { manager, push } = setup(); + const { openCallback } = createRecordingOpenCallback(); + const { statusCallback, events } = collectStatuses(); + + manager.subscribe({ key: 'a' }, openCallback, statusCallback); + await flushPromises(); + events.length = 0; + + push(createSwappedHASS()); + await flushPromises(); + + expect(events.map((e) => e.state)).toEqual(['waiting', 'subscribed']); + }); + + it('should isolate a throwing status callback from the retry state machine', async () => { + const { manager } = setup(); + const { openCallback, calls } = createRecordingOpenCallback(); + + const events: HASSWebSocketSubscriptionStatus[] = []; + const statusCallback: HASSWebSocketStatusCallback = (s) => { + events.push(s); + if (s.state === 'subscribed') { + throw new Error('observer boom'); + } + }; + + manager.subscribe({ key: 'a' }, openCallback, statusCallback); + await flushPromises(); + + // The throw during `subscribed` is swallowed: no `failing` transition and + // no retry. + expect(events.map((e) => e.state)).toEqual(['waiting', 'subscribed']); + expect(calls).toHaveLength(1); + }); + }); + + describe('retry', () => { + it('should cancel the pending retry timer and submit immediately', async () => { + useDeterministicTimers(); + const { manager } = setup(); + const openCallback: HASSWebSocketOpenCallback = vi + .fn() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce(vi.fn().mockResolvedValue(undefined)); + const req = { key: 'a' }; + + manager.subscribe(req, openCallback); + await vi.advanceTimersByTimeAsync(0); + expect(openCallback).toBeCalledTimes(1); + + // Pending timer would fire ~1s from now. retry runs the second attempt + // synchronously instead of waiting. + manager.retry(req); + await flushPromises(); + expect(openCallback).toBeCalledTimes(2); + + // Advance well past the original 1s schedule: nothing further fires. + await vi.advanceTimersByTimeAsync(10_000); + expect(openCallback).toBeCalledTimes(2); + }); + + it('should reset the backoff so the next failure schedules at the base delay', async () => { + useDeterministicTimers(); + const { manager } = setup(); + const failing = createFailingOpenCallback(); + const req = { key: 'a' }; + + manager.subscribe(req, failing); + + // Burn through three failures to escalate the backoff. + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(2000); + expect(failing).toBeCalledTimes(3); + + // User clicks retry: counter resets. Next failure should schedule at ~1s + // again, not ~8s. + manager.retry(req); + await flushPromises(); + expect(failing).toBeCalledTimes(4); + await vi.advanceTimersByTimeAsync(999); + expect(failing).toBeCalledTimes(4); + await vi.advanceTimersByTimeAsync(1); + expect(failing).toBeCalledTimes(5); + }); + + it('should be a no-op for an unknown request', () => { + const { manager } = setup(); + expect(() => manager.retry({ key: 'never-subscribed' })).not.toThrow(); + }); + + it('should not submit in a dead era, but reset the backoff for next era', async () => { + useDeterministicTimers(); + const notReady = createHASS(); + notReady.config.state = STATE_STARTING; + const { manager, push } = setup(notReady); + const openCallback: HASSWebSocketOpenCallback = vi + .fn() + .mockResolvedValue(vi.fn().mockResolvedValue(undefined)); + const req = { key: 'a' }; + + manager.subscribe(req, openCallback); + await vi.advanceTimersByTimeAsync(0); + expect(openCallback).not.toBeCalled(); + + manager.retry(req); + await flushPromises(); + expect(openCallback).not.toBeCalled(); + + // Era starts; the request submits. + const ready = createHASS(); + ready.config.state = STATE_RUNNING; + push(ready); + await flushPromises(); + expect(openCallback).toBeCalledTimes(1); + }); + }); +}); diff --git a/tests/ha/event-data-match.test.ts b/tests/ha/event-data-match.test.ts deleted file mode 100644 index 85f3d51a..00000000 --- a/tests/ha/event-data-match.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -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/event-match.test.ts b/tests/ha/event-match.test.ts new file mode 100644 index 00000000..5105a65a --- /dev/null +++ b/tests/ha/event-match.test.ts @@ -0,0 +1,191 @@ +import { HassEventBase } from 'home-assistant-js-websocket'; +import { describe, expect, it } from 'vitest'; +import { matchesEventContext, matchesEventData } from '../../src/ha/event-match'; + +const ctx = ( + overrides: Partial = {}, +): HassEventBase['context'] => ({ + id: 'ctx-id', + user_id: null, + parent_id: null, + ...overrides, +}); + +describe('matchesEventData', () => { + // Without a nested dict, HA does a plain items-subset compare -- every filter + // key present, values strictly equal. + describe('no nested dict in the filter', () => { + it.each([['string'], [42], [true], [null], [undefined]])( + 'should reject non-object data (%s)', + (data) => { + expect(matchesEventData({ a: 1 }, data)).toBe(false); + }, + ); + + it('should match when every filter key matches, ignoring extra data keys', () => { + expect( + matchesEventData({ command: 'press' }, { command: 'press', extra: 1 }), + ).toBe(true); + }); + + it('should reject when a filter value differs', () => { + expect(matchesEventData({ command: 'press' }, { command: 'release' })).toBe(false); + }); + + it('should reject when a filter key is missing from data', () => { + expect(matchesEventData({ command: 'press' }, { other: 'press' })).toBe(false); + }); + + it('should distinguish null from a different scalar', () => { + expect(matchesEventData({ a: null }, { a: null })).toBe(true); + expect(matchesEventData({ a: null }, { a: 0 })).toBe(false); + }); + + it('should accept an empty filter against any object', () => { + expect(matchesEventData({}, {})).toBe(true); + expect(matchesEventData({}, { anything: 1 })).toBe(true); + }); + + // HA's test_event_data_with_list: top-level lists are strict (order+length). + it('should match a top-level list by strict equality', () => { + expect(matchesEventData({ tags: [1, 2] }, { tags: [1, 2] })).toBe(true); + }); + + it('should reject a top-level list that is a superset, subset, or scalar', () => { + expect(matchesEventData({ tags: [1, 2] }, { tags: [1, 2, 3] })).toBe(false); + expect(matchesEventData({ tags: [1, 2] }, { tags: [1] })).toBe(false); + expect(matchesEventData({ tags: [1, 2] }, { tags: 1 })).toBe(false); + }); + + // Python's `bool` is a subtype of `int`, so HA equates true/false with 1/0. + it('should equate booleans with 0/1 (Python ==)', () => { + expect(matchesEventData({ flag: true }, { flag: 1 })).toBe(true); + expect(matchesEventData({ flag: 1 }, { flag: true })).toBe(true); + expect(matchesEventData({ flag: false }, { flag: 0 })).toBe(true); + expect(matchesEventData({ flag: true }, { flag: 2 })).toBe(false); + expect(matchesEventData({ flag: 2 }, { flag: true })).toBe(false); + expect(matchesEventData({ flag: true }, { flag: 'on' })).toBe(false); + }); + }); + + // A nested dict makes HA validate the whole filter via voluptuous + // (extra=ALLOW_EXTRA, required=True). + describe('nested dict in the filter', () => { + // HA's test_if_fires_on_event_with_nested_data: nested dicts are + // subset-matched -- listed keys required, extra keys allowed. + it('should allow extra keys inside a nested object', () => { + expect( + matchesEventData( + { device: { id: 'abc' } }, + { device: { id: 'abc', name: 'Front Door' } }, + ), + ).toBe(true); + }); + + it('should match a nested object with exact contents', () => { + expect( + matchesEventData({ device: { id: 'abc' } }, { device: { id: 'abc' } }), + ).toBe(true); + }); + + it('should reject a nested object missing a required key', () => { + expect(matchesEventData({ device: { id: 'abc' } }, { device: { x: 'abc' } })).toBe( + false, + ); + }); + + it('should reject a nested object when a value differs', () => { + expect( + matchesEventData({ device: { id: 'abc' } }, { device: { id: 'xyz' } }), + ).toBe(false); + }); + + it('should reject when the event value is not an object', () => { + expect(matchesEventData({ device: { id: 'abc' } }, { device: 'abc' })).toBe(false); + }); + + // HA's test_event_data_with_list_nested: nested lists are + // membership-matched -- every event element must be one of the filter + // array's entries. + it('should match a nested list by membership', () => { + const filter = { svc: { tags: [1, 2] } }; + expect(matchesEventData(filter, { svc: { tags: [1, 2] } })).toBe(true); + expect(matchesEventData(filter, { svc: { tags: [1] } })).toBe(true); + }); + + it('should reject a nested list with an element outside the filter set', () => { + expect( + matchesEventData({ svc: { tags: [1, 2] } }, { svc: { tags: [1, 2, 3] } }), + ).toBe(false); + }); + + it('should reject a nested list when the event value is not a list', () => { + expect(matchesEventData({ svc: { tags: [1, 2] } }, { svc: { tags: 1 } })).toBe( + false, + ); + expect( + matchesEventData({ svc: { tags: [1, 2] } }, { svc: { other: [1, 2] } }), + ).toBe(false); + }); + + it('should membership-match a sibling top-level list given a nested dict', () => { + expect(matchesEventData({ meta: {}, tags: [1, 2] }, { meta: {}, tags: [1] })).toBe( + true, + ); + }); + + // The Python bool/int equivalence also propagates through the slow path. + it('should equate booleans with 0/1 inside nested structures', () => { + expect( + matchesEventData({ device: { armed: true } }, { device: { armed: 1 } }), + ).toBe(true); + expect(matchesEventData({ meta: {}, vals: [true] }, { meta: {}, vals: [1] })).toBe( + true, + ); + }); + }); +}); + +describe('matchesEventContext', () => { + it('should accept any context when the filter is empty', () => { + expect(matchesEventContext({}, ctx())).toBe(true); + }); + + it('should match a scalar filter against an equal value', () => { + expect(matchesEventContext({ id: 'ctx-id' }, ctx({ id: 'ctx-id' }))).toBe(true); + }); + + it('should reject a scalar filter when the value differs', () => { + expect(matchesEventContext({ id: 'other' }, ctx({ id: 'ctx-id' }))).toBe(false); + }); + + it('should match a list filter when the value is a member', () => { + expect(matchesEventContext({ id: ['ctx-id', 'other'] }, ctx({ id: 'ctx-id' }))).toBe( + true, + ); + }); + + it('should reject a list filter when the value is not a member', () => { + expect(matchesEventContext({ id: ['a', 'b'] }, ctx({ id: 'ctx-id' }))).toBe(false); + }); + + it('should reject when an explicit filter targets a null event field', () => { + expect(matchesEventContext({ user_id: 'u1' }, ctx({ user_id: null }))).toBe(false); + }); + + it('should AND across multiple filter fields', () => { + const event = ctx({ id: 'i', user_id: 'u', parent_id: 'p' }); + expect(matchesEventContext({ id: 'i', user_id: 'u', parent_id: 'p' }, event)).toBe( + true, + ); + expect( + matchesEventContext({ id: 'i', user_id: 'u', parent_id: 'OTHER' }, event), + ).toBe(false); + }); + + it('should ignore undefined filter fields', () => { + expect( + matchesEventContext({ id: undefined, user_id: 'u' }, ctx({ user_id: 'u' })), + ).toBe(true); + }); +}); diff --git a/tests/ha/is-hass-ready.test.ts b/tests/ha/is-hass-ready.test.ts new file mode 100644 index 00000000..d579d019 --- /dev/null +++ b/tests/ha/is-hass-ready.test.ts @@ -0,0 +1,34 @@ +import { STATE_RUNNING } from 'home-assistant-js-websocket'; +import { describe, expect, it } from 'vitest'; +import { isHassReady } from '../../src/ha/is-hass-ready'; +import { createHASS } from '../test-utils'; + +describe('isHassReady', () => { + it('should return false for null', () => { + expect(isHassReady(null)).toBe(false); + }); + + it('should return false for undefined', () => { + expect(isHassReady(undefined)).toBe(false); + }); + + it('should return false when disconnected', () => { + const hass = createHASS(); + hass.connected = false; + expect(isHassReady(hass)).toBe(false); + }); + + it('should return false when integrations are still loading', () => { + const hass = createHASS(); + hass.connected = true; + hass.config.state = 'NOT_RUNNING'; + expect(isHassReady(hass)).toBe(false); + }); + + it('should return true when connected and running', () => { + const hass = createHASS(); + hass.connected = true; + hass.config.state = STATE_RUNNING; + expect(isHassReady(hass)).toBe(true); + }); +}); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index f6c45d60..d0083f49 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -1,4 +1,9 @@ -import { HassEntities, HassEntity, STATE_RUNNING } from 'home-assistant-js-websocket'; +import { + HassEntities, + HassEntity, + HassEvent, + STATE_RUNNING, +} from 'home-assistant-js-websocket'; import { LitElement } from 'lit'; import screenfull from 'screenfull'; import { expect, vi } from 'vitest'; @@ -41,6 +46,7 @@ import { FullscreenManager } from '../src/card-controller/fullscreen/fullscreen- import { EventWatcherSubscriptionInterface } from '../src/card-controller/hass/event-watcher'; import { HASSManager } from '../src/card-controller/hass/hass-manager'; import { StateWatcherSubscriptionInterface } from '../src/card-controller/hass/state-watcher'; +import { HASSManagerReadonlyInterface } from '../src/card-controller/hass/types'; import { InitializationManager } from '../src/card-controller/initialization-manager'; import { InteractionManager } from '../src/card-controller/interaction-manager'; import { IssueManager } from '../src/card-controller/issues/issue-manager'; @@ -77,6 +83,7 @@ import { } from '../src/ha/browse-media/types'; import { Device } from '../src/ha/registry/device/types'; import { Entity, EntityRegistryManager } from '../src/ha/registry/entity/types'; +import { HASSListener, HASSSource } from '../src/ha/source'; import { CurrentUser, HassStateDifference, HomeAssistant } from '../src/ha/types'; import { QuerySource } from '../src/query-source'; import { Severity } from '../src/severity'; @@ -124,9 +131,7 @@ export const createInitializedCamera = async ( ): Promise => { const camera = new Camera(config, engine); await camera.initialize({ - hass: createHASS(), - stateWatcher: stateWatcher ?? mock(), - eventWatcher: mock(), + hassManager: createHASSManager({ stateWatcher }), ...(capabilities ? { capabilityOptions: { capabilities } } : {}), }); return camera; @@ -153,6 +158,60 @@ export const createHASS = (states?: HassEntities, user?: CurrentUser): HomeAssis return hass; }; +/** + * Build a driveable HASSSource backed by a single `hass` value. The + * returned `push(hass)` synchronously updates the source's current hass and + * fans out to every registered listener with `(hass, oldHass)`. + */ +export const createHASSSource = ( + initial?: HomeAssistant | null, +): { + source: HASSSource; + push: (hass: HomeAssistant) => void; + getListenerCount: () => number; +} => { + let current: HomeAssistant | null = initial ?? null; + const listeners = new Set(); + const source: HASSSource = { + getHASS: () => current, + addListener: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; + return { + source, + push: (hass) => { + const prev = current; + current = hass; + for (const l of listeners) { + l(hass, prev); + } + }, + getListenerCount: () => listeners.size, + }; +}; + +export const createHASSManager = (options?: { + hass?: HomeAssistant | null; + stateWatcher?: StateWatcherSubscriptionInterface; + eventWatcher?: EventWatcherSubscriptionInterface; +}): HASSManagerReadonlyInterface => { + const hassManager = mock(); + hassManager.getHASS.mockReturnValue( + options?.hass === undefined ? createHASS() : options.hass, + ); + hassManager.getStateWatcher.mockReturnValue( + options?.stateWatcher ?? mock(), + ); + hassManager.getEventWatcher.mockReturnValue( + options?.eventWatcher ?? mock(), + ); + return hassManager; +}; + export const createUser = (user?: Partial): CurrentUser => ({ id: 'user', is_owner: false, @@ -285,8 +344,7 @@ export const createStore = ( cameraProps.config ?? createCameraConfig(), cameraProps.engine ?? new GenericCameraManagerEngine( - mock(), - mock(), + createHASSManager(), mock(), eventCallback, ), @@ -744,14 +802,26 @@ export const callStateWatcherCallback = ( export const callEventWatcherCallback = ( eventWatcher: EventWatcherSubscriptionInterface, - data: unknown, + event: HassEvent, n = 0, ): void => { const mock = vi.mocked(eventWatcher.subscribe).mock; expect(mock.calls.length).greaterThan(n); - mock.calls[n][1].callback(data); + mock.calls[n][0].callback(event); }; +export const createHASSEvent = ( + event_type: string, + data: Record = {}, + context: HassEvent['context'] = { id: 'ctx', user_id: null, parent_id: null }, +): HassEvent => ({ + event_type, + data, + origin: 'LOCAL', + time_fired: '2026-06-19T21:12:18Z', + context, +}); + /** * Flush resolved promises. */ @@ -759,6 +829,15 @@ export const flushPromises = async (): Promise => { await new Promise(process.nextTick); }; +/** + * Install fake timers and pin Math.random to 1 (max jitter), so exponential + * backoff retry delays advance by exact, predictable durations. + */ +export const useDeterministicTimers = (): void => { + vi.useFakeTimers(); + vi.spyOn(Math, 'random').mockReturnValue(1); +}; + export const createInteractionActionEvent = ( action: string, ): CustomEvent => { diff --git a/tests/utils/concurrency/keyed-subscription-manager.test.ts b/tests/utils/concurrency/keyed-subscription-manager.test.ts index 15a416c7..112f4e95 100644 --- a/tests/utils/concurrency/keyed-subscription-manager.test.ts +++ b/tests/utils/concurrency/keyed-subscription-manager.test.ts @@ -86,6 +86,24 @@ describe('KeyedSubscriptionManager', () => { expect(manager.getRequestsForKey('a')).toEqual([reqA2]); }); + it('should roll back the request when the underlying subscribeFn rejects', async () => { + const manager = create(); + const subscribeFn = vi.fn().mockRejectedValue(new Error('ws-fail')); + + const req = { key: 'a', callback: vi.fn() }; + await expect(manager.subscribe(req, subscribeFn)).rejects.toThrow('ws-fail'); + + // The failed subscriber must not be left dispatching against a + // never-established connection. + expect(manager.getRequestsForKey('a')).toEqual([]); + + // A subsequent successful subscribe should re-attempt the underlying call. + const successFn = vi.fn().mockResolvedValue(vi.fn()); + await manager.subscribe(req, successFn); + expect(successFn).toBeCalledTimes(1); + expect(manager.getRequestsForKey('a')).toEqual([req]); + }); + it('should treat unsubscribe of an unknown request as a no-op', async () => { const manager = create(); const unsub = vi.fn(); diff --git a/tests/utils/exponential-backoff.test.ts b/tests/utils/exponential-backoff.test.ts new file mode 100644 index 00000000..3ded5afe --- /dev/null +++ b/tests/utils/exponential-backoff.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ExponentialBackoff } from '../../src/utils/exponential-backoff'; + +describe('ExponentialBackoff', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should start at attempts=0', () => { + const backoff = new ExponentialBackoff({ baseSeconds: 1, maxSeconds: 60 }); + expect(backoff.getAttempts()).toBe(0); + }); + + it('should compute exponential delays with the configured base', () => { + // Pin jitter to 1.0 so we can read the raw exponential values. + vi.spyOn(Math, 'random').mockReturnValue(1); + const backoff = new ExponentialBackoff({ + baseSeconds: 1, + maxSeconds: 60, + jitterMin: 1, + jitterMax: 1, + }); + + expect(backoff.next()).toBe(1); + expect(backoff.next()).toBe(2); + expect(backoff.next()).toBe(4); + expect(backoff.next()).toBe(8); + expect(backoff.next()).toBe(16); + }); + + it('should cap delays at maxSeconds', () => { + vi.spyOn(Math, 'random').mockReturnValue(1); + const backoff = new ExponentialBackoff({ + baseSeconds: 1, + maxSeconds: 10, + jitterMin: 1, + jitterMax: 1, + }); + + backoff.next(); + backoff.next(); + backoff.next(); + backoff.next(); + + expect(backoff.next()).toBe(10); + expect(backoff.next()).toBe(10); + }); + + it('should apply jitter within the configured range', () => { + // Math.random() returns 0; jitter = jitterMin. + vi.spyOn(Math, 'random').mockReturnValue(0); + const backoff = new ExponentialBackoff({ + baseSeconds: 10, + maxSeconds: 100, + jitterMin: 0.5, + jitterMax: 1.0, + }); + + expect(backoff.next()).toBe(5); + }); + + it('should increment the attempt counter on each next()', () => { + const backoff = new ExponentialBackoff({ baseSeconds: 1, maxSeconds: 60 }); + + expect(backoff.getAttempts()).toBe(0); + backoff.next(); + expect(backoff.getAttempts()).toBe(1); + backoff.next(); + expect(backoff.getAttempts()).toBe(2); + }); + + it('should reset the attempt counter and start over', () => { + vi.spyOn(Math, 'random').mockReturnValue(1); + const backoff = new ExponentialBackoff({ + baseSeconds: 1, + maxSeconds: 60, + jitterMin: 1, + jitterMax: 1, + }); + + backoff.next(); + backoff.next(); + backoff.next(); + expect(backoff.getAttempts()).toBe(3); + + backoff.reset(); + expect(backoff.getAttempts()).toBe(0); + expect(backoff.next()).toBe(1); + }); + + it('should default jitter to [0.5, 1.0] when not provided', () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + const backoff = new ExponentialBackoff({ baseSeconds: 4, maxSeconds: 100 }); + + // jitter = 0.5 + 0 * (1.0 - 0.5) = 0.5; delay = 4 * 0.5 = 2. + expect(backoff.next()).toBe(2); + }); +}); diff --git a/tests/utils/initializer.test.ts b/tests/utils/initializer.test.ts index 183a9dc7..a2e34b1f 100644 --- a/tests/utils/initializer.test.ts +++ b/tests/utils/initializer.test.ts @@ -71,4 +71,36 @@ describe('Initializer', () => { expect(initializer.isInitialized('foo')).toBeFalsy(); }); + + it('should discard an initialization that was uninitialized while it was running', async () => { + const initializer = new Initializer(); + + let finishInitializer: () => void = () => undefined; + const initializing = initializer.initializeIfNecessary( + 'foo', + () => + new Promise((resolve) => { + finishInitializer = resolve; + }), + ); + + // A uninitialize lands while the initializer is still running. + initializer.uninitialize('foo'); + + finishInitializer(); + await initializing; + + expect(initializer.isInitialized('foo')).toBeFalsy(); + }); + + it('should initialize again after being uninitialized', async () => { + const initializer = new Initializer(); + + await initializer.initializeIfNecessary('foo'); + initializer.uninitialize('foo'); + expect(initializer.isInitialized('foo')).toBeFalsy(); + + await initializer.initializeIfNecessary('foo'); + expect(initializer.isInitialized('foo')).toBeTruthy(); + }); }); diff --git a/tests/utils/retry-timer.test.ts b/tests/utils/retry-timer.test.ts new file mode 100644 index 00000000..608d930d --- /dev/null +++ b/tests/utils/retry-timer.test.ts @@ -0,0 +1,206 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { RetryTimer } from '../../src/utils/retry-timer'; + +// @vitest-environment jsdom +describe('RetryTimer', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('should schedule a callback after the current backoff delay', () => { + vi.useFakeTimers(); + vi.spyOn(Math, 'random').mockReturnValue(1); + const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 }); + const cb = vi.fn(); + + timer.schedule(cb); + vi.advanceTimersByTime(999); + expect(cb).not.toBeCalled(); + vi.advanceTimersByTime(1); + expect(cb).toBeCalledTimes(1); + }); + + it('should advance the counter on schedule by default', () => { + const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 }); + timer.schedule(() => {}); + expect(timer.getAttempts()).toBe(1); + timer.schedule(() => {}); + expect(timer.getAttempts()).toBe(2); + }); + + it('should not advance the counter when schedule is called with advance: false', () => { + const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 }); + timer.schedule(() => {}, { advance: false }); + expect(timer.getAttempts()).toBe(0); + timer.schedule(() => {}, { advance: false }); + expect(timer.getAttempts()).toBe(0); + }); + + it('should advance the counter via advance()', () => { + const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 }); + timer.advance(); + expect(timer.getAttempts()).toBe(1); + timer.advance(); + expect(timer.getAttempts()).toBe(2); + }); + + it('should use a longer delay after advance()', () => { + vi.useFakeTimers(); + vi.spyOn(Math, 'random').mockReturnValue(1); + const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 }); + const cb = vi.fn(); + + timer.advance(); + timer.schedule(cb); + + // Counter is 1, delay should be base * 2^1 = 2 seconds. + vi.advanceTimersByTime(1999); + expect(cb).not.toBeCalled(); + vi.advanceTimersByTime(1); + expect(cb).toBeCalledTimes(1); + }); + + it('should cancel a pending callback', () => { + vi.useFakeTimers(); + vi.spyOn(Math, 'random').mockReturnValue(1); + const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 }); + const cb = vi.fn(); + + timer.schedule(cb); + timer.cancel(); + vi.advanceTimersByTime(10_000); + expect(cb).not.toBeCalled(); + }); + + it('should reset both the timer and the counter', () => { + vi.useFakeTimers(); + vi.spyOn(Math, 'random').mockReturnValue(1); + const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 }); + const cb = vi.fn(); + + timer.advance(); + timer.advance(); + timer.schedule(cb, { advance: false }); + expect(timer.getAttempts()).toBe(2); + expect(timer.isRunning()).toBe(true); + + timer.reset(); + expect(timer.getAttempts()).toBe(0); + expect(timer.isRunning()).toBe(false); + + vi.advanceTimersByTime(10_000); + expect(cb).not.toBeCalled(); + }); + + it('should report running state', () => { + vi.useFakeTimers(); + vi.spyOn(Math, 'random').mockReturnValue(1); + const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 }); + + expect(timer.isRunning()).toBe(false); + timer.schedule(() => {}); + expect(timer.isRunning()).toBe(true); + vi.advanceTimersByTime(1000); + expect(timer.isRunning()).toBe(false); + }); + + it('should advance the counter when schedule is called with advance: true', () => { + vi.useFakeTimers(); + vi.spyOn(Math, 'random').mockReturnValue(1); + const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 }); + + timer.schedule(() => {}, { advance: true }); + expect(timer.getAttempts()).toBe(1); + timer.schedule(() => {}, { advance: true }); + expect(timer.getAttempts()).toBe(2); + }); + + it('should produce a fixed delay when configured with base=max and jitter=1', () => { + // The "static delay" idiom: callers wanting a non-growing delay configure + // the backoff to flatten out. No special mode in the class. + vi.useFakeTimers(); + const timer = new RetryTimer({ + baseSeconds: 30, + maxSeconds: 30, + jitterMin: 1, + jitterMax: 1, + }); + const cb = vi.fn(); + + timer.advance(); + timer.advance(); + timer.schedule(cb); + vi.advanceTimersByTime(29_999); + expect(cb).not.toBeCalled(); + vi.advanceTimersByTime(1); + expect(cb).toBeCalledTimes(1); + }); + + it('should accept a plain number as shorthand for a fixed delay', () => { + vi.useFakeTimers(); + const timer = new RetryTimer(30); + const cb = vi.fn(); + + timer.advance(); + timer.schedule(cb); + vi.advanceTimersByTime(29_999); + expect(cb).not.toBeCalled(); + vi.advanceTimersByTime(1); + expect(cb).toBeCalledTimes(1); + }); + + describe('setOptions', () => { + it('should apply the new backoff config to the next schedule', () => { + vi.useFakeTimers(); + vi.spyOn(Math, 'random').mockReturnValue(1); + const timer = new RetryTimer({ + baseSeconds: 1, + maxSeconds: 60, + jitterMin: 1, + jitterMax: 1, + }); + const cb = vi.fn(); + + timer.setOptions({ + baseSeconds: 10, + maxSeconds: 10, + jitterMin: 1, + jitterMax: 1, + }); + timer.schedule(cb); + vi.advanceTimersByTime(9_999); + expect(cb).not.toBeCalled(); + vi.advanceTimersByTime(1); + expect(cb).toBeCalledTimes(1); + }); + + it('should preserve the attempt counter and any pending callback', () => { + vi.useFakeTimers(); + vi.spyOn(Math, 'random').mockReturnValue(1); + const timer = new RetryTimer({ + baseSeconds: 1, + maxSeconds: 60, + jitterMin: 1, + jitterMax: 1, + }); + const cb = vi.fn(); + + timer.advance(); + timer.advance(); + timer.schedule(cb, { advance: false }); + expect(timer.getAttempts()).toBe(2); + expect(timer.isRunning()).toBe(true); + + // Idempotent setOptions doesn't touch counter or pending timer. + timer.setOptions({ + baseSeconds: 1, + maxSeconds: 60, + jitterMin: 1, + jitterMax: 1, + }); + expect(timer.getAttempts()).toBe(2); + expect(timer.isRunning()).toBe(true); + }); + }); +});