diff --git a/src/camera-manager/browse-media/camera.ts b/src/camera-manager/browse-media/camera.ts index 030707d4..031ef5c2 100644 --- a/src/camera-manager/browse-media/camera.ts +++ b/src/camera-manager/browse-media/camera.ts @@ -2,19 +2,23 @@ import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; import { localize } from '../../localize/localize'; import { EntityRegistryManager } from '../../utils/ha/entity-registry'; import { Entity } from '../../utils/ha/entity-registry/types'; -import { Camera } from '../camera'; +import { Camera, CameraInitializationOptions } from '../camera'; import { CameraInitializationError } from '../error'; +interface BrowseMediaCameraInitializationOptions extends CameraInitializationOptions { + entityRegistryManager: EntityRegistryManager; + hass: HomeAssistant; +} + export class BrowseMediaCamera extends Camera { protected _entity: Entity | null = null; public async initialize( - hass: HomeAssistant, - entityRegistryManager: EntityRegistryManager, + options: BrowseMediaCameraInitializationOptions, ): Promise { const config = this.getConfig(); const entity = config.camera_entity - ? await entityRegistryManager.getEntity(hass, config.camera_entity) + ? await options.entityRegistryManager.getEntity(options.hass, config.camera_entity) : null; if (!entity || !config.camera_entity) { diff --git a/src/camera-manager/browse-media/engine-browse-media.ts b/src/camera-manager/browse-media/engine-browse-media.ts index 1463d8f9..997cf9d3 100644 --- a/src/camera-manager/browse-media/engine-browse-media.ts +++ b/src/camera-manager/browse-media/engine-browse-media.ts @@ -1,4 +1,5 @@ import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; +import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher'; import { CameraConfig } from '../../config/types'; import { ExtendedHomeAssistant } from '../../types'; import { canonicalizeHAURL } from '../../utils/ha'; @@ -28,10 +29,10 @@ import { PartialEventQuery, QueryType, } from '../types'; +import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; import { BrowseMediaCamera } from './camera'; import { BrowseMediaViewMediaFactory } from './media'; import { BrowseMediaMetadata } from './types'; -import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; /** * A utility method to determine if a browse media object matches against a @@ -123,16 +124,20 @@ export class BrowseMediaCameraManagerEngine implements CameraManagerEngine { protected _browseMediaManager: BrowseMediaManager; + protected _entityRegistryManager: EntityRegistryManager; protected _resolvedMediaCache: ResolvedMediaCache; protected _requestCache: RequestCache; public constructor( + entityRegistryManager: EntityRegistryManager, + stateWatcher: StateWatcherSubscriptionInterface, browseMediaManager: BrowseMediaManager, resolvedMediaCache: ResolvedMediaCache, requestCache: RequestCache, eventCallback?: CameraEventCallback, ) { - super(eventCallback); + super(stateWatcher, eventCallback); + this._entityRegistryManager = entityRegistryManager; this._browseMediaManager = browseMediaManager; this._resolvedMediaCache = resolvedMediaCache; this._requestCache = requestCache; @@ -140,7 +145,6 @@ export class BrowseMediaCameraManagerEngine public async createCamera( hass: HomeAssistant, - entityRegistryManager: EntityRegistryManager, cameraConfig: CameraConfig, ): Promise { const camera = new BrowseMediaCamera(cameraConfig, this, { @@ -164,7 +168,11 @@ export class BrowseMediaCameraManagerEngine ), eventCallback: this._eventCallback, }); - return await camera.initialize(hass, entityRegistryManager); + return await camera.initialize({ + entityRegistryManager: this._entityRegistryManager, + hass, + stateWatcher: this._stateWatcher, + }); } public generateDefaultEventQuery( diff --git a/src/camera-manager/camera.ts b/src/camera-manager/camera.ts index 9e83e5d9..c22b731a 100644 --- a/src/camera-manager/camera.ts +++ b/src/camera-manager/camera.ts @@ -1,19 +1,17 @@ -import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; +import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher'; import { CameraConfig } from '../config/types'; import { localize } from '../localize/localize'; -import { allPromises } from '../utils/basic'; -import { - DestroyCallback, - isTriggeredState, - parseStateChangeTrigger, - subscribeToTrigger, -} from '../utils/ha'; -import { EntityRegistryManager } from '../utils/ha/entity-registry'; +import { HassStateDifference, isTriggeredState } from '../utils/ha'; import { Capabilities } from './capabilities'; import { CameraManagerEngine } from './engine'; import { CameraNoIDError } from './error'; import { CameraEventCallback } from './types'; +export interface CameraInitializationOptions { + stateWatcher: StateWatcherSubscriptionInterface; +} +type DestroyCallback = () => void | Promise; + export class Camera { protected _config: CameraConfig; protected _engine: CameraManagerEngine; @@ -35,47 +33,17 @@ export class Camera { this._eventCallback = options?.eventCallback; } - protected async _convertStateChangeToCameraEvent(data: unknown): Promise { - const stateChange = parseStateChangeTrigger(data); - if (!stateChange) { - return; - } - - this._eventCallback?.({ - cameraID: this.getID(), - type: isTriggeredState(stateChange.to_state.state) ? 'new' : 'end', - }); - } - - protected async _subscribeToTriggerEntities(hass: HomeAssistant): Promise { - if (!this._config.triggers.entities.length) { - return; - } - - this._destroyCallbacks.push( - await subscribeToTrigger( - hass, - (data) => this._convertStateChangeToCameraEvent(data), - { - entityID: this._config.triggers.entities, - platform: 'state', - stateOnly: true, - }, - ), + async initialize(options: CameraInitializationOptions): Promise { + options.stateWatcher.subscribe( + this._stateChangeHandler, + this._config.triggers.entities, ); - } - - async initialize( - hass: HomeAssistant, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _entityRegistryManager: EntityRegistryManager, - ): Promise { - await this._subscribeToTriggerEntities(hass); + this._onDestroy(() => options.stateWatcher.unsubscribe(this._stateChangeHandler)); return this; } - async destroy(): Promise { - await allPromises(this._destroyCallbacks, (cb) => cb()); + public async destroy(): Promise { + this._destroyCallbacks.forEach((callback) => callback()); } public getConfig(): CameraConfig { @@ -100,4 +68,15 @@ export class Camera { public getCapabilities(): Capabilities | null { return this._capabilities ?? null; } + + protected _stateChangeHandler = (difference: HassStateDifference): void => { + this._eventCallback?.({ + cameraID: this.getID(), + type: isTriggeredState(difference.newState.state) ? 'new' : 'end', + }); + }; + + protected _onDestroy(callback: DestroyCallback): void { + this._destroyCallbacks.push(callback); + } } diff --git a/src/camera-manager/engine-factory.ts b/src/camera-manager/engine-factory.ts index 39b7384c..00ac031b 100644 --- a/src/camera-manager/engine-factory.ts +++ b/src/camera-manager/engine-factory.ts @@ -1,4 +1,5 @@ import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; +import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher'; import { CameraConfig } from '../config/types'; import { localize } from '../localize/localize'; import { BrowseMediaManager } from '../utils/ha/browse-media/browse-media-manager'; @@ -12,34 +13,41 @@ import { CameraInitializationError } from './error'; import { CameraEventCallback, Engine } from './types'; import { getCameraEntityFromConfig } from './utils/camera-entity-from-config'; -export class CameraManagerEngineFactory { - protected _entityRegistryManager: EntityRegistryManager; - protected _resolvedMediaCache: ResolvedMediaCache; +interface CameraManagerEngineFactoryOptions { + stateWatcher: StateWatcherSubscriptionInterface; + resolvedMediaCache: ResolvedMediaCache; + eventCallback?: CameraEventCallback; +} - constructor( - entityRegistryManager: EntityRegistryManager, - resolvedMediaCache: ResolvedMediaCache, - ) { +export class CameraManagerEngineFactory { + // Entity registry manager is required for the actual function of the factory. + protected _entityRegistryManager: EntityRegistryManager; + + constructor(entityRegistryManager: EntityRegistryManager) { this._entityRegistryManager = entityRegistryManager; - this._resolvedMediaCache = resolvedMediaCache; } public async createEngine( engine: Engine, - eventCallback?: CameraEventCallback, + options: CameraManagerEngineFactoryOptions, ): Promise { let cameraManagerEngine: CameraManagerEngine; switch (engine) { case Engine.Generic: const { GenericCameraManagerEngine } = await import('./generic/engine-generic'); - cameraManagerEngine = new GenericCameraManagerEngine(eventCallback); + cameraManagerEngine = new GenericCameraManagerEngine( + options.stateWatcher, + options.eventCallback, + ); break; case Engine.Frigate: const { FrigateCameraManagerEngine } = await import('./frigate/engine-frigate'); cameraManagerEngine = new FrigateCameraManagerEngine( + this._entityRegistryManager, + options.stateWatcher, new RecordingSegmentsCache(), new RequestCache(), - eventCallback, + options.eventCallback, ); break; case Engine.MotionEye: @@ -47,10 +55,12 @@ export class CameraManagerEngineFactory { './motioneye/engine-motioneye' ); cameraManagerEngine = new MotionEyeCameraManagerEngine( + this._entityRegistryManager, + options.stateWatcher, new BrowseMediaManager(new MemoryRequestCache()), - this._resolvedMediaCache, + options.resolvedMediaCache, new RequestCache(), - eventCallback, + options.eventCallback, ); } return cameraManagerEngine; diff --git a/src/camera-manager/engine.ts b/src/camera-manager/engine.ts index a17c0110..e9fa6b0b 100644 --- a/src/camera-manager/engine.ts +++ b/src/camera-manager/engine.ts @@ -1,7 +1,7 @@ import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; -import { CameraConfig, ActionPhase } from '../config/types'; +import { PTZAction } from '../config/ptz'; +import { ActionPhase, CameraConfig } from '../config/types'; import { ExtendedHomeAssistant } from '../types'; -import { EntityRegistryManager } from '../utils/ha/entity-registry'; import { ViewMedia } from '../view/media'; import { Camera } from './camera'; import { CameraManagerReadOnlyConfigStore } from './store'; @@ -27,18 +27,13 @@ import { RecordingSegmentsQuery, RecordingSegmentsQueryResultsMap, } from './types'; -import { PTZAction } from '../config/ptz'; export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; export interface CameraManagerEngine { getEngineType(): Engine; - createCamera( - hass: HomeAssistant, - entityRegistryManager: EntityRegistryManager, - cameraConfig: CameraConfig, - ): Promise; + createCamera(hass: HomeAssistant, cameraConfig: CameraConfig): Promise; generateDefaultEventQuery( store: CameraManagerReadOnlyConfigStore, diff --git a/src/camera-manager/frigate/camera.ts b/src/camera-manager/frigate/camera.ts index f00d1117..3dd992d6 100644 --- a/src/camera-manager/frigate/camera.ts +++ b/src/camera-manager/frigate/camera.ts @@ -1,5 +1,6 @@ import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; import uniq from 'lodash-es/uniq'; +import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher'; import { CameraConfig } from '../../config/types'; import { localize } from '../../localize/localize'; import { PTZCapabilities, PTZMovementType } from '../../types'; @@ -7,32 +8,52 @@ import { errorToConsole, recursivelyMergeObjectsConcatenatingArraysUniquely, } from '../../utils/basic'; -import { subscribeToTrigger } from '../../utils/ha'; import { EntityRegistryManager } from '../../utils/ha/entity-registry'; import { Entity } from '../../utils/ha/entity-registry/types'; -import { Camera } from '../camera'; +import { Camera, CameraInitializationOptions } from '../camera'; import { Capabilities } from '../capabilities'; +import { CameraManagerEngine } from '../engine'; import { CameraInitializationError } from '../error'; +import { CameraEventCallback } from '../types'; import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config'; -import { getPTZInfo } from './requests'; -import { PTZInfo, frigateEventChangeTriggerResponseSchema } from './types'; import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; +import { + FrigateEventWatcherRequest, + FrigateEventWatcherSubscriptionInterface, +} from './event-watcher'; +import { getPTZInfo } from './requests'; +import { FrigateEventChange, PTZInfo } from './types'; const CAMERA_BIRDSEYE = 'birdseye' as const; +interface FrigateCameraInitializationOptions extends CameraInitializationOptions { + entityRegistryManager: EntityRegistryManager; + frigateEventWatcher: FrigateEventWatcherSubscriptionInterface; + hass: HomeAssistant; + stateWatcher: StateWatcherSubscriptionInterface; +} + export const isBirdseye = (cameraConfig: CameraConfig): boolean => { return cameraConfig.frigate.camera_name === CAMERA_BIRDSEYE; }; export class FrigateCamera extends Camera { - public async initialize( - hass: HomeAssistant, - entityRegistryManager: EntityRegistryManager, - ): Promise { - await this._initializeConfig(hass, entityRegistryManager); - await this._initializeCapabilities(hass); - await this._subscribeToEvents(hass); - return await super.initialize(hass, entityRegistryManager); + constructor( + config: CameraConfig, + engine: CameraManagerEngine, + options?: { + capabilities?: Capabilities; + eventCallback?: CameraEventCallback; + }, + ) { + super(config, engine, options); + } + + public async initialize(options: FrigateCameraInitializationOptions): Promise { + await this._initializeConfig(options.hass, options.entityRegistryManager); + await this._initializeCapabilities(options.hass); + await this._subscribeToEvents(options.hass, options.frigateEventWatcher); + return await super.initialize(options); } protected async _initializeConfig( @@ -266,49 +287,39 @@ export class FrigateCamera extends Camera { return null; } - protected async _subscribeToEvents(hass: HomeAssistant): Promise { + protected async _subscribeToEvents( + hass: HomeAssistant, + frigateEventWatcher: FrigateEventWatcherSubscriptionInterface, + ): Promise { const config = this.getConfig(); if (!config.triggers.events.length || !config.frigate.camera_name) { return; } - this._destroyCallbacks.push( - await subscribeToTrigger(hass, (ev) => this._handleEventChange(ev), { - platform: 'mqtt', - topic: `${config.frigate.client_id}/events`, + /* istanbul ignore next -- exercising the matcher is not possible when the + test uses an event watcher -- @preserve */ + const request: FrigateEventWatcherRequest = { + instanceID: config.frigate.client_id, + callback: (event: FrigateEventChange) => this._frigateEventHandler(event), + matcher: (event: FrigateEventChange): boolean => + event.after.camera === config.frigate.camera_name, + }; - // Only trigger for events pertaining to this camera. - payload: config.frigate.camera_name, - valueTemplate: '{{ value_json.after.camera }}', - }), - ); + await frigateEventWatcher.subscribe(hass, request); + this._onDestroy(() => frigateEventWatcher.unsubscribe(request)); } - protected _handleEventChange(ev: unknown): void { - const parseResult = frigateEventChangeTriggerResponseSchema.safeParse(ev); - if (!parseResult.success) { - console.warn('Ignoring unparseable Frigate event', ev); - return; - } - - const change = parseResult.data.variables.trigger.payload_json; + protected _frigateEventHandler = (ev: FrigateEventChange): void => { const snapshotChange = - (!change.before.has_snapshot && change.after.has_snapshot) || - change.before.snapshot?.frame_time !== change.after.snapshot?.frame_time; - const clipChange = !change.before.has_clip && change.after.has_clip; + (!ev.before.has_snapshot && ev.after.has_snapshot) || + ev.before.snapshot?.frame_time !== ev.after.snapshot?.frame_time; + const clipChange = !ev.before.has_clip && ev.after.has_clip; const config = this.getConfig(); - if (config.frigate.camera_name !== change.after.camera) { - return; - } - if ( (config.frigate.zones?.length && - !config.frigate.zones.some((zone) => - change.after.current_zones.includes(zone), - )) || - (config.frigate.labels?.length && - !config.frigate.labels.includes(change.after.label)) + !config.frigate.zones.some((zone) => ev.after.current_zones.includes(zone))) || + (config.frigate.labels?.length && !config.frigate.labels.includes(ev.after.label)) ) { return; } @@ -327,11 +338,11 @@ export class FrigateCamera extends Camera { this._eventCallback?.({ fidelity: 'high', cameraID: this.getID(), - type: change.type, + type: ev.type, // In cases where there are both clip and snapshot media, ensure to only // trigger on the media type that is allowed by the configuration. clip: clipChange && eventsToTriggerOn.includes('clips'), snapshot: snapshotChange && eventsToTriggerOn.includes('snapshots'), }); - } + }; } diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index bc2a837e..d8d871e5 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -4,6 +4,7 @@ import isEqual from 'lodash-es/isEqual'; import orderBy from 'lodash-es/orderBy'; import throttle from 'lodash-es/throttle'; import uniqWith from 'lodash-es/uniqWith'; +import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher'; import { PTZAction } from '../../config/ptz'; import { ActionPhase, CameraConfig } from '../../config/types'; import { ExtendedHomeAssistant } from '../../types'; @@ -20,8 +21,8 @@ import { ViewMediaClassifier } from '../../view/media-classifier'; import { RecordingSegmentsCache, RequestCache } from '../cache'; import { Camera } from '../camera'; import { - CameraManagerEngine, CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, + CameraManagerEngine, } from '../engine'; import { GenericCameraManagerEngine } from '../generic/engine-generic'; import { DateRange } from '../range'; @@ -59,15 +60,16 @@ import { import { getDefaultGo2RTCEndpoint } from '../utils/go2rtc-endpoint'; import frigateLogo from './assets/frigate-logo-dark.svg'; import { FrigateCamera, isBirdseye } from './camera'; +import { FrigateEventWatcher } from './event-watcher'; import { FrigateViewMediaFactory } from './media'; import { FrigateViewMediaClassifier } from './media-classifier'; import { - getEvents, - getEventSummary, - getRecordingSegments, - getRecordingsSummary, NativeFrigateEventQuery, NativeFrigateRecordingSegmentsQuery, + getEventSummary, + getEvents, + getRecordingSegments, + getRecordingsSummary, retainEvent, } from './requests'; import { @@ -110,6 +112,8 @@ export class FrigateCameraManagerEngine extends GenericCameraManagerEngine implements CameraManagerEngine { + protected _entityRegistryManager: EntityRegistryManager; + protected _frigateEventWatcher: FrigateEventWatcher; protected _recordingSegmentsCache: RecordingSegmentsCache; protected _requestCache: RequestCache; @@ -121,11 +125,15 @@ export class FrigateCameraManagerEngine ); constructor( + entityRegistryManager: EntityRegistryManager, + stateWatcher: StateWatcherSubscriptionInterface, recordingSegmentsCache: RecordingSegmentsCache, requestCache: RequestCache, eventCallback?: CameraEventCallback, ) { - super(eventCallback); + super(stateWatcher, eventCallback); + this._entityRegistryManager = entityRegistryManager; + this._frigateEventWatcher = new FrigateEventWatcher(); this._recordingSegmentsCache = recordingSegmentsCache; this._requestCache = requestCache; } @@ -136,13 +144,17 @@ export class FrigateCameraManagerEngine public async createCamera( hass: HomeAssistant, - entityRegistryManager: EntityRegistryManager, cameraConfig: CameraConfig, ): Promise { const camera = new FrigateCamera(cameraConfig, this, { eventCallback: this._eventCallback, }); - return await camera.initialize(hass, entityRegistryManager); + return await camera.initialize({ + hass, + entityRegistryManager: this._entityRegistryManager, + stateWatcher: this._stateWatcher, + frigateEventWatcher: this._frigateEventWatcher, + }); } public async getMediaDownloadPath( diff --git a/src/camera-manager/frigate/event-watcher.ts b/src/camera-manager/frigate/event-watcher.ts new file mode 100644 index 00000000..6aee9002 --- /dev/null +++ b/src/camera-manager/frigate/event-watcher.ts @@ -0,0 +1,76 @@ +import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; +import { FrigateEventChange, frigateEventChangeSchema } from './types'; + +export interface FrigateEventWatcherRequest { + instanceID: string; + matcher?(event: FrigateEventChange): boolean; + callback(event: FrigateEventChange): void; +} + +export interface FrigateEventWatcherSubscriptionInterface { + subscribe(hass: HomeAssistant, request: FrigateEventWatcherRequest): Promise; + unsubscribe(callback: FrigateEventWatcherRequest): void; +} + +type SubscriptionUnsubscribe = () => Promise; + +export class FrigateEventWatcher implements FrigateEventWatcherSubscriptionInterface { + protected _requests: FrigateEventWatcherRequest[] = []; + protected _unsubscribeCallback: Record = {}; + + public async subscribe( + hass: HomeAssistant, + request: FrigateEventWatcherRequest, + ): Promise { + const shouldSubscribe = !this._hasSubscribers(request.instanceID); + this._requests.push(request); + if (shouldSubscribe) { + this._unsubscribeCallback[request.instanceID] = + await hass.connection.subscribeMessage( + (data) => this._receiveHandler(request.instanceID, data), + { type: 'frigate/events/subscribe', instance_id: request.instanceID }, + ); + } + } + + public async unsubscribe(request: FrigateEventWatcherRequest): Promise { + this._requests = this._requests.filter( + (existingRequest) => existingRequest !== request, + ); + + if (!this._hasSubscribers(request.instanceID)) { + await this._unsubscribeCallback[request.instanceID](); + delete this._unsubscribeCallback[request.instanceID]; + } + } + + protected _hasSubscribers(instanceID: string): boolean { + return !!this._requests.filter((request) => request.instanceID === instanceID) + .length; + } + + protected _receiveHandler(instanceID: string, data: string): void { + let json: unknown; + try { + json = JSON.parse(data); + } catch (e) { + console.warn('Received non-JSON payload as Frigate event', data); + return; + } + + const parsedEvent = frigateEventChangeSchema.safeParse(json); + if (!parsedEvent.success) { + console.warn('Received malformed Frigate event from Home Assistant', data); + return; + } + + for (const request of this._requests) { + if ( + request.instanceID === instanceID && + (!request.matcher || request.matcher(parsedEvent.data)) + ) { + request.callback(parsedEvent.data); + } + } + } +} diff --git a/src/camera-manager/frigate/types.ts b/src/camera-manager/frigate/types.ts index 7cc7559b..77515ff4 100644 --- a/src/camera-manager/frigate/types.ts +++ b/src/camera-manager/frigate/types.ts @@ -83,8 +83,7 @@ export const ptzInfoSchema = z.object({ }); export type PTZInfo = z.infer; -// Frigate events as stored in MQTT updates. -const frigateEventChangeSchema = z.object({ +const frigateEventChangeBeforeAfterSchema = z.object({ camera: z.string(), snapshot: z .object({ @@ -96,25 +95,13 @@ const frigateEventChangeSchema = z.object({ label: z.string(), current_zones: z.string().array(), }); -export type FrigateEventChange = z.infer; -const frigateEventChangeType = z.enum(['new', 'update', 'end']); -export type FrigateEventChangeType = z.infer; - -export const frigateEventChangeTriggerResponseSchema = z.object({ - variables: z.object({ - trigger: z.object({ - payload_json: z.object({ - before: frigateEventChangeSchema, - after: frigateEventChangeSchema, - type: frigateEventChangeType, - }), - }), - }), +export const frigateEventChangeSchema = z.object({ + before: frigateEventChangeBeforeAfterSchema, + after: frigateEventChangeBeforeAfterSchema, + type: z.enum(['new', 'update', 'end']), }); -export type FrigateEventChangeTriggerResponse = z.infer< - typeof frigateEventChangeTriggerResponseSchema ->; +export type FrigateEventChange = z.infer; // ============================== // Frigate concrete query results diff --git a/src/camera-manager/generic/engine-generic.ts b/src/camera-manager/generic/engine-generic.ts index dc69f35e..f201c852 100644 --- a/src/camera-manager/generic/engine-generic.ts +++ b/src/camera-manager/generic/engine-generic.ts @@ -1,11 +1,11 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; -import { PTZAction, PTZ_PAN_TILT_ACTIONS, PTZ_ZOOM_ACTIONS } from '../../config/ptz'; +import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher'; +import { PTZAction } from '../../config/ptz'; import { ActionPhase, CameraConfig } from '../../config/types'; -import { ExtendedHomeAssistant, PTZCapabilities, PTZMovementType } from '../../types'; +import { ExtendedHomeAssistant } from '../../types'; import { getEntityIcon, getEntityTitle } from '../../utils/ha'; -import { EntityRegistryManager } from '../../utils/ha/entity-registry'; import { ViewMedia } from '../../view/media'; import { Camera } from '../camera'; import { Capabilities } from '../capabilities'; @@ -40,8 +40,13 @@ import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; export class GenericCameraManagerEngine implements CameraManagerEngine { protected _eventCallback?: CameraEventCallback; + protected _stateWatcher: StateWatcherSubscriptionInterface; - constructor(eventCallback?: CameraEventCallback) { + constructor( + stateWatcher: StateWatcherSubscriptionInterface, + eventCallback?: CameraEventCallback, + ) { + this._stateWatcher = stateWatcher; this._eventCallback = eventCallback; } @@ -50,8 +55,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { } public async createCamera( - hass: HomeAssistant, - entityRegistryManager: EntityRegistryManager, + _hass: HomeAssistant, cameraConfig: CameraConfig, ): Promise { return await new Camera(cameraConfig, this, { @@ -74,7 +78,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { }, ), eventCallback: this._eventCallback, - }).initialize(hass, entityRegistryManager); + }).initialize({ stateWatcher: this._stateWatcher }); } public generateDefaultEventQuery( diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index 9bae0252..71ba9d4f 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -123,10 +123,7 @@ export class CameraManager { this._api = api; this._engineFactory = options?.factory ?? - new CameraManagerEngineFactory( - this._api.getEntityRegistryManager(), - this._api.getResolvedMediaCache(), - ); + new CameraManagerEngineFactory(this._api.getEntityRegistryManager()); this._store = options?.store ?? new CameraManagerStore(); } @@ -157,7 +154,9 @@ export class CameraManager { // rapidly in the config editor). await this._initializationLimit.add(resetAndInitialize); } catch (e: unknown) { - this._api.getMessageManager().setErrorIfHigherPriority(e); + this._api + .getMessageManager() + .setErrorIfHigherPriority(e, localize('error.camera_initialization')); return false; } return true; @@ -190,9 +189,11 @@ export class CameraManager { const engineType = engineTypes[index]; const engine = engineType ? engines.get(engineType) ?? - (await this._engineFactory.createEngine(engineType, (ev) => - this._api.getTriggersManager().handleCameraEvent(ev), - )) + (await this._engineFactory.createEngine(engineType, { + eventCallback: (ev) => this._api.getTriggersManager().handleCameraEvent(ev), + stateWatcher: this._api.getHASSManager().getStateWatcher(), + resolvedMediaCache: this._api.getResolvedMediaCache(), + })) : null; if (!engine || !engineType) { throw new CameraInitializationError( @@ -238,12 +239,7 @@ export class CameraManager { // Configuration is initialized in parallel. const cameras = await allPromises( engineByConfig.entries(), - async ([cameraConfig, engine]) => - await engine.createCamera( - hass, - this._api.getEntityRegistryManager(), - cameraConfig, - ), + async ([cameraConfig, engine]) => await engine.createCamera(hass, cameraConfig), ); // Do the additions based off the result-order, to ensure the map order is diff --git a/src/card-controller/card-element-manager.ts b/src/card-controller/card-element-manager.ts index f8587919..1590b556 100644 --- a/src/card-controller/card-element-manager.ts +++ b/src/card-controller/card-element-manager.ts @@ -46,9 +46,9 @@ export class CardElementManager { this._menuToggleCallback(); } - public update(): void { + public update = (): void => { this._element.requestUpdate(); - } + }; public hasUpdated(): boolean { return this._element.hasUpdated; @@ -69,6 +69,17 @@ export class CardElementManager { this._api.getKeyboardStateManager().initialize(); this._api.getDefaultManager().initialize(); + this._api + .getHASSManager() + .getStateWatcher() + ?.subscribe(this.update, [ + ...(this._api.getConfigManager().getConfig()?.view.render_entities ?? []), + + // Refresh the card if media player state changes: + // https://github.com/dermotduffy/frigate-hass-card/issues/881 + ...(this._api.getMediaPlayerManager().getMediaPlayers() ?? []), + ]); + // Whether or not the card is in panel mode on the dashboard. setOrRemoveAttribute(this._element, isCardInPanel(this._element), 'panel'); setOrRemoveAttribute(this._element, true, 'tabindex', '0'); @@ -129,6 +140,7 @@ export class CardElementManager { this._api.getKeyboardStateManager().uninitialize(); this._api.getActionsManager().uninitialize(); this._api.getDefaultManager().uninitialize(); + this._api.getHASSManager().getStateWatcher()?.unsubscribe(this.update); // Uninitialize cameras to cause them to reinitialize on // reconnection, to ensure the state subscription/unsubscription works diff --git a/src/card-controller/controller.ts b/src/card-controller/controller.ts index cc10b9d1..859f42ab 100644 --- a/src/card-controller/controller.ts +++ b/src/card-controller/controller.ts @@ -20,7 +20,7 @@ import { ConfigManager } from './config/config-manager'; import { DownloadManager } from './download-manager'; import { ExpandManager } from './expand-manager'; import { FullscreenManager } from './fullscreen-manager'; -import { HASSManager } from './hass-manager'; +import { HASSManager } from './hass/hass-manager'; import { InitializationManager } from './initialization-manager'; import { InteractionManager } from './interaction-manager'; import { MediaLoadedInfoManager } from './media-info-manager'; diff --git a/src/card-controller/default-manager.ts b/src/card-controller/default-manager.ts index 88d10027..60eb7383 100644 --- a/src/card-controller/default-manager.ts +++ b/src/card-controller/default-manager.ts @@ -1,9 +1,8 @@ import PQueue from 'p-queue'; -import { DestroyCallback, subscribeToTrigger } from '../utils/ha'; +import { createGeneralAction } from '../utils/action'; import { isActionAllowedBasedOnInteractionState } from '../utils/interaction-mode'; import { Timer } from '../utils/timer'; import { CardDefaultManagerAPI } from './types'; -import { createGeneralAction } from '../utils/action'; /** * Manages automated resetting to the default view. @@ -11,7 +10,6 @@ import { createGeneralAction } from '../utils/action'; export class DefaultManager { protected _timer = new Timer(); protected _api: CardDefaultManagerAPI; - protected _unsubscribeCallback: DestroyCallback | null = null; protected _initializationLimit = new PQueue({ concurrency: 1 }); constructor(api: CardDefaultManagerAPI) { @@ -47,8 +45,7 @@ export class DefaultManager { public uninitialize(): void { this._timer.stop(); - this._unsubscribeCallback?.(); - this._unsubscribeCallback = null; + this._api.getHASSManager().getStateWatcher().unsubscribe(this._stateChangeHandler); this._api.getAutomationsManager().deleteAutomations(this); } @@ -59,19 +56,11 @@ export class DefaultManager { return false; } - if (this._unsubscribeCallback) { - await this._unsubscribeCallback(); - } - - this._unsubscribeCallback = await subscribeToTrigger( - hass, - () => this._setToDefaultIfAllowed(), - { - entityID: config.entities, - platform: 'state', - stateOnly: true, - }, - ); + this._api.getHASSManager().getStateWatcher().unsubscribe(this._stateChangeHandler); + this._api + .getHASSManager() + .getStateWatcher() + .subscribe(this._stateChangeHandler, config.entities); // If the timer is running, restart it with the newly configured timer. if (this._timer.isRunning()) { @@ -82,6 +71,10 @@ export class DefaultManager { return true; } + protected _stateChangeHandler = (): void => { + this._setToDefaultIfAllowed(); + }; + protected _setToDefaultIfAllowed(): void { if (this._isAutomatedUpdateAllowed()) { this._api.getViewManager().setViewDefault(); diff --git a/src/card-controller/hass-manager.ts b/src/card-controller/hass/hass-manager.ts similarity index 64% rename from src/card-controller/hass-manager.ts rename to src/card-controller/hass/hass-manager.ts index 34c35e7d..a0ea929f 100644 --- a/src/card-controller/hass-manager.ts +++ b/src/card-controller/hass/hass-manager.ts @@ -1,11 +1,13 @@ -import { localize } from '../localize/localize'; -import { ExtendedHomeAssistant } from '../types'; -import { hasHAConnectionStateChanged, isHassDifferent } from '../utils/ha'; -import { CardHASSAPI } from './types'; +import { localize } from '../../localize/localize'; +import { ExtendedHomeAssistant } from '../../types'; +import { hasHAConnectionStateChanged } from '../../utils/ha'; +import { CardHASSAPI } from '../types'; +import { StateWatcher, StateWatcherSubscriptionInterface } from './state-watcher'; export class HASSManager { protected _hass: ExtendedHomeAssistant | null = null; protected _api: CardHASSAPI; + protected _stateWatcher: StateWatcher = new StateWatcher(); constructor(api: CardHASSAPI) { this._api = api; @@ -15,6 +17,10 @@ export class HASSManager { return this._hass; } + public getStateWatcher(): StateWatcherSubscriptionInterface { + return this._stateWatcher; + } + public setHASS(hass?: ExtendedHomeAssistant | null): void { if (hasHAConnectionStateChanged(this._hass, hass)) { if (!hass?.connected) { @@ -36,18 +42,6 @@ export class HASSManager { const oldHass = this._hass; this._hass = hass; - if ( - isHassDifferent(this._hass, oldHass, [ - ...(this._api.getConfigManager().getConfig()?.view.render_entities ?? []), - - // Refresh the card if media player state changes: - // https://github.com/dermotduffy/frigate-hass-card/issues/881 - ...this._api.getMediaPlayerManager().getMediaPlayers(), - ]) - ) { - this._api.getCardElementManager().update(); - } - if (this._api.getConditionsManager().hasHAStateConditions()) { this._api.getConditionsManager().setState({ state: this._hass.states, @@ -57,5 +51,7 @@ export class HASSManager { // Dark mode may depend on HASS. this._api.getStyleManager().setLightOrDarkMode(); + + this._stateWatcher.setHASS(oldHass, hass); } } diff --git a/src/card-controller/hass/state-watcher.ts b/src/card-controller/hass/state-watcher.ts new file mode 100644 index 00000000..08e42250 --- /dev/null +++ b/src/card-controller/hass/state-watcher.ts @@ -0,0 +1,51 @@ +import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; +import { getHassDifferences, HassStateDifference } from '../../utils/ha'; + +type StateWatcherCallback = (difference: HassStateDifference) => void; + +export interface StateWatcherSubscriptionInterface { + subscribe(callback: StateWatcherCallback, entityIDs: string[]): void; + unsubscribe(callback: StateWatcherCallback): void; +} + +export class StateWatcher implements StateWatcherSubscriptionInterface { + protected _watcherCallbacks = new Map(); + + public setHASS(oldHass: HomeAssistant | null, hass: HomeAssistant): void { + if (!oldHass) { + return; + } + + for (const [callback, entityIDs] of this._watcherCallbacks.entries()) { + const differences = getHassDifferences(hass, oldHass, entityIDs, { + stateOnly: true, + firstOnly: true, + }); + if (differences.length) { + callback(differences[0]); + } + } + } + + /** + * 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/message-manager.ts b/src/card-controller/message-manager.ts index 22afbdf6..940f56e7 100644 --- a/src/card-controller/message-manager.ts +++ b/src/card-controller/message-manager.ts @@ -42,16 +42,17 @@ export class MessageManager { } } - public setErrorIfHigherPriority(error: unknown): void { + public setErrorIfHigherPriority(error: unknown, prefix?: string): void { // This object should accept unknown objects to be able to seamlessly - // process arguments to catch() which can only be unknown/any. - if (!(error instanceof Error)) { + // process arguments to catch() which can only be unknown/any. HA may throw + // non Error() based errors. + if (!error || typeof error !== 'object' || !('message' in error)) { return; } errorToConsole(error); this.setMessageIfHigherPriority({ - message: error.message, + message: prefix ? `${prefix}: ${error.message}` : error.message, type: 'error', ...(error instanceof FrigateCardError && { context: error.context }), }); diff --git a/src/card-controller/style-manager.ts b/src/card-controller/style-manager.ts index 02a95115..ff3245fd 100644 --- a/src/card-controller/style-manager.ts +++ b/src/card-controller/style-manager.ts @@ -11,7 +11,7 @@ export class StyleManager { this._api = api; } - public setLightOrDarkMode(): void { + public setLightOrDarkMode = (): void => { const config = this._api.getConfigManager().getConfig(); const isDarkMode = config?.view.dark_mode === 'on' || @@ -24,7 +24,7 @@ export class StyleManager { isDarkMode, 'dark', ); - } + }; public setExpandedMode(): void { const card = this._api.getCardElementManager().getElement(); diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index 0806cb66..5dd754d6 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -12,7 +12,7 @@ import type { DefaultManager } from './default-manager'; import type { DownloadManager } from './download-manager'; import type { ExpandManager } from './expand-manager'; import type { FullscreenManager } from './fullscreen-manager'; -import type { HASSManager } from './hass-manager'; +import type { HASSManager } from './hass/hass-manager'; import type { InitializationManager } from './initialization-manager'; import type { InteractionManager } from './interaction-manager'; import type { KeyboardStateManager } from './keyboard-state-manager'; @@ -121,13 +121,16 @@ export interface CardDownloadAPI { export interface CardElementAPI { getActionsManager(): ActionsManager; getCameraManager(): CameraManager; + getConfigManager(): ConfigManager; getDefaultManager(): DefaultManager; getExpandManager(): ExpandManager; getFullscreenManager(): FullscreenManager; getInitializationManager(): InitializationManager; getInteractionManager(): InteractionManager; + getHASSManager(): HASSManager; getKeyboardStateManager(): KeyboardStateManager; getMediaLoadedInfoManager(): MediaLoadedInfoManager; + getMediaPlayerManager(): MediaPlayerManager; getMicrophoneManager(): MicrophoneManager; getQueryStringManager(): QueryStringManager; } diff --git a/src/config/ptz.ts b/src/config/ptz.ts index 3dd77e98..4507e2bf 100644 --- a/src/config/ptz.ts +++ b/src/config/ptz.ts @@ -1,5 +1,5 @@ -export const PTZ_PAN_TILT_ACTIONS = ['left', 'right', 'up', 'down'] as const; -export const PTZ_ZOOM_ACTIONS = ['zoom_in', 'zoom_out'] as const; +const PTZ_PAN_TILT_ACTIONS = ['left', 'right', 'up', 'down'] as const; +const PTZ_ZOOM_ACTIONS = ['zoom_in', 'zoom_out'] as const; const PTZ_BASE_ACTIONS = [...PTZ_PAN_TILT_ACTIONS, ...PTZ_ZOOM_ACTIONS] as const; export type PTZBaseAction = (typeof PTZ_BASE_ACTIONS)[number]; diff --git a/src/localize/languages/ca.json b/src/localize/languages/ca.json index cd51cdcb..efb70f05 100644 --- a/src/localize/languages/ca.json +++ b/src/localize/languages/ca.json @@ -570,6 +570,7 @@ }, "error": { "awaiting_live": "S'està esperant que es carregui la transmissió en directe ...", + "camera_initialization": "", "could_not_render_elements": "No s'han pogut representar els elements de la imatge", "could_not_resolve": "No s'ha pogut resoldre l'URL multimèdia", "diagnostics": "Diagnòstic de targetes. Reviseu la informació confidencial abans de compartir-la", diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index d4f6b6bc..dbb6703c 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -570,6 +570,7 @@ }, "error": { "awaiting_live": "Waiting for live stream to load ...", + "camera_initialization": "Camera initialization failed", "could_not_render_elements": "Could not render picture elements", "could_not_resolve": "Could not resolve media URL", "diagnostics": "Card diagnostics. Please review for confidential information prior to sharing", diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json index f7193c85..567e431b 100644 --- a/src/localize/languages/fr.json +++ b/src/localize/languages/fr.json @@ -570,6 +570,7 @@ }, "error": { "awaiting_live": "", + "camera_initialization": "", "could_not_render_elements": "Impossible de restituer les éléments de l'image", "could_not_resolve": "", "diagnostics": "Diagnostic de la carte. Veuillez enlever les informations confidentielles avant de les partager", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index c9eae16f..7876a92f 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -570,6 +570,7 @@ }, "error": { "awaiting_live": "", + "camera_initialization": "", "could_not_render_elements": "Impossibile renderizzare gli elementi dell'immagine", "could_not_resolve": "Impossibile risolvere l'URL dei media", "diagnostics": "Diagnostica delle carte.Si prega di rivedere per informazioni riservate prima di condividere", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 137fba4f..bd16f26b 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -570,6 +570,7 @@ }, "error": { "awaiting_live": "", + "camera_initialization": "", "could_not_render_elements": "Não foi possível renderizar os elementos da imagem", "could_not_resolve": "Não foi possível resolver o URL de mídia", "diagnostics": "Diagnósticos do cartão. Revise as informações confidenciais antes de compartilhar", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index 9771e9f9..ad342dea 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -570,6 +570,7 @@ }, "error": { "awaiting_live": "", + "camera_initialization": "", "could_not_render_elements": "Não foi possível renderizar os elementos da imagem", "could_not_resolve": "Não foi possível resolver o URL de mídia", "diagnostics": "Diagnósticos do cartão. Reveja as informações confidenciais antes de partilhar", diff --git a/src/types.ts b/src/types.ts index fac43e60..990deea5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -56,7 +56,7 @@ export const MESSAGE_TYPE_PRIORITIES: MessagePriority = { }; export interface Message { - message: string; + message: unknown; type: MessageType; icon?: string; context?: unknown; diff --git a/src/utils/basic.ts b/src/utils/basic.ts index b3f4ee6e..36d5342e 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -101,14 +101,17 @@ export function contentsChanged( /** * Log an error as a warning to the console. - * @param e The Error object. + * @param e The Error-like object. * @param func The Console func to call. */ -export function errorToConsole(e: Error, func: CallableFunction = console.warn): void { +export function errorToConsole( + e: Error | { message: unknown }, + func: CallableFunction = console.warn, +): void { if (e instanceof FrigateCardError && e.context) { func(e, e.context); } else { - func(e); + func(e.message); } } diff --git a/src/utils/ha/index.ts b/src/utils/ha/index.ts index 1a85e69a..bd0d5ec2 100644 --- a/src/utils/ha/index.ts +++ b/src/utils/ha/index.ts @@ -17,14 +17,6 @@ import { } from '../../types.js'; import { domainIcon } from '../icons/domain-icon.js'; import { getParseErrorKeys } from '../zod.js'; -import { - HAStateChangeFromTo, - haStateChangeTriggerResponseSchema, - SubscriptionCallback, - SubscriptionUnsubscribe, -} from './types.js'; - -export type DestroyCallback = () => Promise; /** * Make a HomeAssistant websocket request. May throw. @@ -100,8 +92,8 @@ export async function homeAssistantSignPath( return hass.hassUrl(response.path); } -interface HassStateDifference { - entity: string; +export interface HassStateDifference { + entityID: string; oldState?: HassEntity; newState: HassEntity; } @@ -115,7 +107,7 @@ interface HassStateDifference { * strings only, firstOnly: whether or not to get the first difference only. * @returns An array of HassStateDifference objects. */ -function getHassDifferences( +export function getHassDifferences( newHass: HomeAssistant | undefined | null, oldHass: HomeAssistant | undefined | null, entities: string[] | null, @@ -137,7 +129,7 @@ function getHassDifferences( (!options?.stateOnly && oldState !== newState) ) { differences.push({ - entity: entity, + entityID: entity, oldState: oldState, newState: newState, }); @@ -403,53 +395,3 @@ export const hasHAConnectionStateChanged = ( ): boolean => { return oldHass?.connected !== newHass?.connected; }; - -/** - * Subscribe to a HA trigger - * @param hass The HA object. - * @param callback The callback to call with the data. - * @param options Parameters to the trigger, see: - * https://www.home-assistant.io/docs/automation/trigger/#state-trigger - * @returns A callback to unsubscribe. - */ -export const subscribeToTrigger = async ( - hass: HomeAssistant, - callback: SubscriptionCallback, - options?: { - entityID?: string | string[]; - platform?: string; - topic?: string; - payload?: string; - valueTemplate?: string; - stateOnly?: boolean; - }, -): Promise => { - return await hass.connection.subscribeMessage(callback, { - type: 'subscribe_trigger', - trigger: { - ...(options?.platform && { platform: options.platform }), - ...(options?.entityID && { entity_id: options.entityID }), - ...(options?.topic && { topic: options.topic }), - ...(options?.payload && { payload: options.payload }), - ...(options?.valueTemplate && { value_template: options.valueTemplate }), - ...(options?.stateOnly && { - from: null, - to: null, - }), - }, - }); -}; - -/** - * Parse a state change trigger response. - * @param data The raw data. - * @returns A HAStateChangeFromTo object. - */ -export const parseStateChangeTrigger = (data: unknown): HAStateChangeFromTo | null => { - const parseResult = haStateChangeTriggerResponseSchema.safeParse(data); - if (!parseResult.success) { - console.warn('Ignoring unparseable HA state change', data); - return null; - } - return parseResult.data.variables.trigger; -}; diff --git a/src/utils/ha/types.ts b/src/utils/ha/types.ts deleted file mode 100644 index d1c54943..00000000 --- a/src/utils/ha/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { z } from 'zod'; - -export type SubscriptionUnsubscribe = () => Promise; -export type SubscriptionCallback = (data: unknown) => void; - -const haStateChangeSchema = z.object({ - entity_id: z.string(), - state: z.string(), -}); - -const haStateChangeFromToSchema = z.object({ - from_state: haStateChangeSchema, - to_state: haStateChangeSchema, -}); -export type HAStateChangeFromTo = z.infer; - -export const haStateChangeTriggerResponseSchema = z.object({ - variables: z.object({ - trigger: haStateChangeFromToSchema, - }), -}); diff --git a/tests/camera-manager/browse-media/camera.test.ts b/tests/camera-manager/browse-media/camera.test.ts index c7905f24..eaf96699 100644 --- a/tests/camera-manager/browse-media/camera.test.ts +++ b/tests/camera-manager/browse-media/camera.test.ts @@ -5,6 +5,7 @@ import { CameraManagerEngine } from '../../../src/camera-manager/engine'; import { EntityRegistryManager } from '../../../src/utils/ha/entity-registry'; import { Entity } from '../../../src/utils/ha/entity-registry/types'; import { createCameraConfig, createHASS } from '../../test-utils'; +import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher'; describe('BrowseMediaCamera', () => { describe('should initialize', () => { @@ -15,7 +16,12 @@ describe('BrowseMediaCamera', () => { ); expect( - async () => await camera.initialize(createHASS(), mock()), + async () => + await camera.initialize({ + hass: createHASS(), + stateWatcher: mock(), + entityRegistryManager: mock(), + }), ).rejects.toThrowError(/Could not find camera entity/); }); @@ -30,7 +36,13 @@ describe('BrowseMediaCamera', () => { const entity = mock(); entityRegistryManager.getEntity.mockResolvedValue(entity); - expect(await camera.initialize(createHASS(), entityRegistryManager)).toBe(camera); + expect( + await camera.initialize({ + hass: createHASS(), + stateWatcher: mock(), + entityRegistryManager: entityRegistryManager, + }), + ).toBe(camera); expect(camera.getEntity()).toBe(entity); }); }); diff --git a/tests/camera-manager/camera.test.ts b/tests/camera-manager/camera.test.ts index 46e5196b..6d08f403 100644 --- a/tests/camera-manager/camera.test.ts +++ b/tests/camera-manager/camera.test.ts @@ -2,51 +2,69 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { Camera } from '../../src/camera-manager/camera.js'; import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic.js'; -import { EntityRegistryManager } from '../../src/utils/ha/entity-registry/index.js'; +import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js'; import { - callHASubscribeMessageHandler, + callStateWatcherCallback, createCameraConfig, createCapabilities, - createHASS, + createStateEntity, } from '../test-utils.js'; describe('Camera', () => { it('should get config', async () => { const config = createCameraConfig(); - const camera = new Camera(config, new GenericCameraManagerEngine()); + const camera = new Camera( + config, + new GenericCameraManagerEngine(mock()), + ); expect(camera.getConfig()).toBe(config); }); describe('should get capabilities', async () => { it('when populated', async () => { const capabilities = createCapabilities(); - const camera = new Camera(createCameraConfig(), new GenericCameraManagerEngine(), { - capabilities: capabilities, - }); + const camera = new Camera( + createCameraConfig(), + new GenericCameraManagerEngine(mock()), + { + capabilities: capabilities, + }, + ); expect(camera.getCapabilities()).toBe(capabilities); }); it('when unpopulated', async () => { - const camera = new Camera(createCameraConfig(), new GenericCameraManagerEngine()); + const camera = new Camera( + createCameraConfig(), + new GenericCameraManagerEngine(mock()), + ); expect(camera.getCapabilities()).toBeNull(); }); }); it('should get engine', async () => { - const engine = new GenericCameraManagerEngine(); + const engine = new GenericCameraManagerEngine( + mock(), + ); const camera = new Camera(createCameraConfig(), engine); expect(camera.getEngine()).toBe(engine); }); it('should set and get id', async () => { - const camera = new Camera(createCameraConfig(), new GenericCameraManagerEngine()); + const camera = new Camera( + createCameraConfig(), + new GenericCameraManagerEngine(mock()), + ); camera.setID('foo'); expect(camera.getID()).toBe('foo'); expect(camera.getConfig().id).toBe('foo'); }); it('should throw without id', async () => { - const camera = new Camera(createCameraConfig(), new GenericCameraManagerEngine()); + const camera = new Camera( + createCameraConfig(), + new GenericCameraManagerEngine(mock()), + ); expect(() => camera.getID()).toThrowError( 'Could not determine camera id for the following ' + "camera, may need to set 'id' parameter manually", @@ -60,39 +78,19 @@ describe('Camera', () => { entities: ['camera.foo'], }, }), - new GenericCameraManagerEngine(), + new GenericCameraManagerEngine(mock()), ); - const hass = createHASS(); - const unsubcribeCallback = vi.fn(); - vi.mocked(hass.connection.subscribeMessage).mockResolvedValue(unsubcribeCallback); + const stateWatcher = mock(); + await camera.initialize({ + stateWatcher: stateWatcher, + }); - await camera.initialize(hass, mock()); - - expect(hass.connection.subscribeMessage).toBeCalledWith( - expect.anything(), - expect.objectContaining({ - type: 'subscribe_trigger', - trigger: { - entity_id: ['camera.foo'], - platform: 'state', - from: null, - to: null, - }, - }), - ); - expect(unsubcribeCallback).not.toBeCalled(); + expect(stateWatcher.subscribe).toBeCalledWith(expect.any(Function), ['camera.foo']); await camera.destroy(); - expect(unsubcribeCallback).toBeCalled(); - }); - it('should not subscribe without trigger entities', async () => { - const camera = new Camera(createCameraConfig(), new GenericCameraManagerEngine()); - const hass = createHASS(); - - await camera.initialize(hass, mock()); - expect(hass.connection.subscribeMessage).not.toBeCalled(); + expect(stateWatcher.unsubscribe).toBeCalled(); }); describe('should handle trigger state changes', () => { @@ -100,28 +98,6 @@ describe('Camera', () => { vi.restoreAllMocks(); }); - it('when malformed', async () => { - vi.spyOn(global.console, 'warn').mockReturnValue(undefined); - - const eventCallback = vi.fn(); - const camera = new Camera( - createCameraConfig({ - triggers: { - entities: ['camera.foo'], - }, - }), - new GenericCameraManagerEngine(), - { eventCallback: eventCallback }, - ); - - const hass = createHASS(); - await camera.initialize(hass, mock()); - - callHASubscribeMessageHandler(hass, 'MALFORMED_DATA'); - - expect(eventCallback).not.toBeCalled(); - }); - it.each([ ['off' as const, 'on' as const, 'new' as const], ['on' as const, 'off' as const, 'end' as const], @@ -138,28 +114,22 @@ describe('Camera', () => { entities: ['binary_sensor.foo'], }, }), - new GenericCameraManagerEngine(), + new GenericCameraManagerEngine(mock()), { eventCallback: eventCallback }, ); - const hass = createHASS(); - await camera.initialize(hass, mock()); - - callHASubscribeMessageHandler(hass, { - variables: { - trigger: { - from_state: { - entity_id: 'binary_sensor.foo', - state: stateFrom, - }, - to_state: { - entity_id: 'binary_sensor.foo', - state: stateTo, - }, - }, - }, + const stateWatcher = mock(); + await camera.initialize({ + stateWatcher: stateWatcher, }); + const diff = { + entityID: 'sensor.force_update', + oldState: createStateEntity({ state: stateFrom }), + newState: createStateEntity({ state: stateTo }), + }; + callStateWatcherCallback(stateWatcher, diff); + expect(eventCallback).toBeCalledWith({ cameraID: 'camera_1', type: eventType, diff --git a/tests/camera-manager/engine-factory.test.ts b/tests/camera-manager/engine-factory.test.ts index 647352f9..fe0e9ac1 100644 --- a/tests/camera-manager/engine-factory.test.ts +++ b/tests/camera-manager/engine-factory.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js'; import { FrigateCameraManagerEngine } from '../../src/camera-manager/frigate/engine-frigate'; import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic'; import { MotionEyeCameraManagerEngine } from '../../src/camera-manager/motioneye/engine-motioneye'; import { Engine } from '../../src/camera-manager/types.js'; +import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js'; import { CardWideConfig } from '../../src/config/types.js'; import { EntityRegistryManager } from '../../src/utils/ha/entity-registry'; import { EntityCache } from '../../src/utils/ha/entity-registry/cache'; @@ -20,12 +22,10 @@ vi.mock('../../src/utils/ha/entity-registry/cache'); const createFactory = (options?: { entityRegistryManager?: EntityRegistryManager; - resolvedMediaCache?: ResolvedMediaCache; cardWideConfig?: CardWideConfig; }): CameraManagerEngineFactory => { return new CameraManagerEngineFactory( options?.entityRegistryManager ?? new EntityRegistryManager(new EntityCache()), - options?.resolvedMediaCache ?? new ResolvedMediaCache(), ); }; @@ -181,18 +181,27 @@ describe('getEngineForCamera()', () => { describe('createEngine()', () => { it('should create generic engine', async () => { - expect(await createFactory().createEngine(Engine.Generic)).toBeInstanceOf( - GenericCameraManagerEngine, - ); + expect( + await createFactory().createEngine(Engine.Generic, { + stateWatcher: mock(), + resolvedMediaCache: mock(), + }), + ).toBeInstanceOf(GenericCameraManagerEngine); }); it('should create frigate engine', async () => { - expect(await createFactory().createEngine(Engine.Frigate)).toBeInstanceOf( - FrigateCameraManagerEngine, - ); + expect( + await createFactory().createEngine(Engine.Frigate, { + stateWatcher: mock(), + resolvedMediaCache: mock(), + }), + ).toBeInstanceOf(FrigateCameraManagerEngine); }); it('should create motioneye engine', async () => { - expect(await createFactory().createEngine(Engine.MotionEye)).toBeInstanceOf( - MotionEyeCameraManagerEngine, - ); + expect( + await createFactory().createEngine(Engine.MotionEye, { + stateWatcher: mock(), + resolvedMediaCache: mock(), + }), + ).toBeInstanceOf(MotionEyeCameraManagerEngine); }); }); diff --git a/tests/camera-manager/frigate/camera.test.ts b/tests/camera-manager/frigate/camera.test.ts index 15ed29e8..3ec504aa 100644 --- a/tests/camera-manager/frigate/camera.test.ts +++ b/tests/camera-manager/frigate/camera.test.ts @@ -2,40 +2,25 @@ 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'; +import { FrigateEventWatcher } from '../../../src/camera-manager/frigate/event-watcher'; import { getPTZInfo } from '../../../src/camera-manager/frigate/requests'; -import { - FrigateEventChange, - FrigateEventChangeTriggerResponse, - FrigateEventChangeType, -} from '../../../src/camera-manager/frigate/types'; +import { FrigateEventChange } from '../../../src/camera-manager/frigate/types'; +import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; import { CameraTriggerEventType } from '../../../src/config/types'; import { EntityRegistryManager } from '../../../src/utils/ha/entity-registry'; import { Entity } from '../../../src/utils/ha/entity-registry/types'; -import { - callHASubscribeMessageHandler, - createCameraConfig, - createHASS, - createRegistryEntity, -} from '../../test-utils'; +import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils'; vi.mock('../../../src/camera-manager/frigate/requests'); -const createFrigateEventChangeTrigger = ( - type: FrigateEventChangeType, - before: FrigateEventChange, - after: FrigateEventChange, -): FrigateEventChangeTriggerResponse => { - return { - variables: { - trigger: { - payload_json: { - before: before, - after: after, - type: type, - }, - }, - }, - }; +const callEventWatcherCallback = ( + eventWatcher: FrigateEventWatcher, + event: FrigateEventChange, + n = 0, +): void => { + const mock = vi.mocked(eventWatcher.subscribe).mock; + expect(mock.calls.length).greaterThan(n); + mock.calls[n][1].callback(event); }; describe('FrigateCamera', () => { @@ -51,7 +36,12 @@ describe('FrigateCamera', () => { const camera = new FrigateCamera(config, mock()); const beforeConfig = { ...config }; - await camera.initialize(createHASS(), mock()); + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: mock(), + }); expect(beforeConfig).toEqual(camera.getConfig()); }); @@ -67,7 +57,13 @@ describe('FrigateCamera', () => { entityRegistryManager.getEntity.mockRejectedValue(null); expect( - async () => await camera.initialize(createHASS(), entityRegistryManager), + async () => + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: entityRegistryManager, + stateWatcher: mock(), + frigateEventWatcher: mock(), + }), ).rejects.toThrowError(/Could not find camera entity/); }); @@ -85,9 +81,13 @@ describe('FrigateCamera', () => { }); entityRegistryManager.getEntity.mockResolvedValue(entity); - await camera.initialize(createHASS(), entityRegistryManager); - - expect(camera.getConfig().frigate.camera_name).toBe('fnt_dr'); + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: entityRegistryManager, + stateWatcher: mock(), + frigateEventWatcher: mock(), + }), + expect(camera.getConfig().frigate.camera_name).toBe('fnt_dr'); }); it('with a camera_entity without camera_name match', async () => { @@ -104,9 +104,13 @@ describe('FrigateCamera', () => { }); entityRegistryManager.getEntity.mockResolvedValue(entity); - await camera.initialize(createHASS(), entityRegistryManager); - - expect(camera.getConfig().frigate.camera_name).toBeUndefined(); + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: entityRegistryManager, + stateWatcher: mock(), + frigateEventWatcher: mock(), + }), + expect(camera.getConfig().frigate.camera_name).toBeUndefined(); }); it('with a camera_entity without platform match', async () => { @@ -123,9 +127,13 @@ describe('FrigateCamera', () => { }); entityRegistryManager.getEntity.mockResolvedValue(entity); - await camera.initialize(createHASS(), entityRegistryManager); - - expect(camera.getConfig().frigate.camera_name).toBeUndefined(); + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: entityRegistryManager, + stateWatcher: mock(), + frigateEventWatcher: mock(), + }), + expect(camera.getConfig().frigate.camera_name).toBeUndefined(); }); }); }); @@ -141,9 +149,13 @@ describe('FrigateCamera', () => { mock(), ); - await camera.initialize(createHASS(), mock()); - - expect(camera.getCapabilities()?.has('favorite-events')).toBeTruthy(); + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: mock(), + }), + expect(camera.getCapabilities()?.has('favorite-events')).toBeTruthy(); expect(camera.getCapabilities()?.has('favorite-recordings')).toBeFalsy(); expect(camera.getCapabilities()?.has('seek')).toBeTruthy(); expect(camera.getCapabilities()?.has('clips')).toBeTruthy(); @@ -163,9 +175,13 @@ describe('FrigateCamera', () => { mock(), ); - await camera.initialize(createHASS(), mock()); - - expect(camera.getCapabilities()?.has('favorite-events')).toBeFalsy(); + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: mock(), + }), + expect(camera.getCapabilities()?.has('favorite-events')).toBeFalsy(); expect(camera.getCapabilities()?.has('favorite-recordings')).toBeFalsy(); expect(camera.getCapabilities()?.has('seek')).toBeFalsy(); expect(camera.getCapabilities()?.has('clips')).toBeFalsy(); @@ -188,9 +204,14 @@ describe('FrigateCamera', () => { mock(), ); vi.mocked(getPTZInfo).mockRejectedValue(new Error()); - await camera.initialize(createHASS(), mock()); - expect(camera.getCapabilities()?.has('ptz')).toBeFalsy(); + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: mock(), + }), + expect(camera.getCapabilities()?.has('ptz')).toBeFalsy(); expect(camera.getCapabilities()?.hasPTZCapability()).toBeFalsy(); expect(consoleSpy).toBeCalled(); }); @@ -211,9 +232,14 @@ describe('FrigateCamera', () => { name: 'front_door', presets: ['preset01'], }); - await camera.initialize(createHASS(), mock()); - expect(camera.getCapabilities()?.has('ptz')).toBeTruthy(); + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: mock(), + }), + expect(camera.getCapabilities()?.has('ptz')).toBeTruthy(); expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ left: ['continuous'], right: ['continuous'], @@ -242,9 +268,14 @@ describe('FrigateCamera', () => { name: 'front_door', presets: ['preset01'], }); - await camera.initialize(createHASS(), mock()); - expect(camera.getCapabilities()?.has('ptz')).toBeTruthy(); + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: mock(), + }), + expect(camera.getCapabilities()?.has('ptz')).toBeTruthy(); expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ left: [], right: [], @@ -272,17 +303,19 @@ describe('FrigateCamera', () => { ); const hass = createHASS(); - await camera.initialize(hass, mock()); - - expect(hass.connection.subscribeMessage).toBeCalledWith(expect.anything(), { - type: 'subscribe_trigger', - trigger: { - platform: 'mqtt', - topic: `CLIENT_ID/events`, - payload: 'CAMERA', - value_template: '{{ value_json.after.camera }}', - }, - }); + const eventWatcher = mock(); + await camera.initialize({ + hass: hass, + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: eventWatcher, + }), + expect(eventWatcher.subscribe).toBeCalledWith( + hass, + expect.objectContaining({ + instanceID: 'CLIENT_ID', + }), + ); }); it('should not subscribe with no trigger events', async () => { @@ -300,9 +333,14 @@ describe('FrigateCamera', () => { ); const hass = createHASS(); - await camera.initialize(hass, mock()); - - expect(hass.connection.subscribeMessage).not.toBeCalled(); + const eventWatcher = mock(); + await camera.initialize({ + hass: hass, + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: eventWatcher, + }), + expect(eventWatcher.subscribe).not.toBeCalled(); }); it('should not subscribe with no camera name', async () => { @@ -316,9 +354,14 @@ describe('FrigateCamera', () => { ); const hass = createHASS(); - await camera.initialize(hass, mock()); - - expect(hass.connection.subscribeMessage).not.toBeCalled(); + const eventWatcher = mock(); + await camera.initialize({ + hass: hass, + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: eventWatcher, + }), + expect(eventWatcher.subscribe).not.toBeCalled(); }); it('should unsubscribe on destroy', async () => { @@ -332,79 +375,20 @@ describe('FrigateCamera', () => { const unsubscribeCallback = vi.fn(); vi.mocked(hass.connection.subscribeMessage).mockResolvedValue(unsubscribeCallback); - await camera.initialize(hass, mock()); - expect(unsubscribeCallback).not.toBeCalled(); + const eventWatcher = mock(); + await camera.initialize({ + hass: hass, + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: eventWatcher, + }), + expect(eventWatcher.unsubscribe).not.toBeCalled(); await camera.destroy(); - expect(unsubscribeCallback).toBeCalled(); + expect(eventWatcher.unsubscribe).toBeCalled(); }); describe('should call handler correctly', () => { - it('with malformed Frigate event', async () => { - const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined); - - const camera = new FrigateCamera( - createCameraConfig({ - frigate: { camera_name: 'front_door' }, - }), - mock(), - ); - - const hass = createHASS(); - await camera.initialize(hass, mock()); - - callHASubscribeMessageHandler(hass, 'GARBAGE'); - - expect(consoleSpy).toBeCalledWith( - 'Ignoring unparseable Frigate event', - 'GARBAGE', - ); - }); - - it('with wrong camera', async () => { - const eventCallback = vi.fn(); - const camera = new FrigateCamera( - createCameraConfig({ - id: 'CAMERA_1', - frigate: { - camera_name: 'camera.front_door', - }, - }), - mock(), - { - eventCallback: eventCallback, - }, - ); - - const hass = createHASS(); - await camera.initialize(hass, mock()); - - callHASubscribeMessageHandler( - hass, - createFrigateEventChangeTrigger( - 'new', - { - camera: 'camera.back_door', - snapshot: null, - has_clip: false, - has_snapshot: false, - label: 'person', - current_zones: [], - }, - { - camera: 'camera.back_door', - snapshot: null, - has_clip: false, - has_snapshot: true, - label: 'person', - current_zones: [], - }, - ), - ); - - expect(eventCallback).not.toBeCalled(); - }); - describe('should handle event type correctly', () => { it.each([ [ @@ -487,30 +471,33 @@ describe('FrigateCamera', () => { ); const hass = createHASS(); - await camera.initialize(hass, mock()); + const eventWatcher = mock(); + await camera.initialize({ + hass: hass, + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: eventWatcher, + }); - callHASubscribeMessageHandler( - hass, - createFrigateEventChangeTrigger( - 'new', - { - camera: 'camera.front_door', - snapshot: null, - has_clip: false, - has_snapshot: false, - label: 'person', - current_zones: [], - }, - { - camera: 'camera.front_door', - snapshot: null, - has_clip: hasClip, - has_snapshot: hasSnapshot, - label: 'person', - current_zones: [], - }, - ), - ); + callEventWatcherCallback(eventWatcher, { + type: 'new', + before: { + camera: 'camera.front_door', + snapshot: null, + has_clip: false, + has_snapshot: false, + label: 'person', + current_zones: [], + }, + after: { + camera: 'camera.front_door', + snapshot: null, + has_clip: hasClip, + has_snapshot: hasSnapshot, + label: 'person', + current_zones: [], + }, + }); if (call) { expect(eventCallback).toBeCalledWith({ @@ -549,30 +536,33 @@ describe('FrigateCamera', () => { ); const hass = createHASS(); - await camera.initialize(hass, mock()); + const eventWatcher = mock(); + await camera.initialize({ + hass: hass, + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: eventWatcher, + }); - callHASubscribeMessageHandler( - hass, - createFrigateEventChangeTrigger( - 'new', - { - camera: 'camera.front_door', - snapshot: null, - has_clip: false, - has_snapshot: false, - label: 'person', - current_zones: [], - }, - { - camera: 'camera.front_door', - snapshot: null, - has_clip: false, - has_snapshot: true, - label: 'person', - current_zones: zones, - }, - ), - ); + callEventWatcherCallback(eventWatcher, { + type: 'new', + before: { + camera: 'camera.front_door', + snapshot: null, + has_clip: false, + has_snapshot: false, + label: 'person', + current_zones: [], + }, + after: { + camera: 'camera.front_door', + snapshot: null, + has_clip: false, + has_snapshot: true, + label: 'person', + current_zones: zones, + }, + }); expect(eventCallback).toHaveBeenCalledTimes(call ? 1 : 0); }); @@ -599,32 +589,35 @@ describe('FrigateCamera', () => { ); const hass = createHASS(); - await camera.initialize(hass, mock()); + const eventWatcher = mock(); + await camera.initialize({ + hass: hass, + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: eventWatcher, + }); - callHASubscribeMessageHandler( - hass, - createFrigateEventChangeTrigger( - 'new', - { - camera: 'camera.front_door', - snapshot: null, - has_clip: false, - has_snapshot: false, - // Even new events appear to have the event label in the - // 'before' dictionary. - label: label, - current_zones: [], - }, - { - camera: 'camera.front_door', - snapshot: null, - has_clip: false, - has_snapshot: true, - label: label, - current_zones: [], - }, - ), - ); + callEventWatcherCallback(eventWatcher, { + type: 'new', + before: { + camera: 'camera.front_door', + snapshot: null, + has_clip: false, + has_snapshot: false, + // Even new events appear to have the event label in the + // 'before' dictionary. + label: label, + current_zones: [], + }, + after: { + camera: 'camera.front_door', + snapshot: null, + has_clip: false, + has_snapshot: true, + label: label, + current_zones: [], + }, + }); expect(eventCallback).toHaveBeenCalledTimes(call ? 1 : 0); }); @@ -668,8 +661,14 @@ describe('FrigateCamera', () => { }), mock(), ); + const hass = createHASS(); - await camera.initialize(hass, entityRegistryManager); + await camera.initialize({ + hass: hass, + entityRegistryManager: entityRegistryManager, + stateWatcher: mock(), + frigateEventWatcher: mock(), + }); expect(camera.getConfig().triggers.entities).toEqual([]); }); @@ -694,8 +693,14 @@ describe('FrigateCamera', () => { }), mock(), ); + const hass = createHASS(); - await camera.initialize(hass, entityRegistryManager); + await camera.initialize({ + hass: hass, + entityRegistryManager: entityRegistryManager, + stateWatcher: mock(), + frigateEventWatcher: mock(), + }); expect(camera.getConfig().triggers.entities).toEqual(['binary_sensor.foo']); }); @@ -719,8 +724,14 @@ describe('FrigateCamera', () => { }), mock(), ); + const hass = createHASS(); - await camera.initialize(hass, entityRegistryManager); + await camera.initialize({ + hass: hass, + entityRegistryManager: entityRegistryManager, + stateWatcher: mock(), + frigateEventWatcher: mock(), + }); expect(camera.getConfig().triggers.entities).toEqual(['binary_sensor.foo']); }); @@ -743,7 +754,12 @@ describe('FrigateCamera', () => { mock(), ); const hass = createHASS(); - await camera.initialize(hass, entityRegistryManager); + await camera.initialize({ + hass: hass, + entityRegistryManager: entityRegistryManager, + stateWatcher: mock(), + frigateEventWatcher: mock(), + }); expect(camera.getConfig().triggers.entities).toEqual([]); }); @@ -767,7 +783,12 @@ describe('FrigateCamera', () => { mock(), ); const hass = createHASS(); - await camera.initialize(hass, entityRegistryManager); + await camera.initialize({ + hass: hass, + entityRegistryManager: entityRegistryManager, + stateWatcher: mock(), + frigateEventWatcher: mock(), + }); expect(camera.getConfig().triggers.entities).toEqual([]); }); @@ -789,7 +810,12 @@ describe('FrigateCamera', () => { mock(), ); const hass = createHASS(); - await camera.initialize(hass, entityRegistryManager); + await camera.initialize({ + hass: hass, + entityRegistryManager: entityRegistryManager, + stateWatcher: mock(), + frigateEventWatcher: mock(), + }); expect(camera.getConfig().triggers.entities).toEqual([]); }); @@ -814,7 +840,12 @@ describe('FrigateCamera', () => { mock(), ); const hass = createHASS(); - await camera.initialize(hass, entityRegistryManager); + await camera.initialize({ + hass: hass, + entityRegistryManager: entityRegistryManager, + stateWatcher: mock(), + frigateEventWatcher: mock(), + }); expect(camera.getConfig().triggers.entities).toEqual(['binary_sensor.foo']); }); @@ -837,7 +868,12 @@ describe('FrigateCamera', () => { mock(), ); const hass = createHASS(); - await camera.initialize(hass, entityRegistryManager); + await camera.initialize({ + hass: hass, + entityRegistryManager: entityRegistryManager, + stateWatcher: mock(), + frigateEventWatcher: mock(), + }); expect(camera.getConfig().triggers.entities).toEqual([]); }); @@ -866,7 +902,12 @@ describe('FrigateCamera', () => { mock(), ); const hass = createHASS(); - await camera.initialize(hass, entityRegistryManager); + await camera.initialize({ + hass: hass, + entityRegistryManager: entityRegistryManager, + stateWatcher: mock(), + frigateEventWatcher: mock(), + }); expect(camera.getConfig().triggers.entities).toEqual(['binary_sensor.foo']); }); @@ -896,7 +937,12 @@ describe('FrigateCamera', () => { mock(), ); const hass = createHASS(); - await camera.initialize(hass, entityRegistryManager); + await camera.initialize({ + hass: hass, + entityRegistryManager: entityRegistryManager, + stateWatcher: mock(), + frigateEventWatcher: mock(), + }); expect(camera.getConfig().triggers.entities).toEqual(['binary_sensor.foo']); }); @@ -922,7 +968,12 @@ describe('FrigateCamera', () => { mock(), ); const hass = createHASS(); - await camera.initialize(hass, entityRegistryManager); + await camera.initialize({ + hass: hass, + entityRegistryManager: entityRegistryManager, + stateWatcher: mock(), + frigateEventWatcher: mock(), + }); const filterFunc = entityRegistryManager.getMatchingEntities.mock.calls[0][1]; diff --git a/tests/camera-manager/frigate/event-watcher.test.ts b/tests/camera-manager/frigate/event-watcher.test.ts new file mode 100644 index 00000000..36219baf --- /dev/null +++ b/tests/camera-manager/frigate/event-watcher.test.ts @@ -0,0 +1,201 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { FrigateEventWatcher } from '../../../src/camera-manager/frigate/event-watcher'; +import { createHASS } from '../../test-utils'; +import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; +import { FrigateEventChange } from '../../../src/camera-manager/frigate/types'; + +const createEventChange = (): FrigateEventChange => { + return { + type: 'new', + before: { + camera: 'front_door', + snapshot: null, + has_clip: false, + has_snapshot: false, + label: 'person', + current_zones: [], + }, + after: { + camera: 'front_door', + snapshot: null, + has_clip: true, + has_snapshot: true, + label: 'person', + current_zones: [], + }, + }; +}; + +const callHASubscribeMessageCallback = ( + 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); +}; + +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); + }); + + it('should only subscribe from a given topic once', async () => { + const stateWatcher = new FrigateEventWatcher(); + const hass = createHASS(); + + const unsubscribeCallback = vi.fn(); + vi.mocked(hass.connection.subscribeMessage).mockResolvedValue(unsubscribeCallback); + + 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); + }); + + 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 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 as Frigate event', + 'NOT_JSON', + ); + }); + + it('with malformed event', async () => { + const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => true); + + const stateWatcher = new FrigateEventWatcher(); + const hass = createHASS(); + + const callback = vi.fn(); + const request = { + instanceID: 'frigate', + callback: callback, + }; + + await stateWatcher.subscribe(hass, request); + const data = JSON.stringify({}); + callHASubscribeMessageCallback(hass, data); + + expect(callback).not.toBeCalled(); + expect(spy).toBeCalledWith( + 'Received malformed Frigate event from Home Assistant', + data, + ); + }); + + it('without a matcher', async () => { + const stateWatcher = new FrigateEventWatcher(); + const hass = createHASS(); + + const callback = vi.fn(); + const request = { + instanceID: 'frigate', + callback: callback, + }; + + await stateWatcher.subscribe(hass, request); + const eventChange = createEventChange(); + callHASubscribeMessageCallback(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 matching_callback = vi.fn(); + const matching_request = { + instanceID: 'frigate', + callback: matching_callback, + matcher: (event: FrigateEventChange) => event.after.camera === 'front_door', + }; + + const non_matching_callback = vi.fn(); + const non_matching_request = { + 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); + + const eventChange = createEventChange(); + callHASubscribeMessageCallback(hass, JSON.stringify(eventChange)); + + expect(non_matching_callback).not.toBeCalledWith(eventChange); + expect(matching_callback).toBeCalledWith(eventChange); + }); + }); +}); diff --git a/tests/camera-manager/generic/engine-generic.test.ts b/tests/camera-manager/generic/engine-generic.test.ts index 1acf663b..da678641 100644 --- a/tests/camera-manager/generic/engine-generic.test.ts +++ b/tests/camera-manager/generic/engine-generic.test.ts @@ -2,8 +2,8 @@ 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 { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher'; import { CameraConfig, RawFrigateCardConfig } from '../../../src/config/types'; -import { EntityRegistryManager } from '../../../src/utils/ha/entity-registry'; import { TestViewMedia, createCameraConfig, @@ -13,7 +13,7 @@ import { } from '../../test-utils'; const createEngine = (): GenericCameraManagerEngine => { - return new GenericCameraManagerEngine(); + return new GenericCameraManagerEngine(mock()); }; const createGenericCameraConfig = (config?: RawFrigateCardConfig): CameraConfig => { @@ -27,11 +27,7 @@ describe('GenericCameraManagerEngine', () => { it('should initialize camera', async () => { const config = createGenericCameraConfig(); - const camera = await createEngine().createCamera( - createHASS(), - mock(), - config, - ); + const camera = await createEngine().createCamera(createHASS(), config); expect(camera.getConfig()).toEqual(config); expect(camera.getCapabilities()).toBeTruthy(); diff --git a/tests/camera-manager/manager.test.ts b/tests/camera-manager/manager.test.ts index 71451bde..69a609c2 100644 --- a/tests/camera-manager/manager.test.ts +++ b/tests/camera-manager/manager.test.ts @@ -29,7 +29,6 @@ import { import { sortMedia } from '../../src/camera-manager/utils/sort-media'; import { CardController } from '../../src/card-controller/controller'; import { CameraConfig } from '../../src/config/types'; -import { EntityRegistryManager } from '../../src/utils/ha/entity-registry'; import { ViewMedia } from '../../src/view/media'; import { TestViewMedia, @@ -240,11 +239,7 @@ describe('CameraManager', async () => { camera.engineType === undefined ? Engine.Generic : camera.engineType; if (engineType) { vi.mocked(mockEngine.createCamera).mockImplementationOnce( - async ( - _hass: HomeAssistant, - _entityRegistryManager: EntityRegistryManager, - cameraConfig: CameraConfig, - ): Promise => + async (_hass: HomeAssistant, cameraConfig: CameraConfig): Promise => createCamera( cameraConfig, mockEngine, @@ -313,6 +308,7 @@ describe('CameraManager', async () => { 'Could not determine camera id for the following camera, ' + "may need to set 'id' parameter manually", ), + 'Camera initialization failed', ); }); @@ -338,6 +334,7 @@ describe('CameraManager', async () => { 'Duplicate Frigate camera id for the following camera, ' + "use the 'id' parameter to uniquely identify cameras", ), + 'Camera initialization failed', ); }); @@ -356,6 +353,7 @@ describe('CameraManager', async () => { expect(await manager.initializeCamerasFromConfig()).toBeFalsy(); expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith( new Error('Could not determine suitable engine for camera'), + 'Camera initialization failed', ); }); @@ -371,13 +369,13 @@ describe('CameraManager', async () => { factory, ); expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); - const triggerCallback = factory.createEngine.mock.calls[0][1]; + const eventCallback = factory.createEngine.mock.calls[0][1].eventCallback; const cameraEvent: CameraEvent = { cameraID: 'camera', type: 'new', }; - triggerCallback?.(cameraEvent); + eventCallback?.(cameraEvent); expect(api.getTriggersManager().handleCameraEvent).toBeCalledWith(cameraEvent); }); diff --git a/tests/camera-manager/store.test.ts b/tests/camera-manager/store.test.ts index 5c218ffb..13daec36 100644 --- a/tests/camera-manager/store.test.ts +++ b/tests/camera-manager/store.test.ts @@ -8,6 +8,7 @@ import { Engine } from '../../src/camera-manager/types.js'; import { EntityRegistryManager } from '../../src/utils/ha/entity-registry/index.js'; import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media.js'; import { TestViewMedia, createCameraConfig } from '../test-utils.js'; +import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js'; describe('CameraManagerStore', async () => { const configVisible = createCameraConfig({ @@ -18,13 +19,16 @@ describe('CameraManagerStore', async () => { hide: true, }); - const engineFactory = new CameraManagerEngineFactory( - mock(), - mock(), - ); + const engineFactory = new CameraManagerEngineFactory(mock()); - const engineGeneric = await engineFactory.createEngine(Engine.Generic); - const engineFrigate = await engineFactory.createEngine(Engine.Frigate); + const engineGeneric = await engineFactory.createEngine(Engine.Generic, { + stateWatcher: mock(), + resolvedMediaCache: mock(), + }); + const engineFrigate = await engineFactory.createEngine(Engine.Frigate, { + stateWatcher: mock(), + resolvedMediaCache: mock(), + }); const setupStore = (): CameraManagerStore => { const store = new CameraManagerStore(); diff --git a/tests/card-controller/card-element-manager.test.ts b/tests/card-controller/card-element-manager.test.ts index 12fced9c..0ac1d74f 100644 --- a/tests/card-controller/card-element-manager.test.ts +++ b/tests/card-controller/card-element-manager.test.ts @@ -1,7 +1,14 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { CardElementManager } from '../../src/card-controller/card-element-manager'; -import { createCardAPI, createLitElement } from '../test-utils'; +import { StateWatcher } from '../../src/card-controller/hass/state-watcher'; +import { + callStateWatcherCallback, + createCardAPI, + createConfig, + createLitElement, + createStateEntity, +} from '../test-utils'; // @vitest-environment jsdom describe('CardElementManager', () => { @@ -191,4 +198,66 @@ describe('CardElementManager', () => { expect(api.getActionsManager().uninitialize).toBeCalled(); expect(api.getInitializationManager().uninitialize).toBeCalledWith('cameras'); }); + + describe('should update card when', () => { + it('render entity changes', () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + render_entities: ['sensor.force_update'], + }, + }), + ); + + const stateWatcher = mock(); + vi.mocked(api.getHASSManager().getStateWatcher).mockReturnValue(stateWatcher); + + const element = createLitElement(); + const manager = new CardElementManager( + api, + element, + () => undefined, + () => undefined, + ); + + manager.elementConnected(); + + const diff = { + entityID: 'sensor.force_update', + newState: createStateEntity({ state: 'off' }), + }; + callStateWatcherCallback(stateWatcher, diff); + + expect(element.requestUpdate).toBeCalled(); + }); + + it('media player entity changes', () => { + const api = createCardAPI(); + vi.mocked(api.getMediaPlayerManager().getMediaPlayers).mockReturnValue([ + 'media_player.foo', + ]); + + const stateWatcher = mock(); + vi.mocked(api.getHASSManager().getStateWatcher).mockReturnValue(stateWatcher); + + const element = createLitElement(); + const manager = new CardElementManager( + api, + element, + () => undefined, + () => undefined, + ); + + manager.elementConnected(); + + const diff = { + entityID: 'sensor.force_update', + newState: createStateEntity({ state: 'off' }), + }; + callStateWatcherCallback(stateWatcher, diff); + + expect(element.requestUpdate).toBeCalled(); + }); + }); }); diff --git a/tests/card-controller/controller.test.ts b/tests/card-controller/controller.test.ts index ff5f0018..a211175e 100644 --- a/tests/card-controller/controller.test.ts +++ b/tests/card-controller/controller.test.ts @@ -14,7 +14,7 @@ import { DefaultManager } from '../../src/card-controller/default-manager'; import { DownloadManager } from '../../src/card-controller/download-manager'; import { ExpandManager } from '../../src/card-controller/expand-manager'; import { FullscreenManager } from '../../src/card-controller/fullscreen-manager'; -import { HASSManager } from '../../src/card-controller/hass-manager'; +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'; import { KeyboardStateManager } from '../../src/card-controller/keyboard-state-manager'; @@ -23,13 +23,13 @@ import { MediaPlayerManager } from '../../src/card-controller/media-player-manag import { MessageManager } from '../../src/card-controller/message-manager'; import { MicrophoneManager } from '../../src/card-controller/microphone-manager'; import { QueryStringManager } from '../../src/card-controller/query-string-manager'; +import { StatusBarItemManager } from '../../src/card-controller/status-bar-item-manager'; import { StyleManager } from '../../src/card-controller/style-manager'; import { TriggersManager } from '../../src/card-controller/triggers-manager'; import { ViewManager } from '../../src/card-controller/view/view-manager'; import { FrigateCardEditor } from '../../src/editor'; import { EntityRegistryManager } from '../../src/utils/ha/entity-registry'; import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media'; -import { StatusBarItemManager } from '../../src/card-controller/status-bar-item-manager'; vi.mock('../../src/camera-manager/manager'); vi.mock('../../src/card-controller/actions/actions-manager'); @@ -42,7 +42,7 @@ vi.mock('../../src/card-controller/default-manager'); vi.mock('../../src/card-controller/download-manager'); vi.mock('../../src/card-controller/expand-manager'); vi.mock('../../src/card-controller/fullscreen-manager'); -vi.mock('../../src/card-controller/hass-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'); diff --git a/tests/card-controller/default-manager.test.ts b/tests/card-controller/default-manager.test.ts index 5f3be314..e266f66f 100644 --- a/tests/card-controller/default-manager.test.ts +++ b/tests/card-controller/default-manager.test.ts @@ -1,12 +1,24 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { CardController } from '../../src/card-controller/controller'; import { DefaultManager } from '../../src/card-controller/default-manager'; +import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher'; import { - callHASubscribeMessageHandler, + callStateWatcherCallback, createCardAPI, createConfig, createHASS, + createStateEntity, } from '../test-utils'; +const createCardAPIWithStateWatcher = (): CardController => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getStateWatcher).mockReturnValue( + mock(), + ); + return api; +}; + // @vitest-environment jsdom describe('DefaultManager', () => { afterEach(() => { @@ -15,7 +27,7 @@ describe('DefaultManager', () => { describe('time based', () => { it('should set default view when allowed', async () => { - const api = createCardAPI(); + const api = createCardAPIWithStateWatcher(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ view: { @@ -75,7 +87,7 @@ describe('DefaultManager', () => { }); it('should restart timer when reconfigured', async () => { - const api = createCardAPI(); + const api = createCardAPIWithStateWatcher(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ view: { @@ -88,8 +100,6 @@ describe('DefaultManager', () => { vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false); const hass = createHASS(); - const unsubcribeCallback = vi.fn(); - vi.mocked(hass.connection.subscribeMessage).mockResolvedValue(unsubcribeCallback); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); vi.useFakeTimers(); @@ -99,71 +109,47 @@ describe('DefaultManager', () => { await manager.initialize(); expect(api.getViewManager().setViewDefault).not.toBeCalled(); + await manager.initialize(); + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + vi.runOnlyPendingTimers(); expect(api.getViewManager().setViewDefault).toBeCalled(); }); }); - describe('state based', () => { - it('should set default view when state changed', async () => { - const api = createCardAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - view: { - default_reset: { - every_seconds: 10, - }, - }, - }), - ); - vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false); - - const hass = createHASS(); - const unsubcribeCallback = vi.fn(); - vi.mocked(hass.connection.subscribeMessage).mockResolvedValue(unsubcribeCallback); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); - - const manager = new DefaultManager(api); - await manager.initialize(); - - callHASubscribeMessageHandler(hass, { - variables: { - trigger: { - from_state: { - entity_id: 'binary_sensor.foo', - state: 'off', - }, - to_state: { - entity_id: 'binary_sensor.foo', - state: 'on', - }, + it('should set default view when state changed', async () => { + const api = createCardAPIWithStateWatcher(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + default_reset: { + entities: ['binary_sensor.foo'], + every_seconds: 10, }, }, - }); + }), + ); + vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false); - await manager.initialize(); - expect(unsubcribeCallback).toBeCalledTimes(1); + const hass = createHASS(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); - expect(api.getViewManager().setViewDefault).toBeCalledTimes(1); + const manager = new DefaultManager(api); + await manager.initialize(); + + callStateWatcherCallback(api.getHASSManager().getStateWatcher(), { + entityID: 'binary_sensor.foo', + oldState: createStateEntity({ state: 'off' }), + newState: createStateEntity({ state: 'on' }), }); - it('should not monitor state without config', async () => { - const api = createCardAPI(); - const hass = createHASS(); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); - - const manager = new DefaultManager(api); - await manager.initialize(); - - const mock = vi.mocked(hass.connection.subscribeMessage).mock; - expect(mock.calls.length).toBe(0); - }); + expect(api.getViewManager().setViewDefault).toBeCalled(); }); describe('interaction based', () => { it('should not register automation on initialization', async () => { - const api = createCardAPI(); + const api = createCardAPIWithStateWatcher(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ @@ -182,7 +168,7 @@ describe('DefaultManager', () => { }); it('should register automation on initialization', async () => { - const api = createCardAPI(); + const api = createCardAPIWithStateWatcher(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ @@ -216,10 +202,10 @@ describe('DefaultManager', () => { ]); }); - it('should remove automation on uninitalize', async () => { - const api = createCardAPI(); + it('should remove automation on uninitalize', () => { + const api = createCardAPIWithStateWatcher(); const manager = new DefaultManager(api); - await manager.uninitialize(); + manager.uninitialize(); expect(api.getAutomationsManager().deleteAutomations).toBeCalledWith(manager); }); diff --git a/tests/card-controller/hass-manager.test.ts b/tests/card-controller/hass/hass-manager.test.ts similarity index 74% rename from tests/card-controller/hass-manager.test.ts rename to tests/card-controller/hass/hass-manager.test.ts index 6bf3bf5a..f4051d31 100644 --- a/tests/card-controller/hass-manager.test.ts +++ b/tests/card-controller/hass/hass-manager.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { CardController } from '../../src/card-controller/controller'; -import { HASSManager } from '../../src/card-controller/hass-manager'; +import { HASSManager } from '../../../src/card-controller/hass/hass-manager'; +import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; import { createCameraConfig, createCameraManager, @@ -11,13 +11,7 @@ import { createStore, createUser, createView, -} from '../test-utils'; - -const createAPIWithoutMediaPlayers = (): CardController => { - const api = createCardAPI(); - vi.mocked(api.getMediaPlayerManager().getMediaPlayers).mockReturnValue([]); - return api; -}; +} from '../../test-utils'; describe('HASSManager', () => { beforeEach(() => { @@ -29,8 +23,13 @@ describe('HASSManager', () => { expect(manager.getHASS()).toBeNull(); }); + it('should get state watcher', () => { + const manager = new HASSManager(createCardAPI()); + expect(manager.getStateWatcher()).toEqual(expect.any(StateWatcher)); + }); + it('should set light or dark mode upon setting hass', () => { - const api = createAPIWithoutMediaPlayers(); + const api = createCardAPI(); const manager = new HASSManager(api); manager.setHASS(createHASS()); @@ -40,7 +39,7 @@ describe('HASSManager', () => { describe('should set condition manager state', () => { it('positively', () => { - const api = createAPIWithoutMediaPlayers(); + const api = createCardAPI(); const manager = new HASSManager(api); vi.mocked(api.getConditionsManager().hasHAStateConditions).mockReturnValue(true); @@ -59,7 +58,7 @@ describe('HASSManager', () => { }); it('negatively', () => { - const api = createAPIWithoutMediaPlayers(); + const api = createCardAPI(); const manager = new HASSManager(api); vi.mocked(api.getConditionsManager().hasHAStateConditions).mockReturnValue(false); @@ -71,7 +70,7 @@ describe('HASSManager', () => { describe('should handle connection state change when', () => { it('initially disconnected', () => { - const api = createAPIWithoutMediaPlayers(); + const api = createCardAPI(); const manager = new HASSManager(api); const disconnectedHASS = createHASS(); @@ -90,7 +89,7 @@ describe('HASSManager', () => { }); it('disconnected', () => { - const api = createAPIWithoutMediaPlayers(); + const api = createCardAPI(); const manager = new HASSManager(api); manager.setHASS(createHASS()); @@ -110,7 +109,7 @@ describe('HASSManager', () => { }); it('reconnected', () => { - const api = createAPIWithoutMediaPlayers(); + const api = createCardAPI(); const manager = new HASSManager(api); const disconnectedHASS = createHASS(); @@ -124,7 +123,7 @@ describe('HASSManager', () => { }); it('hass is null', () => { - const api = createAPIWithoutMediaPlayers(); + const api = createCardAPI(); const manager = new HASSManager(api); const connectedHASS = createHASS(); connectedHASS.connected = true; @@ -148,7 +147,7 @@ describe('HASSManager', () => { describe('should not set default view when', () => { it('selected camera is unknown', () => { - const api = createAPIWithoutMediaPlayers(); + const api = createCardAPI(); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); vi.mocked(api.getCameraManager().getStore).mockReturnValue( createStore([ @@ -179,7 +178,7 @@ describe('HASSManager', () => { }); it('when there is card interaction', () => { - const api = createAPIWithoutMediaPlayers(); + const api = createCardAPI(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ view: { @@ -201,42 +200,4 @@ describe('HASSManager', () => { expect(api.getViewManager().setViewDefault).not.toBeCalled(); }); }); - - describe('should update card when', () => { - it('render entity changes', () => { - const api = createAPIWithoutMediaPlayers(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - view: { - render_entities: ['sensor.force_update'], - }, - }), - ); - - const manager = new HASSManager(api); - const hass = createHASS({ - 'sensor.force_update': createStateEntity(), - }); - - manager.setHASS(hass); - - expect(api.getCardElementManager().update).toBeCalled(); - }); - - it('media player entity changes', () => { - const api = createCardAPI(); - vi.mocked(api.getMediaPlayerManager().getMediaPlayers).mockReturnValue([ - 'media_player.foo', - ]); - - const manager = new HASSManager(api); - const hass = createHASS({ - 'media_player.foo': createStateEntity(), - }); - - manager.setHASS(hass); - - expect(api.getCardElementManager().update).toBeCalled(); - }); - }); }); diff --git a/tests/card-controller/hass/state-watcher.test.ts b/tests/card-controller/hass/state-watcher.test.ts new file mode 100644 index 00000000..2e6ff4e4 --- /dev/null +++ b/tests/card-controller/hass/state-watcher.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest'; +import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; +import { createHASS, createStateEntity } from '../../test-utils'; + +describe('StateWatcher', () => { + it('should not subscribe with no entities', () => { + const stateWatcher = new StateWatcher(); + const callback = vi.fn(); + expect(stateWatcher.subscribe(callback, [])).toBeFalsy(); + }); + + it('should call back with state change', () => { + const stateWatcher = new StateWatcher(); + 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' }), + }), + createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'on' }), + 'binary_sensor.bar': createStateEntity({ state: 'on' }), + }), + ); + + expect(callback).toBeCalledTimes(1); + expect(callback).toBeCalledWith( + expect.objectContaining({ + entityID: 'binary_sensor.bar', + oldState: createStateEntity({ state: 'off' }), + newState: createStateEntity({ state: 'on' }), + }), + ); + }); + + it('should not call back without state change', () => { + const stateWatcher = new StateWatcher(); + const callback = vi.fn(); + expect(stateWatcher.subscribe(callback, ['binary_sensor.foo'])).toBeTruthy(); + + stateWatcher.setHASS( + null, + createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'on' }), + }), + ); + + expect(callback).not.toBeCalled(); + + stateWatcher.setHASS( + createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'on' }), + }), + createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'on' }), + }), + ); + + expect(callback).not.toBeCalled(); + }); + + it('should not call back when unsubscribed', () => { + const stateWatcher = new StateWatcher(); + const callback = vi.fn(); + expect(stateWatcher.subscribe(callback, ['binary_sensor.foo'])).toBeTruthy(); + expect(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' }), + }), + createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'off' }), + }), + ); + + expect(callback).not.toBeCalled(); + }); +}); diff --git a/tests/card-controller/message-manager.test.ts b/tests/card-controller/message-manager.test.ts index 658943d7..38283cae 100644 --- a/tests/card-controller/message-manager.test.ts +++ b/tests/card-controller/message-manager.test.ts @@ -144,6 +144,22 @@ describe('MessageManager', () => { expect(consoleSpy).toBeCalled(); }); + it('should set error with prefix', () => { + const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined); + + const api = createCardAPI(); + const manager = new MessageManager(api); + + manager.setErrorIfHigherPriority(new Error('generic error message'), 'PREFIX'); + expect(manager.hasMessage()).toBeTruthy(); + expect(manager.getMessage()).toEqual({ + message: 'PREFIX: generic error message', + type: 'error', + }); + + expect(consoleSpy).toBeCalled(); + }); + it('should not set unknown error type', () => { const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index e5d017aa..31d705af 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -1,8 +1,4 @@ -import { - CurrentUser, - HASSDomEvent, - HomeAssistant, -} from '@dermotduffy/custom-card-helpers'; +import { CurrentUser, HASSDomEvent } from '@dermotduffy/custom-card-helpers'; import { HassEntities, HassEntity } from 'home-assistant-js-websocket'; import { LitElement } from 'lit'; import { expect, vi } from 'vitest'; @@ -29,7 +25,8 @@ import { DefaultManager } from '../src/card-controller/default-manager'; import { DownloadManager } from '../src/card-controller/download-manager'; import { ExpandManager } from '../src/card-controller/expand-manager'; import { FullscreenManager } from '../src/card-controller/fullscreen-manager'; -import { HASSManager } from '../src/card-controller/hass-manager'; +import { HASSManager } from '../src/card-controller/hass/hass-manager'; +import { StateWatcherSubscriptionInterface } from '../src/card-controller/hass/state-watcher'; import { InitializationManager } from '../src/card-controller/initialization-manager'; import { InteractionManager } from '../src/card-controller/interaction-manager'; import { KeyboardStateManager } from '../src/card-controller/keyboard-state-manager'; @@ -57,6 +54,7 @@ import { performanceConfigSchema, } from '../src/config/types'; import { CapabilitiesRaw, ExtendedHomeAssistant, MediaLoadedInfo } from '../src/types'; +import { HassStateDifference } from '../src/utils/ha'; import { EntityRegistryManager } from '../src/utils/ha/entity-registry'; import { Entity } from '../src/utils/ha/entity-registry/types'; import { ViewMedia, ViewMediaType } from '../src/view/media'; @@ -213,7 +211,11 @@ export const createStore = ( const eventCallback = cameraProps.eventCallback ?? vi.fn(); const camera = new Camera( cameraProps.config ?? createCameraConfig(), - cameraProps.engine ?? new GenericCameraManagerEngine(eventCallback), + cameraProps.engine ?? + new GenericCameraManagerEngine( + mock(), + eventCallback, + ), { capabilities: cameraProps.capabilities === undefined @@ -464,14 +466,14 @@ export const createCardAPI = (): CardController => { return api; }; -export const callHASubscribeMessageHandler = ( - hass: HomeAssistant, - ev: unknown, +export const callStateWatcherCallback = ( + stateWatcher: StateWatcherSubscriptionInterface, + diff: HassStateDifference, n = 0, ): void => { - const mock = vi.mocked(hass.connection.subscribeMessage).mock; + const mock = vi.mocked(stateWatcher.subscribe).mock; expect(mock.calls.length).greaterThan(n); - mock.calls[n][0](ev); + mock.calls[n][0](diff); }; /** diff --git a/tests/utils/basic.test.ts b/tests/utils/basic.test.ts index 79188997..81f6ff39 100644 --- a/tests/utils/basic.test.ts +++ b/tests/utils/basic.test.ts @@ -103,36 +103,40 @@ describe('contentsChanged', () => { describe('errorToConsole', () => { const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => true); + afterAll(() => { + vi.restoreAllMocks(); + }); + it('should log given error', () => { - const error = new Error(); + const error = new Error('ERROR'); errorToConsole(error); - expect(spy).toHaveBeenCalledWith(error); + expect(spy).toHaveBeenCalledWith('ERROR'); }); it('should log with context given frigate card error', () => { const data = { foo: 2 }; - const error = new FrigateCardError('foo', { foo: 2 }); + const error = new FrigateCardError('ERROR', { foo: 2 }); errorToConsole(error); expect(spy).toHaveBeenCalledWith(error, data); }); it('should log with custom function', () => { const func = vi.fn(); - const error = new Error(); + const error = new Error('ERROR'); errorToConsole(error, func); - expect(func).toHaveBeenCalledWith(error); + expect(func).toHaveBeenCalledWith('ERROR'); }); }); describe('isHoverableDevice', () => { + afterAll(() => { + vi.restoreAllMocks(); + }); + it('should return hoverable', () => { - const spy = vi - .spyOn(window, 'matchMedia') - .mockReturnValue({ matches: true }); + vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: true }); expect(isHoverableDevice()).toBeTruthy(); }); it('should return not hoverable', () => { - const spy = vi - .spyOn(window, 'matchMedia') - .mockReturnValue({ matches: false }); + vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: false }); expect(isHoverableDevice()).toBeFalsy(); }); }); @@ -225,6 +229,7 @@ describe('sleep', () => { it('should sleep', async () => { const spy = vi .spyOn(global, 'setTimeout') + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any .mockImplementation((func: () => unknown, _time?: number): any => { func(); }); diff --git a/tests/utils/ha/index.test.ts b/tests/utils/ha/index.test.ts index 8c25a4f8..823f7ead 100644 --- a/tests/utils/ha/index.test.ts +++ b/tests/utils/ha/index.test.ts @@ -1,10 +1,8 @@ import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { getEntityIcon, hasHAConnectionStateChanged, - parseStateChangeTrigger, - subscribeToTrigger, } from '../../../src/utils/ha/index.js'; import { createHASS, createStateEntity } from '../../test-utils.js'; @@ -69,105 +67,3 @@ describe('getEntityIcon', () => { expect(getEntityIcon(createHASS(), 'camera.test')).toBe('mdi:video'); }); }); - -describe('should subscribe to trigger', () => { - it('mqtt', async () => { - const hass = createHASS(); - const callback = vi.fn(); - - await subscribeToTrigger(hass, callback, { - platform: 'mqtt', - topic: 'topic', - payload: 'payload', - valueTemplate: 'value_template', - }); - - expect(hass.connection.subscribeMessage).toBeCalledWith(callback, { - type: 'subscribe_trigger', - trigger: { - platform: 'mqtt', - topic: 'topic', - payload: 'payload', - value_template: 'value_template', - }, - }); - }); - - it('state and attributes', async () => { - const hass = createHASS(); - const callback = vi.fn(); - - await subscribeToTrigger(hass, callback, { - platform: 'state', - entityID: 'camera.foo', - }); - - expect(hass.connection.subscribeMessage).toBeCalledWith(callback, { - type: 'subscribe_trigger', - trigger: { - platform: 'state', - entity_id: 'camera.foo', - }, - }); - }); - - it('state only', async () => { - const hass = createHASS(); - const callback = vi.fn(); - - await subscribeToTrigger(hass, callback, { - platform: 'state', - entityID: 'camera.foo', - stateOnly: true, - }); - - expect(hass.connection.subscribeMessage).toBeCalledWith(callback, { - type: 'subscribe_trigger', - trigger: { - platform: 'state', - entity_id: 'camera.foo', - from: null, - to: null, - }, - }); - }); -}); - -describe('should parse state change response', () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it('malformed response', async () => { - const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined); - - expect(parseStateChangeTrigger('INVALID')).toBeNull(); - - expect(consoleSpy).toBeCalledWith('Ignoring unparseable HA state change', 'INVALID'); - }); - - it('valid response', async () => { - const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined); - - const stateChange = { - from_state: { - entity_id: 'camera.foo', - state: 'off', - }, - to_state: { - entity_id: 'camera.foo', - state: 'on', - }, - }; - - expect( - parseStateChangeTrigger({ - variables: { - trigger: stateChange, - }, - }), - ).toEqual(stateChange); - - expect(consoleSpy).not.toBeCalled(); - }); -});