From b5eee5878aa9909c78c458e009e7561c86ad7f00 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Wed, 26 Mar 2025 21:26:02 -0700 Subject: [PATCH] feat: Add out of the box support for Reolink PTZ (#1982) - Closes: #1964 --- src/camera-manager/browse-media/camera.ts | 3 +- .../browse-media/engine-browse-media.ts | 2 +- src/camera-manager/camera.ts | 19 + src/camera-manager/engine-factory.ts | 2 +- src/camera-manager/engine.ts | 11 - src/camera-manager/frigate/camera.ts | 58 ++- src/camera-manager/frigate/engine-frigate.ts | 43 +- src/camera-manager/generic/engine-generic.ts | 12 - src/camera-manager/manager.ts | 20 +- src/camera-manager/reolink/camera.ts | 158 ++++++- src/camera-manager/reolink/engine-reolink.ts | 22 - src/camera-manager/utils/ptz.ts | 4 +- .../actions/actions-manager.ts | 32 +- src/card-controller/actions/actions/ptz.ts | 3 +- src/card-controller/actions/types.ts | 10 +- .../actions/utils/execution-request.ts | 8 +- src/card-controller/automations-manager.ts | 2 +- src/card-controller/controller.ts | 5 +- src/card-controller/query-string-manager.ts | 2 +- src/card-controller/types.ts | 2 +- src/components-lib/menu-controller.ts | 2 +- src/components-lib/ptz/ptz-controller.ts | 2 +- src/components-lib/status-bar-controller.ts | 2 +- src/components/menu.ts | 2 +- src/components/submenu/select-button.ts | 2 +- src/types.ts | 5 +- src/utils/ha/registry/entity/index.ts | 10 +- src/utils/ha/registry/entity/types.ts | 11 + .../browse-media/camera.test.ts | 6 +- tests/camera-manager/capabilities.test.ts | 9 +- tests/camera-manager/engine-factory.test.ts | 73 +--- tests/camera-manager/frigate/camera.test.ts | 402 ++++++++++-------- .../frigate/engine-frigate.test.ts | 106 +---- .../generic/engine-generic.test.ts | 6 - tests/camera-manager/manager.test.ts | 46 +- tests/camera-manager/reolink/camera.test.ts | 264 ++++++++++-- .../reolink/engine-reolink.test.ts | 38 +- tests/camera-manager/store.test.ts | 2 +- .../actions/actions-manager.test.ts | 37 +- .../actions/actions/ptz.test.ts | 21 +- .../automations-manager.test.ts | 6 +- tests/card-controller/controller.test.ts | 4 +- .../media-player-manager.test.ts | 37 +- .../query-string-manager.test.ts | 16 +- tests/components-lib/menu-controller.test.ts | 6 +- .../components-lib/ptz/ptz-controller.test.ts | 31 +- .../status-bar-controller.test.ts | 2 +- tests/test-utils.ts | 3 +- tests/utils/ha/registry/entity/index.test.ts | 16 +- tests/utils/ha/registry/entity/mock.ts | 47 ++ 50 files changed, 953 insertions(+), 679 deletions(-) create mode 100644 tests/utils/ha/registry/entity/mock.ts diff --git a/src/camera-manager/browse-media/camera.ts b/src/camera-manager/browse-media/camera.ts index d679642a..98a2363e 100644 --- a/src/camera-manager/browse-media/camera.ts +++ b/src/camera-manager/browse-media/camera.ts @@ -1,7 +1,6 @@ import { HomeAssistant } from '../../ha/types'; import { localize } from '../../localize/localize'; -import { EntityRegistryManager } from '../../utils/ha/registry/entity'; -import { Entity } from '../../utils/ha/registry/entity/types'; +import { Entity, EntityRegistryManager } from '../../utils/ha/registry/entity/types'; import { Camera, CameraInitializationOptions } from '../camera'; import { CameraInitializationError } from '../error'; diff --git a/src/camera-manager/browse-media/engine-browse-media.ts b/src/camera-manager/browse-media/engine-browse-media.ts index c4b46462..6514c870 100644 --- a/src/camera-manager/browse-media/engine-browse-media.ts +++ b/src/camera-manager/browse-media/engine-browse-media.ts @@ -4,7 +4,7 @@ import { HomeAssistant } from '../../ha/types'; import { canonicalizeHAURL } from '../../utils/ha'; import { BrowseMediaManager } from '../../utils/ha/browse-media/browse-media-manager'; import { BROWSE_MEDIA_CACHE_SECONDS } from '../../utils/ha/browse-media/types'; -import { EntityRegistryManager } from '../../utils/ha/registry/entity'; +import { EntityRegistryManager } from '../../utils/ha/registry/entity/types'; import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media'; import { ViewMedia } from '../../view/media'; import { RequestCache } from '../cache'; diff --git a/src/camera-manager/camera.ts b/src/camera-manager/camera.ts index 0a3692a5..c09ed5ec 100644 --- a/src/camera-manager/camera.ts +++ b/src/camera-manager/camera.ts @@ -1,4 +1,6 @@ +import { ActionsExecutor } from '../card-controller/actions/types'; import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher'; +import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz'; import { CameraConfig } from '../config/schema/cameras'; import { localize } from '../localize/localize'; import { HassStateDifference, isTriggeredState } from '../utils/ha'; @@ -6,6 +8,7 @@ import { Capabilities } from './capabilities'; import { CameraManagerEngine } from './engine'; import { CameraNoIDError } from './error'; import { CameraEventCallback, CameraProxyConfig } from './types'; +import { getConfiguredPTZAction } from './utils/ptz'; export interface CameraInitializationOptions { stateWatcher: StateWatcherSubscriptionInterface; @@ -83,6 +86,22 @@ export class Camera { }; } + public async executePTZAction( + executor: ActionsExecutor, + action: PTZAction, + options?: { + phase?: PTZActionPhase; + preset?: string; + }, + ): Promise { + const configuredAction = getConfiguredPTZAction(this.getConfig(), action, options); + if (configuredAction) { + await executor.executeActions({ actions: configuredAction }); + return true; + } + return false; + } + protected _stateChangeHandler = (difference: HassStateDifference): void => { this._eventCallback?.({ cameraID: this.getID(), diff --git a/src/camera-manager/engine-factory.ts b/src/camera-manager/engine-factory.ts index b198f7c9..00a0cfdf 100644 --- a/src/camera-manager/engine-factory.ts +++ b/src/camera-manager/engine-factory.ts @@ -3,7 +3,7 @@ import { CameraConfig } from '../config/schema/cameras'; import { HomeAssistant } from '../ha/types'; import { localize } from '../localize/localize'; import { BrowseMediaManager } from '../utils/ha/browse-media/browse-media-manager'; -import { EntityRegistryManager } from '../utils/ha/registry/entity'; +import { EntityRegistryManager } from '../utils/ha/registry/entity/types'; import { ResolvedMediaCache } from '../utils/ha/resolved-media'; import { RecordingSegmentsCache, RequestCache } from './cache'; import { CameraManagerEngine } from './engine'; diff --git a/src/camera-manager/engine.ts b/src/camera-manager/engine.ts index 66952f8b..e75b4f5b 100644 --- a/src/camera-manager/engine.ts +++ b/src/camera-manager/engine.ts @@ -1,4 +1,3 @@ -import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz'; import { CameraConfig } from '../config/schema/cameras'; import { HomeAssistant } from '../ha/types'; import { ViewMedia } from '../view/media'; @@ -128,14 +127,4 @@ export interface CameraManagerEngine { cameraConfig: CameraConfig, context?: CameraEndpointsContext, ): CameraEndpoints | null; - - executePTZAction( - hass: HomeAssistant, - cameraConfig: CameraConfig, - action: PTZAction, - options?: { - phase?: PTZActionPhase; - preset?: string; - }, - ): Promise; } diff --git a/src/camera-manager/frigate/camera.ts b/src/camera-manager/frigate/camera.ts index 90189871..af085013 100644 --- a/src/camera-manager/frigate/camera.ts +++ b/src/camera-manager/frigate/camera.ts @@ -1,12 +1,13 @@ import uniq from 'lodash-es/uniq'; +import { ActionsExecutor } from '../../card-controller/actions/types'; import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher'; +import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz'; import { CameraConfig } from '../../config/schema/cameras'; import { HomeAssistant } from '../../ha/types'; import { localize } from '../../localize/localize'; import { PTZCapabilities, PTZMovementType } from '../../types'; import { errorToConsole } from '../../utils/basic'; -import { EntityRegistryManager } from '../../utils/ha/registry/entity'; -import { Entity } from '../../utils/ha/registry/entity/types'; +import { Entity, EntityRegistryManager } from '../../utils/ha/registry/entity/types'; import { Camera, CameraInitializationOptions } from '../camera'; import { Capabilities } from '../capabilities'; import { CameraManagerEngine } from '../engine'; @@ -57,6 +58,55 @@ export class FrigateCamera extends Camera { return await super.initialize(options); } + public async executePTZAction( + executor: ActionsExecutor, + action: PTZAction, + options?: { + phase?: PTZActionPhase; + preset?: string; + }, + ): Promise { + if (await super.executePTZAction(executor, action, options)) { + return true; + } + + const cameraEntity = this.getConfig().camera_entity; + if ((action === 'preset' && !options?.preset) || !cameraEntity) { + return false; + } + + // Awkward translation between card action and service parameters: + // https://github.com/blakeblackshear/frigate-hass-integration/blob/dev/custom_components/frigate/services.yaml + await executor.executeActions({ + actions: { + action: 'perform-action', + perform_action: 'frigate.ptz', + data: { + action: + options?.phase === 'stop' + ? 'stop' + : action === 'zoom_in' || action === 'zoom_out' + ? 'zoom' + : action === 'preset' + ? 'preset' + : 'move', + ...(options?.phase !== 'stop' && { + argument: + action === 'zoom_in' + ? 'in' + : action === 'zoom_out' + ? 'out' + : action === 'preset' + ? options?.preset + : action, + }), + }, + target: { entity_id: cameraEntity }, + }, + }); + return true; + } + protected async _initializeConfig( hass: HomeAssistant, entityRegistryManager: EntityRegistryManager, @@ -194,10 +244,10 @@ export class FrigateCamera extends Camera { // Note: The Frigate integration only supports continuous PTZ movements // (regardless of the actual underlying camera capability). const panTilt: PTZMovementType[] = [ - ...(ptzInfo.features?.includes('pt') ? ['continuous' as const] : []), + ...(ptzInfo.features?.includes('pt') ? [PTZMovementType.Continuous] : []), ]; const zoom: PTZMovementType[] = [ - ...(ptzInfo.features?.includes('zoom') ? ['continuous' as const] : []), + ...(ptzInfo.features?.includes('zoom') ? [PTZMovementType.Continuous] : []), ]; const presets = ptzInfo.presets; diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index 6dfe50a1..72965902 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -4,7 +4,6 @@ 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, PTZActionPhase } from '../../config/schema/actions/custom/ptz'; import { CameraConfig } from '../../config/schema/cameras'; import { HomeAssistant } from '../../ha/types'; import { @@ -14,7 +13,7 @@ import { runWhenIdleIfSupported, } from '../../utils/basic'; import { getEntityTitle } from '../../utils/ha'; -import { EntityRegistryManager } from '../../utils/ha/registry/entity'; +import { EntityRegistryManager } from '../../utils/ha/registry/entity/types'; import { ViewMedia } from '../../view/media'; import { ViewMediaClassifier } from '../../view/media-classifier'; import { RecordingSegmentsCache, RequestCache } from '../cache'; @@ -1005,44 +1004,4 @@ export class FrigateCameraManagerEngine ...(jsmpeg && { jsmpeg: jsmpeg }), }; } - - public async executePTZAction( - hass: HomeAssistant, - cameraConfig: CameraConfig, - action: PTZAction, - options?: { - phase?: PTZActionPhase; - preset?: string; - }, - ): Promise { - const cameraEntity = cameraConfig.camera_entity; - - if (action === 'preset' && !options?.preset) { - return; - } - - // Awkward translation between card action and service parameters: - // https://github.com/blakeblackshear/frigate-hass-integration/blob/dev/custom_components/frigate/services.yaml - await hass.callService('frigate', 'ptz', { - entity_id: cameraEntity, - action: - options?.phase === 'stop' - ? 'stop' - : action === 'zoom_in' || action === 'zoom_out' - ? 'zoom' - : action === 'preset' - ? 'preset' - : 'move', - ...(options?.phase !== 'stop' && { - argument: - action === 'zoom_in' - ? 'in' - : action === 'zoom_out' - ? 'out' - : action === 'preset' - ? options?.preset - : action, - }), - }); - } } diff --git a/src/camera-manager/generic/engine-generic.ts b/src/camera-manager/generic/engine-generic.ts index ed71cf00..e0a01429 100644 --- a/src/camera-manager/generic/engine-generic.ts +++ b/src/camera-manager/generic/engine-generic.ts @@ -235,16 +235,4 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { } : null; } - - public async executePTZAction( - _hass: HomeAssistant, - _cameraConfig: CameraConfig, - _action: PTZAction, - _options?: { - phase?: PTZActionPhase; - preset?: string; - }, - ): Promise { - // Pass. - } } diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index 2788b690..77f346a2 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -55,7 +55,6 @@ import { RecordingSegmentsQueryResultsMap, ResultsMap, } from './types.js'; -import { getConfiguredPTZAction } from './utils/ptz.js'; import { sortMedia } from './utils/sort-media.js'; export class QueryClassifier { @@ -789,23 +788,12 @@ export class CameraManager { preset?: string; }, ): Promise { - const cameraConfig = this._store.getCameraConfig(cameraID); - if (!cameraConfig) { + const camera = this._store.getCamera(cameraID); + if (!camera) { return; } - const configuredAction = getConfiguredPTZAction(cameraConfig, action, options); - if (configuredAction) { - return await this._api.getActionsManager().executeActions(configuredAction); - } - - const hass = this._api.getHASSManager().getHASS(); - const engine = this._store.getEngineForCameraID(cameraID); - - if (!engine || !hass) { - return; - } - return await this._requestLimit.add(() => - engine.executePTZAction(hass, cameraConfig, action, options), + await this._requestLimit.add(() => + camera.executePTZAction(this._api.getActionsManager(), action, options), ); } } diff --git a/src/camera-manager/reolink/camera.ts b/src/camera-manager/reolink/camera.ts index 0eaf127c..ee691468 100644 --- a/src/camera-manager/reolink/camera.ts +++ b/src/camera-manager/reolink/camera.ts @@ -1,10 +1,15 @@ +import { ActionsExecutor } from '../../card-controller/actions/types'; +import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz'; import { HomeAssistant } from '../../ha/types'; import { localize } from '../../localize/localize'; -import { EntityRegistryManager } from '../../utils/ha/registry/entity'; +import { PTZCapabilities, PTZMovementType } from '../../types'; +import { Entity, EntityRegistryManager } from '../../utils/ha/registry/entity/types'; import { BrowseMediaCamera } from '../browse-media/camera'; import { Camera, CameraInitializationOptions } from '../camera'; +import { Capabilities } from '../capabilities'; import { CameraInitializationError } from '../error'; import { CameraProxyConfig } from '../types'; +import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; interface ReolinkCameraInitializationOptions extends CameraInitializationOptions { entityRegistryManager: EntityRegistryManager; @@ -13,27 +18,138 @@ interface ReolinkCameraInitializationOptions extends CameraInitializationOptions class ReolinkInitializationError extends CameraInitializationError {} +interface PTZActionToButtonEntity { + stop?: string; + left?: string; + right?: string; + up?: string; + down?: string; + zoomIn?: string; + zoomOut?: string; +} + export class ReolinkCamera extends BrowseMediaCamera { protected _channel: number | null = null; + protected _reolinkUniqueID: string | null = null; + protected _ptzButtons: PTZActionToButtonEntity | null = null; public async initialize(options: ReolinkCameraInitializationOptions): Promise { await super.initialize(options); this._initializeChannel(); + await this._initializeCapabilities(options.hass, options.entityRegistryManager); return this; } protected _initializeChannel(): void { const uniqueID = this._entity?.unique_id; - const match = uniqueID ? String(uniqueID).match(/(.*)_(?\d+)/) : null; - const channel = match && match.groups?.channel ? Number(match.groups.channel) : null; + const match = uniqueID + ? String(uniqueID).match(/(?.*)_(?\d+)/) + : null; - if (channel === null) { + const channel = match && match.groups?.channel ? Number(match.groups.channel) : null; + const reolinkUniqueID = match?.groups?.uniqueid ?? null; + + if (channel === null || reolinkUniqueID === null) { throw new ReolinkInitializationError( localize('error.camera_initialization_reolink'), this.getConfig(), ); } this._channel = channel; + this._reolinkUniqueID = reolinkUniqueID; + } + + protected async _initializeCapabilities( + hass: HomeAssistant, + entityRegistry: EntityRegistryManager, + ): Promise { + const config = this.getConfig(); + + const ptzButtonMap = await this._getPTZButtonEntities(hass, entityRegistry); + const configPTZCapabilities = getPTZCapabilitiesFromCameraConfig(this.getConfig()); + const reolinkPTZCapabilities = ptzButtonMap + ? Object.keys(ptzButtonMap).reduce( + (acc, key) => + key === 'stop' ? acc : { [key]: [PTZMovementType.Continuous], ...acc }, + {}, + ) + : null; + + const combinedPTZCapabilities: PTZCapabilities | null = + configPTZCapabilities || reolinkPTZCapabilities + ? { + ...reolinkPTZCapabilities, + ...configPTZCapabilities, + } + : null; + + this._capabilities = new Capabilities( + { + 'favorite-events': false, + 'favorite-recordings': false, + 'remote-control-entity': true, + clips: true, + live: true, + menu: true, + recordings: false, + seek: false, + snapshots: false, + substream: true, + trigger: true, + ...(combinedPTZCapabilities && { ptz: combinedPTZCapabilities }), + }, + { + disable: config.capabilities?.disable, + disableExcept: config.capabilities?.disable_except, + }, + ); + this._ptzButtons = ptzButtonMap; + } + + protected async _getPTZButtonEntities( + hass: HomeAssistant, + entityRegistry: EntityRegistryManager, + ): Promise { + /* istanbul ignore next: this path cannot be reached as an exception is + thrown in initialize() if this value is not found -- @preserve */ + if (!this._reolinkUniqueID) { + return null; + } + + const uniqueIDPrefix = `${this._reolinkUniqueID}_${this._channel}_`; + const buttonEntities = await entityRegistry.getMatchingEntities( + hass, + (ent: Entity) => + ent.config_entry_id === this._entity?.config_entry_id && + !!ent.unique_id && + String(ent.unique_id).startsWith(uniqueIDPrefix) && + !ent.disabled_by && + ent.entity_id.startsWith('button.'), + ); + + const capabilityMap = { + _ptz_stop: 'stop', + _ptz_left: 'left', + _ptz_right: 'right', + _ptz_up: 'up', + _ptz_down: 'down', + _ptz_zoom_in: 'zoomIn', + _ptz_zoom_out: 'zoomOut', + }; + + const buttonMap: PTZActionToButtonEntity = {}; + for (const buttonEntity of buttonEntities) { + for (const [uniqueIDSuffix, capability] of Object.entries(capabilityMap)) { + if ( + buttonEntity.unique_id && + String(buttonEntity.unique_id).endsWith(uniqueIDSuffix) + ) { + buttonMap[capability] = buttonEntity.entity_id; + } + } + } + + return Object.keys(buttonMap).length ? buttonMap : null; } public getChannel(): number | null { @@ -61,4 +177,38 @@ export class ReolinkCamera extends BrowseMediaCamera { : this._config.proxy.ssl_ciphers, }; } + + public async executePTZAction( + executor: ActionsExecutor, + action: PTZAction, + options?: { + phase?: PTZActionPhase; + preset?: string; + }, + ): Promise { + if (await super.executePTZAction(executor, action, options)) { + return true; + } + + const entityID = + options?.phase === 'start' + ? this._ptzButtons?.[action] + : options?.phase === 'stop' + ? this._ptzButtons?.stop + : null; + if (!entityID) { + return false; + } + + await executor.executeActions({ + actions: [ + { + action: 'perform-action', + perform_action: 'button.press', + target: { entity_id: entityID }, + }, + ], + }); + return true; + } } diff --git a/src/camera-manager/reolink/engine-reolink.ts b/src/camera-manager/reolink/engine-reolink.ts index c398ac37..50806f8a 100644 --- a/src/camera-manager/reolink/engine-reolink.ts +++ b/src/camera-manager/reolink/engine-reolink.ts @@ -17,7 +17,6 @@ import { getViewMediaFromBrowseMediaArray } from '../browse-media/utils/browse-m import { isMediaWithinDates } from '../browse-media/utils/within-dates'; import { MemoryRequestCache } from '../cache'; import { Camera } from '../camera'; -import { Capabilities } from '../capabilities'; import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine'; import { CameraManagerReadOnlyConfigStore } from '../store'; import { @@ -37,7 +36,6 @@ import { QueryResultsType, QueryReturnType, } from '../types'; -import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; import { ReolinkCamera } from './camera'; import { BrowseMediaReolinkCameraMetadata, ReolinkEventQueryResults } from './types'; @@ -144,26 +142,6 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine { cameraConfig: CameraConfig, ): Promise { const camera = new ReolinkCamera(cameraConfig, this, { - capabilities: new Capabilities( - { - 'favorite-events': false, - 'favorite-recordings': false, - 'remote-control-entity': true, - clips: true, - live: true, - menu: true, - recordings: false, - seek: false, - snapshots: false, - substream: true, - trigger: true, - ptz: getPTZCapabilitiesFromCameraConfig(cameraConfig) ?? undefined, - }, - { - disable: cameraConfig.capabilities?.disable, - disableExcept: cameraConfig.capabilities?.disable_except, - }, - ), eventCallback: this._eventCallback, }); return await camera.initialize({ diff --git a/src/camera-manager/utils/ptz.ts b/src/camera-manager/utils/ptz.ts index 74ecc796..b0764d93 100644 --- a/src/camera-manager/utils/ptz.ts +++ b/src/camera-manager/utils/ptz.ts @@ -48,8 +48,8 @@ export const getConfiguredPTZMovementType = ( return continuous || relative ? [ - ...(continuous ? ['continuous' as const] : []), - ...(relative ? ['relative' as const] : []), + ...(continuous ? [PTZMovementType.Continuous] : []), + ...(relative ? [PTZMovementType.Relative] : []), ] : null; }; diff --git a/src/card-controller/actions/actions-manager.ts b/src/card-controller/actions/actions-manager.ts index ec8b4864..895eed85 100644 --- a/src/card-controller/actions/actions-manager.ts +++ b/src/card-controller/actions/actions-manager.ts @@ -1,11 +1,9 @@ import { ActionContext } from 'action'; import { z } from 'zod'; -import { ConditionsTriggerData } from '../../conditions/types.js'; import { ActionConfig, Actions, ActionsConfig, - AuxillaryActionConfig, } from '../../config/schema/actions/types.js'; import { forwardHaptic } from '../../ha/haptic.js'; import { @@ -16,7 +14,7 @@ import { allPromises } from '../../utils/basic.js'; import { TemplateRenderer } from '../templates/index.js'; import { CardActionsManagerAPI } from '../types.js'; import { ActionSet } from './actions/set.js'; -import { ActionExecutionRequest } from './types.js'; +import { ActionsExecutionRequest, ActionsExecutor } from './types.js'; const INTERACTIONS = ['tap', 'double_tap', 'hold', 'start_tap', 'end_tap'] as const; export type InteractionName = (typeof INTERACTIONS)[number]; @@ -30,7 +28,7 @@ const interactionEventSchema = z.object({ detail: interactionSchema, }); -export class ActionsManager { +export class ActionsManager implements ActionsExecutor { protected _api: CardActionsManagerAPI; protected _actionsInFlight: ActionSet[] = []; protected _actionContext: ActionContext = {}; @@ -86,7 +84,7 @@ export class ActionsManager { // actions). actionConfig ) { - await this.executeActions(actionConfig, { config }); + await this.executeActions({ actions: actionConfig, config }); } }; @@ -113,7 +111,7 @@ export class ActionsManager { return; } - await this.executeActions(action); + await this.executeActions({ actions: action }); }; /** @@ -121,11 +119,9 @@ export class ActionsManager { * Card itself (e.g. menu, PTZ controller). */ public handleActionExecutionRequestEvent = async ( - ev: CustomEvent, + ev: CustomEvent, ): Promise => { - await this.executeActions(ev.detail.action, { - config: ev.detail.config, - }); + await this.executeActions(ev.detail); }; public async uninitialize(): Promise { @@ -133,24 +129,18 @@ export class ActionsManager { await allPromises(this._actionsInFlight, (actionSet) => actionSet.stop()); } - public async executeActions( - action: ActionConfig | ActionConfig[], - options?: { - config?: AuxillaryActionConfig; - triggerData?: ConditionsTriggerData; - }, - ): Promise { + public async executeActions(request: ActionsExecutionRequest): Promise { const hass = this._api.getHASSManager().getHASS(); const renderedAction: ActionConfig | ActionConfig[] = hass && this._templateRenderer - ? (this._templateRenderer.renderRecursively(hass, action, { + ? (this._templateRenderer.renderRecursively(hass, request.actions, { conditionState: this._api.getConditionStateManager().getState(), - triggerData: options?.triggerData, + triggerData: request?.triggerData, }) as ActionConfig | ActionConfig[]) - : action; + : request.actions; const actionSet = new ActionSet(this._actionContext, renderedAction, { - config: options?.config, + config: request.config, cardID: this._api.getConfigManager().getConfig()?.card_id, }); diff --git a/src/card-controller/actions/actions/ptz.ts b/src/card-controller/actions/actions/ptz.ts index a8e9b44e..b48fa349 100644 --- a/src/card-controller/actions/actions/ptz.ts +++ b/src/card-controller/actions/actions/ptz.ts @@ -1,4 +1,5 @@ import { PTZActionConfig } from '../../../config/schema/actions/custom/ptz'; +import { PTZMovementType } from '../../../types'; import { getPTZTarget, ptzActionToCapabilityKey } from '../../../utils/ptz'; import { Timer } from '../../../utils/timer'; import { CardActionsAPI } from '../../types'; @@ -66,7 +67,7 @@ export class PTZAction extends AdvancedCameraCardAction { if ( (capabilityKey && ptzCapabilities[capabilityKey]?.includes( - this._action.ptz_phase ? 'continuous' : 'relative', + this._action.ptz_phase ? PTZMovementType.Continuous : PTZMovementType.Relative, )) || this._action.ptz_action === 'preset' ) { diff --git a/src/card-controller/actions/types.ts b/src/card-controller/actions/types.ts index 3fac4a09..a070b673 100644 --- a/src/card-controller/actions/types.ts +++ b/src/card-controller/actions/types.ts @@ -1,3 +1,4 @@ +import { ConditionsTriggerData } from '../../conditions/types.js'; import { ActionConfig, AuxillaryActionConfig, @@ -10,9 +11,14 @@ export interface Action { stop(): Promise; } -export interface ActionExecutionRequest { - action: ActionConfig[] | ActionConfig; +export interface ActionsExecutionRequest { + actions: ActionConfig[] | ActionConfig; config?: AuxillaryActionConfig; + triggerData?: ConditionsTriggerData; +} + +export interface ActionsExecutor { + executeActions(request: ActionsExecutionRequest): Promise; } export interface TargetedActionContext { diff --git a/src/card-controller/actions/utils/execution-request.ts b/src/card-controller/actions/utils/execution-request.ts index 12a2527c..5caaba0d 100644 --- a/src/card-controller/actions/utils/execution-request.ts +++ b/src/card-controller/actions/utils/execution-request.ts @@ -1,9 +1,9 @@ import { fireAdvancedCameraCardEvent } from '../../../utils/fire-advanced-camera-card-event'; -import { ActionExecutionRequest } from '../types'; +import { ActionsExecutionRequest } from '../types'; export const dispatchActionExecutionRequest = ( element: HTMLElement, - request: ActionExecutionRequest, + request: ActionsExecutionRequest, ) => { fireAdvancedCameraCardEvent(element, 'action:execution-request', request); }; @@ -13,7 +13,7 @@ export interface ActionExecutionRequestEventTarget extends EventTarget { event: 'advanced-camera-card:action:execution-request', listener: ( this: ActionExecutionRequestEventTarget, - ev: CustomEvent, + ev: CustomEvent, ) => void, options?: AddEventListenerOptions | boolean, ): void; @@ -26,7 +26,7 @@ export interface ActionExecutionRequestEventTarget extends EventTarget { event: 'advanced-camera-card:action:execution-request', listener: ( this: ActionExecutionRequestEventTarget, - ev: CustomEvent, + ev: CustomEvent, ) => void, options?: boolean | EventListenerOptions, ): void; diff --git a/src/card-controller/automations-manager.ts b/src/card-controller/automations-manager.ts index be39fab5..4624eac6 100644 --- a/src/card-controller/automations-manager.ts +++ b/src/card-controller/automations-manager.ts @@ -75,7 +75,7 @@ export class AutomationsManager { await this._api .getActionsManager() - .executeActions(actions, { triggerData: result.triggerData }); + .executeActions({ actions, triggerData: result.triggerData }); --this._nestedAutomationExecutions; }; diff --git a/src/card-controller/controller.ts b/src/card-controller/controller.ts index d2efcb77..18e0ecde 100644 --- a/src/card-controller/controller.ts +++ b/src/card-controller/controller.ts @@ -9,8 +9,9 @@ import { } from '../utils/ha/registry/device'; import { createEntityRegistryCache, - EntityRegistryManager, + EntityRegistryManagerLive, } from '../utils/ha/registry/entity'; +import { EntityRegistryManager } from '../utils/ha/registry/entity/types'; import { ResolvedMediaCache } from '../utils/ha/resolved-media'; import { ActionsManager } from './actions/actions-manager'; import { AutomationsManager } from './automations-manager'; @@ -100,7 +101,7 @@ export class CardController protected _deviceRegistryManager = new DeviceRegistryManager( createDeviceRegistryCache(), ); - protected _entityRegistryManager = new EntityRegistryManager( + protected _entityRegistryManager = new EntityRegistryManagerLive( createEntityRegistryCache(), ); protected _resolvedMediaCache = new ResolvedMediaCache(); diff --git a/src/card-controller/query-string-manager.ts b/src/card-controller/query-string-manager.ts index fe8affa4..e3ab16d9 100644 --- a/src/card-controller/query-string-manager.ts +++ b/src/card-controller/query-string-manager.ts @@ -69,7 +69,7 @@ export class QueryStringManager { protected async _executeNonViewRelated(intent: QueryStringViewIntent): Promise { if (intent.other) { - await this._api.getActionsManager().executeActions(intent.other); + await this._api.getActionsManager().executeActions({ actions: intent.other }); } } diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index 712f19a8..c05750eb 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -1,7 +1,7 @@ import type { CameraManager } from '../camera-manager/manager'; import type { ConditionStateManager } from '../conditions/state-manager'; import type { Automation } from '../config/schema/automations'; -import type { EntityRegistryManager } from '../utils/ha/registry/entity'; +import type { EntityRegistryManager } from '../utils/ha/registry/entity/types'; import type { ResolvedMediaCache } from '../utils/ha/resolved-media'; import type { ActionsManager } from './actions/actions-manager'; import type { AutomationsManager } from './automations-manager'; diff --git a/src/components-lib/menu-controller.ts b/src/components-lib/menu-controller.ts index ed406121..73fb2632 100644 --- a/src/components-lib/menu-controller.ts +++ b/src/components-lib/menu-controller.ts @@ -116,7 +116,7 @@ export class MenuController { if (toggleLessActions.length) { dispatchActionExecutionRequest(this._host, { - action: actions, + actions: actions, config: config, }); } diff --git a/src/components-lib/ptz/ptz-controller.ts b/src/components-lib/ptz/ptz-controller.ts index 0402f99c..a220c80a 100644 --- a/src/components-lib/ptz/ptz-controller.ts +++ b/src/components-lib/ptz/ptz-controller.ts @@ -63,7 +63,7 @@ export class PTZController { const action = getActionConfigGivenAction(interaction, config); if (action) { dispatchActionExecutionRequest(this._host, { - action: action, + actions: action, ...(config && { config: config }), }); } diff --git a/src/components-lib/status-bar-controller.ts b/src/components-lib/status-bar-controller.ts index 0e84ba98..c39a3c6e 100644 --- a/src/components-lib/status-bar-controller.ts +++ b/src/components-lib/status-bar-controller.ts @@ -86,7 +86,7 @@ export class StatusBarController { } dispatchActionExecutionRequest(this._host, { - action: arrayify(action), + actions: arrayify(action), config: config, }); } diff --git a/src/components/menu.ts b/src/components/menu.ts index 2916d1c3..fd08afa9 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -9,7 +9,7 @@ import { HomeAssistant } from '../ha/types.js'; import menuStyle from '../scss/menu.scss'; import { hasAction } from '../utils/action.js'; import { getEntityTitle } from '../utils/ha/index.js'; -import { EntityRegistryManager } from '../utils/ha/registry/entity/index.js'; +import { EntityRegistryManager } from '../utils/ha/registry/entity/types.js'; import './icon.js'; import './submenu/select-button.js'; import './submenu/submenu-button'; diff --git a/src/components/submenu/select-button.ts b/src/components/submenu/select-button.ts index efa7a6ed..f603e50f 100644 --- a/src/components/submenu/select-button.ts +++ b/src/components/submenu/select-button.ts @@ -15,7 +15,7 @@ import menuButtonStyle from '../../scss/menu-button.scss'; import { Icon } from '../../types.js'; import { getEntityTitle, isHassDifferent } from '../../utils/ha'; import { getEntityStateTranslation } from '../../utils/ha/entity-state-translation.js'; -import { EntityRegistryManager } from '../../utils/ha/registry/entity/index.js'; +import { EntityRegistryManager } from '../../utils/ha/registry/entity/types.js'; import '../icon.js'; import './index.js'; diff --git a/src/types.ts b/src/types.ts index be57899f..7d0a1758 100644 --- a/src/types.ts +++ b/src/types.ts @@ -81,7 +81,10 @@ export interface CardHelpers { createCardElement(config: LovelaceCardConfig): Promise; } -export type PTZMovementType = 'relative' | 'continuous'; +export enum PTZMovementType { + Relative = 'relative', + Continuous = 'continuous', +} export interface PTZCapabilities { left?: PTZMovementType[]; diff --git a/src/utils/ha/registry/entity/index.ts b/src/utils/ha/registry/entity/index.ts index 7010f9df..3890bb6d 100644 --- a/src/utils/ha/registry/entity/index.ts +++ b/src/utils/ha/registry/entity/index.ts @@ -2,7 +2,13 @@ import { HomeAssistant } from '../../../../ha/types.js'; import { errorToConsole } from '../../../basic.js'; import { homeAssistantWSRequest } from '../../ws-request.js'; import { RegistryCache } from '../cache.js'; -import { Entity, EntityList, entityListSchema, entitySchema } from './types.js'; +import { + Entity, + EntityList, + entityListSchema, + EntityRegistryManager, + entitySchema, +} from './types.js'; export const createEntityRegistryCache = (): RegistryCache => { return new RegistryCache((entity) => entity.entity_id); @@ -12,7 +18,7 @@ export const createEntityRegistryCache = (): RegistryCache => { // as necessary. Some calls require every entity to be fetched, which may be // non-trivial in size (after which they are cached forever). -export class EntityRegistryManager { +export class EntityRegistryManagerLive implements EntityRegistryManager { protected _cache: RegistryCache; protected _fetchedEntityList = false; diff --git a/src/utils/ha/registry/entity/types.ts b/src/utils/ha/registry/entity/types.ts index 1ab16af5..e4ada98d 100644 --- a/src/utils/ha/registry/entity/types.ts +++ b/src/utils/ha/registry/entity/types.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { HomeAssistant } from '../../../../ha/types'; export const entitySchema = z.object({ config_entry_id: z.string().nullable(), @@ -17,3 +18,13 @@ export type Entity = z.infer; export const entityListSchema = entitySchema.array(); export type EntityList = z.infer; + +export interface EntityRegistryManager { + getEntity(hass: HomeAssistant, entityID: string): Promise; + getEntities(hass: HomeAssistant, entityIDs: string[]): Promise>; + getMatchingEntities( + hass: HomeAssistant, + func: (arg: Entity) => boolean, + ): Promise; + fetchEntityList(hass: HomeAssistant): Promise; +} diff --git a/tests/camera-manager/browse-media/camera.test.ts b/tests/camera-manager/browse-media/camera.test.ts index d5e76269..82583709 100644 --- a/tests/camera-manager/browse-media/camera.test.ts +++ b/tests/camera-manager/browse-media/camera.test.ts @@ -3,8 +3,10 @@ import { mock } from 'vitest-mock-extended'; import { BrowseMediaCamera } from '../../../src/camera-manager/browse-media/camera'; import { CameraManagerEngine } from '../../../src/camera-manager/engine'; import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher'; -import { EntityRegistryManager } from '../../../src/utils/ha/registry/entity'; -import { Entity } from '../../../src/utils/ha/registry/entity/types'; +import { + Entity, + EntityRegistryManager, +} from '../../../src/utils/ha/registry/entity/types'; import { createCameraConfig, createHASS } from '../../test-utils'; describe('BrowseMediaCamera', () => { diff --git a/tests/camera-manager/capabilities.test.ts b/tests/camera-manager/capabilities.test.ts index b84fc5cc..dc0df37c 100644 --- a/tests/camera-manager/capabilities.test.ts +++ b/tests/camera-manager/capabilities.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; import { Capabilities } from '../../src/camera-manager/capabilities'; -import { CapabilityKey, PTZCapabilities, capabilityKeys } from '../../src/types'; +import { + CapabilityKey, + PTZCapabilities, + PTZMovementType, + capabilityKeys, +} from '../../src/types'; describe('Capabilities', () => { it('default capabilities', () => { @@ -73,7 +78,7 @@ describe('Capabilities', () => { it('when set', () => { const ptz: PTZCapabilities = { - left: ['continuous' as const], + left: [PTZMovementType.Continuous], presets: ['1', '2'], }; const capabilities = new Capabilities({ diff --git a/tests/camera-manager/engine-factory.test.ts b/tests/camera-manager/engine-factory.test.ts index d5cf4051..ba96b725 100644 --- a/tests/camera-manager/engine-factory.test.ts +++ b/tests/camera-manager/engine-factory.test.ts @@ -8,10 +8,7 @@ import { ReolinkCameraManagerEngine } from '../../src/camera-manager/reolink/eng import { Engine } from '../../src/camera-manager/types.js'; import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js'; import { CardWideConfig } from '../../src/config/schema/types.js'; -import { - createEntityRegistryCache, - EntityRegistryManager, -} from '../../src/utils/ha/registry/entity/index.js'; +import { EntityRegistryManager } from '../../src/utils/ha/registry/entity/types.js'; import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media'; import { createCameraConfig, @@ -19,6 +16,7 @@ import { createRegistryEntity, createStateEntity, } from '../test-utils'; +import { EntityRegistryManagerMock } from '../utils/ha/registry/entity/mock.js'; vi.mock('../../src/utils/ha/entity-registry'); vi.mock('../../src/utils/ha/entity-registry/cache'); @@ -28,8 +26,7 @@ const createFactory = (options?: { cardWideConfig?: CardWideConfig; }): CameraManagerEngineFactory => { return new CameraManagerEngineFactory( - options?.entityRegistryManager ?? - new EntityRegistryManager(createEntityRegistryCache()), + options?.entityRegistryManager ?? new EntityRegistryManagerMock(), ); }; @@ -44,15 +41,9 @@ describe('getEngineForCamera()', () => { it('from auto detection', async () => { const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); - const entityRegistryManager = new EntityRegistryManager( - createEntityRegistryCache(), - ); - - entityRegistryManager.getEntity = vi - .fn() - .mockResolvedValue( - createRegistryEntity({ entity_id: 'camera.foo', platform: 'frigate' }), - ); + const entityRegistryManager = new EntityRegistryManagerMock([ + createRegistryEntity({ entity_id: 'camera.foo', platform: 'frigate' }), + ]); expect( await createFactory({ @@ -81,15 +72,9 @@ describe('getEngineForCamera()', () => { it('from auto detection', async () => { const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); - const entityRegistryManager = new EntityRegistryManager( - createEntityRegistryCache(), - ); - - entityRegistryManager.getEntity = vi - .fn() - .mockResolvedValue( - createRegistryEntity({ entity_id: 'camera.foo', platform: 'motioneye' }), - ); + const entityRegistryManager = new EntityRegistryManagerMock([ + createRegistryEntity({ entity_id: 'camera.foo', platform: 'motioneye' }), + ]); expect( await createFactory({ @@ -109,15 +94,9 @@ describe('getEngineForCamera()', () => { it('from auto detection', async () => { const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); - const entityRegistryManager = new EntityRegistryManager( - createEntityRegistryCache(), - ); - - entityRegistryManager.getEntity = vi - .fn() - .mockResolvedValue( - createRegistryEntity({ entity_id: 'camera.foo', platform: 'reolink' }), - ); + const entityRegistryManager = new EntityRegistryManagerMock([ + createRegistryEntity({ entity_id: 'camera.foo', platform: 'reolink' }), + ]); expect( await createFactory({ @@ -137,15 +116,9 @@ describe('getEngineForCamera()', () => { it('from auto detection', async () => { const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); - const entityRegistryManager = new EntityRegistryManager( - createEntityRegistryCache(), - ); - - entityRegistryManager.getEntity = vi - .fn() - .mockResolvedValue( - createRegistryEntity({ entity_id: 'camera.foo', platform: 'generic' }), - ); + const entityRegistryManager = new EntityRegistryManagerMock([ + createRegistryEntity({ entity_id: 'camera.foo', platform: 'generic' }), + ]); expect( await createFactory({ @@ -159,11 +132,7 @@ describe('getEngineForCamera()', () => { engine: 'auto', webrtc_card: { entity: 'camera.foo' }, }); - const entityRegistryManager = new EntityRegistryManager( - createEntityRegistryCache(), - ); - - entityRegistryManager.getEntity = vi.fn().mockResolvedValue(null); + const entityRegistryManager = new EntityRegistryManagerMock(); expect( await createFactory({ @@ -182,10 +151,7 @@ describe('getEngineForCamera()', () => { engine: 'auto', webrtc_card: { entity: 'camera.foo' }, }); - const entityRegistryManager = new EntityRegistryManager( - createEntityRegistryCache(), - ); - entityRegistryManager.getEntity = vi.fn().mockResolvedValue(null); + const entityRegistryManager = new EntityRegistryManagerMock(); expect( async () => @@ -225,9 +191,8 @@ describe('getEngineForCamera()', () => { it('should throw error on invalid entity', async () => { const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); - const entityRegistryManager = new EntityRegistryManager(createEntityRegistryCache()); - - entityRegistryManager.getEntity = vi.fn().mockRejectedValue(new Error()); + const entityRegistryManager = mock(); + entityRegistryManager.getEntity.mockRejectedValue(new Error()); await expect( createFactory({ diff --git a/tests/camera-manager/frigate/camera.test.ts b/tests/camera-manager/frigate/camera.test.ts index 935c40cc..c45a7ac9 100644 --- a/tests/camera-manager/frigate/camera.test.ts +++ b/tests/camera-manager/frigate/camera.test.ts @@ -5,11 +5,16 @@ 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 } from '../../../src/camera-manager/frigate/types'; +import { ActionsExecutor } from '../../../src/card-controller/actions/types'; import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; +import { PTZAction } from '../../../src/config/schema/actions/custom/ptz'; import { CameraTriggerEventType } from '../../../src/config/schema/cameras'; -import { EntityRegistryManager } from '../../../src/utils/ha/registry/entity'; -import { Entity } from '../../../src/utils/ha/registry/entity/types'; +import { + Entity, + EntityRegistryManager, +} from '../../../src/utils/ha/registry/entity/types'; import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils'; +import { EntityRegistryManagerMock } from '../../utils/ha/registry/entity/mock'; vi.mock('../../../src/camera-manager/frigate/requests'); @@ -53,8 +58,7 @@ describe('FrigateCamera', () => { }), mock(), ); - const entityRegistryManager = mock(); - entityRegistryManager.getEntity.mockResolvedValue(null); + const entityRegistryManager = new EntityRegistryManagerMock(); expect( async () => @@ -74,12 +78,13 @@ describe('FrigateCamera', () => { }), mock(), ); - const entityRegistryManager = mock(); - const entity = createRegistryEntity({ - unique_id: '8c4e19d258359e82bc0cf9d47b021c46:camera:fnt_dr', - platform: 'frigate', - }); - entityRegistryManager.getEntity.mockResolvedValue(entity); + const entityRegistryManager = new EntityRegistryManagerMock([ + createRegistryEntity({ + entity_id: 'camera.front_door', + unique_id: '8c4e19d258359e82bc0cf9d47b021c46:camera:fnt_dr', + platform: 'frigate', + }), + ]); await camera.initialize({ hass: createHASS(), @@ -97,12 +102,13 @@ describe('FrigateCamera', () => { }), mock(), ); - const entityRegistryManager = mock(); - const entity = createRegistryEntity({ - unique_id: '8c4e19d258359e82bc0cf9d47b021c46:WRONG:fnt_dr', - platform: 'frigate', - }); - entityRegistryManager.getEntity.mockResolvedValue(entity); + const entityRegistryManager = new EntityRegistryManagerMock([ + createRegistryEntity({ + entity_id: 'camera.front_door', + unique_id: '8c4e19d258359e82bc0cf9d47b021c46:WRONG:fnt_dr', + platform: 'frigate', + }), + ]); await camera.initialize({ hass: createHASS(), @@ -120,12 +126,13 @@ describe('FrigateCamera', () => { }), mock(), ); - const entityRegistryManager = mock(); - const entity = createRegistryEntity({ - unique_id: '8c4e19d258359e82bc0cf9d47b021c46:camera:fnt_dr', - platform: 'something_else', - }); - entityRegistryManager.getEntity.mockResolvedValue(entity); + const entityRegistryManager = new EntityRegistryManagerMock([ + createRegistryEntity({ + entity_id: 'camera.front_door', + unique_id: '8c4e19d258359e82bc0cf9d47b021c46:camera:fnt_dr', + platform: 'something_else', + }), + ]); await camera.initialize({ hass: createHASS(), @@ -656,6 +663,7 @@ describe('FrigateCamera', () => { describe('should handle triggers', () => { const cameraEntity: Partial = { config_entry_id: 'config_entry_id', + entity_id: 'camera.front_door', }; const occupancySensorEntityAll: Partial = { @@ -674,11 +682,8 @@ describe('FrigateCamera', () => { describe('should detect motion sensor', () => { it('without a camera name', async () => { - const entityRegistryManager = mock(); - entityRegistryManager.getEntity.mockResolvedValue( + const entityRegistryManager = new EntityRegistryManagerMock([ createRegistryEntity(cameraEntity), - ); - entityRegistryManager.getMatchingEntities.mockResolvedValue([ createRegistryEntity(motionSensorEntity), ]); const camera = new FrigateCamera( @@ -702,11 +707,8 @@ describe('FrigateCamera', () => { }); it('with camera entity and name', async () => { - const entityRegistryManager = mock(); - entityRegistryManager.getEntity.mockResolvedValue( + const entityRegistryManager = new EntityRegistryManagerMock([ createRegistryEntity(cameraEntity), - ); - entityRegistryManager.getMatchingEntities.mockResolvedValue([ createRegistryEntity(motionSensorEntity), ]); const camera = new FrigateCamera( @@ -733,43 +735,7 @@ describe('FrigateCamera', () => { expect(camera.getConfig().triggers.entities).toEqual(['binary_sensor.foo']); }); - it('with matching entity', async () => { - const entityRegistryManager = mock(); - entityRegistryManager.getEntity.mockResolvedValue( - createRegistryEntity(cameraEntity), - ); - entityRegistryManager.getMatchingEntities.mockResolvedValue([ - createRegistryEntity(motionSensorEntity), - ]); - const camera = new FrigateCamera( - createCameraConfig({ - frigate: { - camera_name: 'front_door', - }, - triggers: { - motion: true, - }, - }), - mock(), - ); - - const hass = createHASS(); - await camera.initialize({ - hass: hass, - entityRegistryManager: entityRegistryManager, - stateWatcher: mock(), - frigateEventWatcher: mock(), - }); - - expect(camera.getConfig().triggers.entities).toEqual(['binary_sensor.foo']); - }); - it('without matching entity', async () => { - const entityRegistryManager = mock(); - entityRegistryManager.getEntity.mockResolvedValue( - createRegistryEntity(cameraEntity), - ); - entityRegistryManager.getMatchingEntities.mockResolvedValue([]); const camera = new FrigateCamera( createCameraConfig({ frigate: { @@ -784,7 +750,7 @@ describe('FrigateCamera', () => { const hass = createHASS(); await camera.initialize({ hass: hass, - entityRegistryManager: entityRegistryManager, + entityRegistryManager: new EntityRegistryManagerMock(), stateWatcher: mock(), frigateEventWatcher: mock(), }); @@ -795,11 +761,8 @@ describe('FrigateCamera', () => { describe('should detect occupancy sensor', () => { it('without a camera name', async () => { - const entityRegistryManager = mock(); - entityRegistryManager.getEntity.mockResolvedValue( + const entityRegistryManager = new EntityRegistryManagerMock([ createRegistryEntity(cameraEntity), - ); - entityRegistryManager.getMatchingEntities.mockResolvedValue([ createRegistryEntity(occupancySensorEntityAll), ]); const camera = new FrigateCamera( @@ -822,11 +785,8 @@ describe('FrigateCamera', () => { }); it('without a camera name but with occupancy trigger', async () => { - const entityRegistryManager = mock(); - entityRegistryManager.getEntity.mockResolvedValue( + const entityRegistryManager = new EntityRegistryManagerMock([ createRegistryEntity(cameraEntity), - ); - entityRegistryManager.getMatchingEntities.mockResolvedValue([ createRegistryEntity(occupancySensorEntityAll), ]); const camera = new FrigateCamera( @@ -848,42 +808,7 @@ describe('FrigateCamera', () => { expect(camera.getConfig().triggers.entities).toEqual([]); }); - it('with matching entity', async () => { - const entityRegistryManager = mock(); - entityRegistryManager.getEntity.mockResolvedValue( - createRegistryEntity(cameraEntity), - ); - entityRegistryManager.getMatchingEntities.mockResolvedValue([ - createRegistryEntity(occupancySensorEntityAll), - ]); - const camera = new FrigateCamera( - createCameraConfig({ - frigate: { - camera_name: 'front_door', - }, - triggers: { - occupancy: true, - }, - }), - mock(), - ); - const hass = createHASS(); - await camera.initialize({ - hass: hass, - entityRegistryManager: entityRegistryManager, - stateWatcher: mock(), - frigateEventWatcher: mock(), - }); - - expect(camera.getConfig().triggers.entities).toEqual(['binary_sensor.foo']); - }); - it('without matching entity', async () => { - const entityRegistryManager = mock(); - entityRegistryManager.getEntity.mockResolvedValue( - createRegistryEntity(cameraEntity), - ); - entityRegistryManager.getMatchingEntities.mockResolvedValue([]); const camera = new FrigateCamera( createCameraConfig({ frigate: { @@ -898,7 +823,7 @@ describe('FrigateCamera', () => { const hass = createHASS(); await camera.initialize({ hass: hass, - entityRegistryManager: entityRegistryManager, + entityRegistryManager: new EntityRegistryManagerMock(), stateWatcher: mock(), frigateEventWatcher: mock(), }); @@ -907,11 +832,8 @@ describe('FrigateCamera', () => { }); it('with zones', async () => { - const entityRegistryManager = mock(); - entityRegistryManager.getEntity.mockResolvedValue( + const entityRegistryManager = new EntityRegistryManagerMock([ createRegistryEntity(cameraEntity), - ); - entityRegistryManager.getMatchingEntities.mockResolvedValue([ createRegistryEntity({ ...occupancySensorEntityAll, unique_id: '8c4e19d258359e82bc0cf9d47b021c46:occupancy_sensor:zone_all', @@ -919,6 +841,7 @@ describe('FrigateCamera', () => { ]); const camera = new FrigateCamera( createCameraConfig({ + camera_entity: 'camera.front_door', frigate: { camera_name: 'front_door', zones: ['zone'], @@ -941,19 +864,18 @@ describe('FrigateCamera', () => { }); it('with labels', async () => { - const entityRegistryManager = mock(); - entityRegistryManager.getEntity.mockResolvedValue( + const entityRegistryManager = new EntityRegistryManagerMock([ createRegistryEntity(cameraEntity), - ); - entityRegistryManager.getMatchingEntities.mockResolvedValue([ createRegistryEntity({ ...occupancySensorEntityAll, unique_id: '8c4e19d258359e82bc0cf9d47b021c46:occupancy_sensor:front_door_car', }), ]); + const camera = new FrigateCamera( createCameraConfig({ + camera_entity: 'camera.front_door', frigate: { camera_name: 'front_door', labels: ['car'], @@ -976,74 +898,194 @@ describe('FrigateCamera', () => { }); }); - it('should filter entities with correct function', async () => { - const entityRegistryManager = mock(); - entityRegistryManager.getEntity.mockResolvedValue( - createRegistryEntity(cameraEntity), - ); - entityRegistryManager.getMatchingEntities.mockResolvedValue([ - createRegistryEntity({ - ...occupancySensorEntityAll, - }), - ]); - const camera = new FrigateCamera( - createCameraConfig({ - camera_entity: 'camera.foo', - triggers: { - occupancy: true, - }, - }), - mock(), - ); - const hass = createHASS(); - await camera.initialize({ - hass: hass, - entityRegistryManager: entityRegistryManager, - stateWatcher: mock(), - frigateEventWatcher: mock(), + describe('should execute PTZ action', () => { + it('should ignore preset action without a preset', async () => { + const camera = new FrigateCamera( + createCameraConfig(), + mock(), + ); + + const hass = createHASS(); + await camera.initialize({ + hass: hass, + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: mock(), + }); + + const executor = mock(); + await camera.executePTZAction(executor, 'preset'); + + expect(executor.executeActions).not.toBeCalled(); }); - const filterFunc = entityRegistryManager.getMatchingEntities.mock.calls[0][1]; - - expect( - filterFunc( - createRegistryEntity({ - config_entry_id: cameraEntity.config_entry_id, - disabled_by: '', - entity_id: 'binary_sensor.foo', + it('should ignore actions with configured action', async () => { + const camera = new FrigateCamera( + createCameraConfig({ + camera_entity: 'camera.office_frigate', + ptz: { + actions_left_start: { + action: 'perform-action', + perform_action: 'button.press', + target: { + entity_id: 'button.foo', + }, + }, + }, }), - ), - ).toBeTruthy(); + mock(), + ); - expect( - filterFunc( - createRegistryEntity({ - config_entry_id: cameraEntity.config_entry_id, - disabled_by: 'user', - entity_id: 'binary_sensor.foo', - }), - ), - ).toBeFalsy(); + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([ + createRegistryEntity({ entity_id: 'camera.office_frigate' }), + ]), + stateWatcher: mock(), + frigateEventWatcher: mock(), + }); - expect( - filterFunc( - createRegistryEntity({ - config_entry_id: cameraEntity.config_entry_id, - disabled_by: '', - entity_id: 'camera.is_not_a_binary_sensor', - }), - ), - ).toBeFalsy(); + const executor = mock(); + await camera.executePTZAction(executor, 'left', { phase: 'start' }); - expect( - filterFunc( - createRegistryEntity({ - config_entry_id: 'not_a_matching_config_entry_id', - disabled_by: '', - entity_id: 'binary_sensor.foo', + expect(executor.executeActions).toBeCalledTimes(1); + expect(executor.executeActions).toHaveBeenLastCalledWith({ + actions: { + action: 'perform-action', + perform_action: 'button.press', + target: { + entity_id: 'button.foo', + }, + }, + }); + }); + + it.each([ + ['left' as const], + ['right' as const], + ['up' as const], + ['down' as const], + ])('should execute action %s', async (action: PTZAction) => { + const camera = new FrigateCamera( + createCameraConfig({ + camera_entity: 'camera.office_frigate', }), - ), - ).toBeFalsy(); + mock(), + ); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([ + createRegistryEntity({ entity_id: 'camera.office_frigate' }), + ]), + stateWatcher: mock(), + frigateEventWatcher: mock(), + }); + + const executor = mock(); + + await camera.executePTZAction(executor, action, { phase: 'start' }); + expect(executor.executeActions).toHaveBeenLastCalledWith({ + actions: { + action: 'perform-action', + data: { + action: 'move', + argument: action, + }, + perform_action: 'frigate.ptz', + target: { + entity_id: 'camera.office_frigate', + }, + }, + }); + + await camera.executePTZAction(executor, action, { phase: 'stop' }); + expect(executor.executeActions).toHaveBeenLastCalledWith({ + actions: { + action: 'perform-action', + data: { + action: 'stop', + }, + perform_action: 'frigate.ptz', + target: { + entity_id: 'camera.office_frigate', + }, + }, + }); + }); + + it.each([ + ['zoom_in' as const, 'in' as const], + ['zoom_out' as const, 'out' as const], + ])('should execute action %s', async (action: PTZAction, zoom: 'in' | 'out') => { + const camera = new FrigateCamera( + createCameraConfig({ + camera_entity: 'camera.office_frigate', + }), + mock(), + ); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([ + createRegistryEntity({ entity_id: 'camera.office_frigate' }), + ]), + stateWatcher: mock(), + frigateEventWatcher: mock(), + }); + + const executor = mock(); + + await camera.executePTZAction(executor, action, { phase: 'start' }); + expect(executor.executeActions).toHaveBeenLastCalledWith({ + actions: { + action: 'perform-action', + data: { + action: 'zoom', + argument: zoom, + }, + perform_action: 'frigate.ptz', + target: { + entity_id: 'camera.office_frigate', + }, + }, + }); + }); + + it('should execute preset', async () => { + const camera = new FrigateCamera( + createCameraConfig({ + camera_entity: 'camera.office_frigate', + }), + mock(), + ); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([ + createRegistryEntity({ entity_id: 'camera.office_frigate' }), + ]), + stateWatcher: mock(), + frigateEventWatcher: mock(), + }); + + const executor = mock(); + + await camera.executePTZAction(executor, 'preset', { preset: 'foo' }); + expect(executor.executeActions).toHaveBeenLastCalledWith({ + actions: { + action: 'perform-action', + data: { + action: 'preset', + argument: 'foo', + }, + perform_action: 'frigate.ptz', + target: { + entity_id: 'camera.office_frigate', + }, + }, + }); + }); }); }); }); diff --git a/tests/camera-manager/frigate/engine-frigate.test.ts b/tests/camera-manager/frigate/engine-frigate.test.ts index 7cf0ea85..77cdb5d0 100644 --- a/tests/camera-manager/frigate/engine-frigate.test.ts +++ b/tests/camera-manager/frigate/engine-frigate.test.ts @@ -7,20 +7,16 @@ import { } from '../../../src/camera-manager/frigate/media'; import { FrigateEvent, eventSchema } from '../../../src/camera-manager/frigate/types.js'; import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; -import { PTZAction } from '../../../src/config/schema/actions/custom/ptz'; import { CameraConfig } from '../../../src/config/schema/cameras'; import { AdvancedCameraCardView } from '../../../src/config/schema/common/const'; import { RawAdvancedCameraCardConfig } from '../../../src/config/types'; -import { - EntityRegistryManager, - createEntityRegistryCache, -} from '../../../src/utils/ha/registry/entity'; import { ViewMedia } from '../../../src/view/media'; import { TestViewMedia, createCameraConfig, createHASS } from '../../test-utils'; +import { EntityRegistryManagerMock } from '../../utils/ha/registry/entity/mock'; const createEngine = (): FrigateCameraManagerEngine => { return new FrigateCameraManagerEngine( - new EntityRegistryManager(createEntityRegistryCache()), + new EntityRegistryManagerMock(), new StateWatcher(), new RecordingSegmentsCache(), new RequestCache(), @@ -401,101 +397,3 @@ describe('getCameraEndpoints', () => { }); }); }); - -describe('executePTZAction', () => { - describe('preset', () => { - it('should reject without preset argument', () => { - const hass = createHASS(); - const cameraConfig = createCameraConfig({ camera_entity: 'camera.office' }); - - createEngine().executePTZAction(hass, cameraConfig, 'preset'); - - expect(hass.callService).not.toBeCalled(); - }); - - it('should succeed', () => { - const hass = createHASS(); - const cameraConfig = createCameraConfig({ camera_entity: 'camera.office' }); - - createEngine().executePTZAction(hass, cameraConfig, 'preset', { - preset: 'preset-foo', - }); - - expect(hass.callService).toBeCalledWith('frigate', 'ptz', { - entity_id: 'camera.office', - action: 'preset', - argument: 'preset-foo', - }); - }); - }); - - describe('zoom', () => { - describe.each([['zoom_in' as const], ['zoom_out' as const]])( - '%s', - (actionName: PTZAction) => { - it('start', () => { - const hass = createHASS(); - const cameraConfig = createCameraConfig({ camera_entity: 'camera.office' }); - - createEngine().executePTZAction(hass, cameraConfig, actionName); - - expect(hass.callService).toBeCalledWith('frigate', 'ptz', { - entity_id: 'camera.office', - action: 'zoom', - argument: actionName === 'zoom_in' ? 'in' : 'out', - }); - }); - - it('stop', () => { - const hass = createHASS(); - const cameraConfig = createCameraConfig({ camera_entity: 'camera.office' }); - - createEngine().executePTZAction(hass, cameraConfig, actionName, { - phase: 'stop', - }); - - expect(hass.callService).toBeCalledWith('frigate', 'ptz', { - entity_id: 'camera.office', - action: 'stop', - }); - }); - }, - ); - }); - - describe('move', () => { - describe.each([ - ['left' as const], - ['right' as const], - ['up' as const], - ['down' as const], - ])('%s', (actionName: PTZAction) => { - it('start', () => { - const hass = createHASS(); - const cameraConfig = createCameraConfig({ camera_entity: 'camera.office' }); - - createEngine().executePTZAction(hass, cameraConfig, actionName); - - expect(hass.callService).toBeCalledWith('frigate', 'ptz', { - entity_id: 'camera.office', - action: 'move', - argument: actionName, - }); - }); - - it('stop', () => { - const hass = createHASS(); - const cameraConfig = createCameraConfig({ camera_entity: 'camera.office' }); - - createEngine().executePTZAction(hass, cameraConfig, actionName, { - phase: 'stop', - }); - - expect(hass.callService).toBeCalledWith('frigate', 'ptz', { - entity_id: 'camera.office', - action: 'stop', - }); - }); - }); - }); -}); diff --git a/tests/camera-manager/generic/engine-generic.test.ts b/tests/camera-manager/generic/engine-generic.test.ts index bf015bb5..9fb89297 100644 --- a/tests/camera-manager/generic/engine-generic.test.ts +++ b/tests/camera-manager/generic/engine-generic.test.ts @@ -331,10 +331,4 @@ describe('GenericCameraManagerEngine', () => { }); }); }); - - it('should execute PTZ action', () => { - const hass = createHASS(); - createEngine().executePTZAction(hass, createCameraConfig(), 'left'); - expect(hass.callService).not.toBeCalled(); - }); }); diff --git a/tests/camera-manager/manager.test.ts b/tests/camera-manager/manager.test.ts index 37860d72..10bec801 100644 --- a/tests/camera-manager/manager.test.ts +++ b/tests/camera-manager/manager.test.ts @@ -29,6 +29,7 @@ import { sortMedia } from '../../src/camera-manager/utils/sort-media.js'; import { CardController } from '../../src/card-controller/controller.js'; import { CameraConfig } from '../../src/config/schema/cameras.js'; import { HomeAssistant } from '../../src/ha/types.js'; +import { PTZMovementType } from '../../src/types.js'; import { ViewMedia } from '../../src/view/media.js'; import { TestViewMedia, @@ -1069,7 +1070,7 @@ describe('CameraManager', async () => { snapshots: true, ptz: { - left: ['continuous'], + left: [PTZMovementType.Continuous], }, }), }, @@ -1098,25 +1099,10 @@ describe('CameraManager', async () => { manager.executePTZAction('id', 'left', {}); - expect(engine.executePTZAction).not.toBeCalled(); + // No visible action. }); - it('without hass', async () => { - const api = createCardAPI(); - const engine = mock(); - const manager = createCameraManager(api, engine); - - const hass = createHASS(); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); - - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null); - manager.executePTZAction('id', 'left'); - - expect(engine.executePTZAction).not.toBeCalled(); - }); - - it('successfully from config', async () => { + it('successfully', async () => { const api = createCardAPI(); const engine = mock(); const hass = createHASS(); @@ -1140,29 +1126,7 @@ describe('CameraManager', async () => { manager.executePTZAction('another', 'left'); - expect(api.getActionsManager().executeActions).toBeCalledWith(action); - expect(engine.executePTZAction).not.toBeCalled(); - }); - - it('successfully from engine', async () => { - const api = createCardAPI(); - const engine = mock(); - const hass = createHASS(); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); - const manager = createCameraManager(api, engine); - - expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); - - const action = 'left'; - const options = {}; - manager.executePTZAction('id', action, options); - - expect(engine.executePTZAction).toBeCalledWith( - hass, - expect.anything(), - action, - options, - ); + expect(api.getActionsManager().executeActions).toBeCalledWith({ actions: action }); }); }); diff --git a/tests/camera-manager/reolink/camera.test.ts b/tests/camera-manager/reolink/camera.test.ts index 2dd662d7..818cacda 100644 --- a/tests/camera-manager/reolink/camera.test.ts +++ b/tests/camera-manager/reolink/camera.test.ts @@ -3,12 +3,79 @@ import { mock } from 'vitest-mock-extended'; import { CameraManagerEngine } from '../../../src/camera-manager/engine'; import { ReolinkCamera } from '../../../src/camera-manager/reolink/camera'; import { CameraProxyConfig } from '../../../src/camera-manager/types'; +import { ActionsExecutor } from '../../../src/card-controller/actions/types'; import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; import { ProxyConfig } from '../../../src/config/schema/cameras'; -import { EntityRegistryManager } from '../../../src/utils/ha/registry/entity'; +import { EntityRegistryManagerLive } from '../../../src/utils/ha/registry/entity'; import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils'; +import { EntityRegistryManagerMock } from '../../utils/ha/registry/entity/mock'; describe('ReolinkCamera', () => { + const cameraEntity = createRegistryEntity({ + entity_id: 'camera.office_reolink', + unique_id: '85270002TS7D4RUP_0_main', + platform: 'reolink', + }); + const buttonEntityPTZLeft = createRegistryEntity({ + entity_id: 'button.office_reolink_ptz_left', + unique_id: '85270002TS7D4RUP_0_ptz_left', + platform: 'reolink', + }); + const buttonEntityPTZRight = createRegistryEntity({ + entity_id: 'button.office_reolink_ptz_right', + unique_id: '85270002TS7D4RUP_0_ptz_right', + platform: 'reolink', + }); + const buttonEntityPTZUp = createRegistryEntity({ + entity_id: 'button.office_reolink_ptz_up', + unique_id: '85270002TS7D4RUP_0_ptz_up', + platform: 'reolink', + }); + const buttonEntityPTZDown = createRegistryEntity({ + entity_id: 'button.office_reolink_ptz_down', + unique_id: '85270002TS7D4RUP_0_ptz_down', + platform: 'reolink', + }); + const buttonEntityPTZZoomIn = createRegistryEntity({ + entity_id: 'button.office_reolink_ptz_zoom_in', + unique_id: '85270002TS7D4RUP_0_ptz_zoom_in', + platform: 'reolink', + }); + const buttonEntityPTZZoomOut = createRegistryEntity({ + entity_id: 'button.office_reolink_ptz_zoom_out', + unique_id: '85270002TS7D4RUP_0_ptz_zoom_out', + platform: 'reolink', + }); + const buttonEntityPTZStop = createRegistryEntity({ + entity_id: 'button.office_reolink_ptz_stop', + unique_id: '85270002TS7D4RUP_0_ptz_stop', + platform: 'reolink', + }); + + const ptzPopulatedEntityRegistryManager = new EntityRegistryManagerMock([ + cameraEntity, + buttonEntityPTZLeft, + buttonEntityPTZRight, + buttonEntityPTZUp, + buttonEntityPTZDown, + buttonEntityPTZZoomIn, + buttonEntityPTZZoomOut, + buttonEntityPTZStop, + + // Unrelated button. + createRegistryEntity({ + entity_id: 'button.office_reolink_ptz_foo', + unique_id: '85270002TS7D4RUP_0_ptz_foo', + platform: 'reolink', + }), + + // Unrelated button without unique_id. + createRegistryEntity({ + entity_id: 'button.office_reolink_ptz_bar', + platform: 'reolink', + }), + ]); + describe('should initialize config', () => { describe('should detect channel', () => { it('without a camera_entity', async () => { @@ -19,7 +86,7 @@ describe('ReolinkCamera', () => { async () => await camera.initialize({ hass: createHASS(), - entityRegistryManager: mock(), + entityRegistryManager: mock(), stateWatcher: mock(), }), ).rejects.toThrowError('Could not find camera entity'); @@ -31,17 +98,18 @@ describe('ReolinkCamera', () => { }); const camera = new ReolinkCamera(config, mock()); - const entityRegistryManager = mock(); - const entity = createRegistryEntity({ - platform: 'reolink', - }); - entityRegistryManager.getEntity.mockResolvedValue(entity); + const entityRegistryManager = new EntityRegistryManagerMock([ + createRegistryEntity({ + entity_id: 'camera.office_reolink', + platform: 'reolink', + }), + ]); expect( async () => await camera.initialize({ hass: createHASS(), - entityRegistryManager: entityRegistryManager, + entityRegistryManager, stateWatcher: mock(), }), ).rejects.toThrowError('Could not initialize Reolink camera'); @@ -52,19 +120,19 @@ describe('ReolinkCamera', () => { camera_entity: 'camera.office_reolink', }); const camera = new ReolinkCamera(config, mock()); - - const entityRegistryManager = mock(); - const entity = createRegistryEntity({ - unique_id: 'invalid', - platform: 'reolink', - }); - entityRegistryManager.getEntity.mockResolvedValue(entity); + const entityRegistryManager = new EntityRegistryManagerMock([ + createRegistryEntity({ + entity_id: 'camera.office_reolink', + unique_id: 'invalid', + platform: 'reolink', + }), + ]); expect( async () => await camera.initialize({ hass: createHASS(), - entityRegistryManager: entityRegistryManager, + entityRegistryManager, stateWatcher: mock(), }), ).rejects.toThrowError('Could not initialize Reolink camera'); @@ -76,22 +144,71 @@ describe('ReolinkCamera', () => { camera_entity: 'camera.office_reolink', }); const camera = new ReolinkCamera(config, mock()); - - const entityRegistryManager = mock(); - const entity = createRegistryEntity({ - unique_id: '85270002TS7D4RUP_0_main', - platform: 'reolink', - }); - entityRegistryManager.getEntity.mockResolvedValue(entity); + const entityRegistryManager = new EntityRegistryManagerMock([cameraEntity]); await camera.initialize({ hass: createHASS(), - entityRegistryManager: entityRegistryManager, + entityRegistryManager, stateWatcher: mock(), }); expect(camera.getChannel()).toBe(0); }); + + describe('successfully with PTZ', () => { + it('should find PTZ button entities', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.office_reolink', + }); + const camera = new ReolinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: ptzPopulatedEntityRegistryManager, + stateWatcher: mock(), + }); + + expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ + left: ['continuous'], + right: ['continuous'], + up: ['continuous'], + down: ['continuous'], + zoomIn: ['continuous'], + zoomOut: ['continuous'], + }); + }); + + it('should allow configured PTZ actions to override', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.office_reolink', + ptz: { + actions_left: { + action: 'perform-action', + perform_action: 'homeassistant.toggle', + target: { + entity_id: 'switch.camera_move_left', + }, + }, + }, + }); + const camera = new ReolinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: ptzPopulatedEntityRegistryManager, + stateWatcher: mock(), + }); + + expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ + left: ['relative'], + right: ['continuous'], + up: ['continuous'], + down: ['continuous'], + zoomIn: ['continuous'], + zoomOut: ['continuous'], + }); + }); + }); }); describe('should get proxy config', () => { @@ -213,4 +330,103 @@ describe('ReolinkCamera', () => { }, ); }); + + describe('should execute PTZ action', () => { + it('should ignore actions without matching button', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.office_reolink', + }); + const camera = new ReolinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]), + stateWatcher: mock(), + }); + const executor = mock(); + + await camera.executePTZAction(executor, 'left'); + await camera.executePTZAction(executor, 'left', { phase: 'start' }); + + expect(executor.executeActions).not.toBeCalled(); + }); + + it('should ignore actions with configured action', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.office_reolink', + ptz: { + actions_left_start: { + action: 'perform-action', + perform_action: 'button.press', + target: { + entity_id: 'button.foo', + }, + }, + }, + }); + const camera = new ReolinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([ + cameraEntity, + buttonEntityPTZLeft, + ]), + stateWatcher: mock(), + }); + const executor = mock(); + await camera.executePTZAction(executor, 'left', { phase: 'start' }); + + expect(executor.executeActions).toBeCalledTimes(1); + expect(executor.executeActions).toHaveBeenLastCalledWith({ + actions: { + action: 'perform-action', + perform_action: 'button.press', + target: { + entity_id: 'button.foo', + }, + }, + }); + }); + + it('should execute action with matching button', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.office_reolink', + }); + const camera = new ReolinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: ptzPopulatedEntityRegistryManager, + stateWatcher: mock(), + }); + const executor = mock(); + + await camera.executePTZAction(executor, 'left', { phase: 'start' }); + expect(executor.executeActions).toHaveBeenLastCalledWith({ + actions: [ + { + action: 'perform-action', + perform_action: 'button.press', + target: { + entity_id: 'button.office_reolink_ptz_left', + }, + }, + ], + }); + + await camera.executePTZAction(executor, 'left', { phase: 'stop' }); + expect(executor.executeActions).toHaveBeenLastCalledWith({ + actions: [ + { + action: 'perform-action', + perform_action: 'button.press', + target: { + entity_id: 'button.office_reolink_ptz_stop', + }, + }, + ], + }); + }); + }); }); diff --git a/tests/camera-manager/reolink/engine-reolink.test.ts b/tests/camera-manager/reolink/engine-reolink.test.ts index a4099502..6bd3c5c1 100644 --- a/tests/camera-manager/reolink/engine-reolink.test.ts +++ b/tests/camera-manager/reolink/engine-reolink.test.ts @@ -29,7 +29,7 @@ import { BrowseMedia, browseMediaSchema, } from '../../../src/utils/ha/browse-media/types'; -import { EntityRegistryManager } from '../../../src/utils/ha/registry/entity'; +import { EntityRegistryManager } from '../../../src/utils/ha/registry/entity/types'; import { ResolvedMediaCache } from '../../../src/utils/ha/resolved-media'; import { homeAssistantWSRequest } from '../../../src/utils/ha/ws-request'; import { @@ -39,6 +39,7 @@ import { createRegistryEntity, createStore, } from '../../test-utils'; +import { EntityRegistryManagerMock } from '../../utils/ha/registry/entity/mock'; vi.mock('../../../src/utils/ha/ws-request'); @@ -184,7 +185,7 @@ const createEngine = (options?: { entityRegistryManager?: EntityRegistryManager; }): ReolinkCameraManagerEngine => { return new ReolinkCameraManagerEngine( - options?.entityRegistryManager ?? mock(), + options?.entityRegistryManager ?? new EntityRegistryManagerMock(), mock(), options?.browseMediaManager ?? new BrowseMediaManager(), new ResolvedMediaCache(), @@ -192,15 +193,15 @@ const createEngine = (options?: { ); }; -const createPopulatedEngine = (): ReolinkCameraManagerEngine => { - const entityRegistryManager = mock(); - const entity = createRegistryEntity({ - unique_id: '85270002TS7D4RUP_0_main', - platform: 'reolink', - config_entry_id: '01J8XHYTNH77WE3C654K03KX1F', - }); - vi.mocked(entityRegistryManager.getEntity).mockResolvedValue(entity); +const cameraEntity = createRegistryEntity({ + entity_id: 'camera.office', + unique_id: '85270002TS7D4RUP_0_main', + platform: 'reolink', + config_entry_id: '01J8XHYTNH77WE3C654K03KX1F', +}); +const createPopulatedEngine = (): ReolinkCameraManagerEngine => { + const entityRegistryManager = new EntityRegistryManagerMock([cameraEntity]); return createEngine({ entityRegistryManager }); }; @@ -243,14 +244,7 @@ describe('ReolinkCameraManagerEngine', () => { }); it('should create camera', async () => { - const entityRegistryManager = mock(); - const entity = createRegistryEntity({ - unique_id: '85270002TS7D4RUP_0_main', - platform: 'reolink', - }); - vi.mocked(entityRegistryManager.getEntity).mockResolvedValue(entity); - - const engine = createEngine({ entityRegistryManager }); + const engine = createPopulatedEngine(); const config = createCameraConfig({ camera_entity: 'camera.office', unique_id: 'office', @@ -589,15 +583,15 @@ describe('ReolinkCameraManagerEngine', () => { describe('should ignore invalid cameras', () => { it('camera without a config entry id', async () => { - const entityRegistryManager = mock(); const entity = createRegistryEntity({ + entity_id: 'camera.office', unique_id: '85270002TS7D4RUP_0_main', platform: 'reolink', // Cannot fetch events without a config_entry_id. config_entry_id: null, }); - vi.mocked(entityRegistryManager.getEntity).mockResolvedValue(entity); + const entityRegistryManager = new EntityRegistryManagerMock([entity]); const engine = createEngine({ entityRegistryManager }); const store = await createStoreWithReolinkCamera(engine); @@ -1098,15 +1092,15 @@ describe('ReolinkCameraManagerEngine', () => { describe('should ignore invalid cameras', () => { it('should get no metdata for camera without a config entry id', async () => { - const entityRegistryManager = mock(); const entity = createRegistryEntity({ + entity_id: 'camera.office', unique_id: '85270002TS7D4RUP_0_main', platform: 'reolink', // Cannot fetch events without a config_entry_id. config_entry_id: null, }); - vi.mocked(entityRegistryManager.getEntity).mockResolvedValue(entity); + const entityRegistryManager = new EntityRegistryManagerMock([entity]); const engine = createEngine({ entityRegistryManager }); const store = await createStoreWithReolinkCamera(engine); diff --git a/tests/camera-manager/store.test.ts b/tests/camera-manager/store.test.ts index 0cfdf350..bc13a291 100644 --- a/tests/camera-manager/store.test.ts +++ b/tests/camera-manager/store.test.ts @@ -6,7 +6,7 @@ import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-fact import { CameraManagerStore } from '../../src/camera-manager/store.js'; import { Engine } from '../../src/camera-manager/types.js'; import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js'; -import { EntityRegistryManager } from '../../src/utils/ha/registry/entity/index.js'; +import { EntityRegistryManager } from '../../src/utils/ha/registry/entity/types.js'; import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media.js'; import { TestViewMedia, createCameraConfig } from '../test-utils.js'; diff --git a/tests/card-controller/actions/actions-manager.test.ts b/tests/card-controller/actions/actions-manager.test.ts index a9313ee4..cbab5f72 100644 --- a/tests/card-controller/actions/actions-manager.test.ts +++ b/tests/card-controller/actions/actions-manager.test.ts @@ -258,7 +258,7 @@ describe('ActionsManager', () => { const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); await manager.handleActionExecutionRequestEvent( new CustomEvent('advanced-camera-card:action:execution-request', { - detail: { action: createLogAction('Hello, world!') }, + detail: { actions: createLogAction('Hello, world!') }, }), ); expect(consoleSpy).toBeCalled(); @@ -271,7 +271,7 @@ describe('ActionsManager', () => { const manager = new ActionsManager(api); const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); - await manager.executeActions(createLogAction('Hello, world!')); + await manager.executeActions({ actions: createLogAction('Hello, world!') }); expect(consoleSpy).toBeCalled(); }); @@ -280,7 +280,7 @@ describe('ActionsManager', () => { const manager = new ActionsManager(api); const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); - await manager.executeActions(createLogAction('Hello, world!')); + await manager.executeActions({ actions: createLogAction('Hello, world!') }); expect(consoleSpy).toBeCalled(); }); @@ -303,10 +303,7 @@ describe('ActionsManager', () => { const config = { entity: 'light.office' }; const triggerData = { view: { from: 'previous-view', to: 'view' } }; - await manager.executeActions(action, { - config, - triggerData, - }); + await manager.executeActions({ actions: action, config, triggerData }); expect(templateRenderer.renderRecursively).toBeCalledWith(hass, action, { conditionState, @@ -326,7 +323,7 @@ describe('ActionsManager', () => { const api = createCardAPI(); const manager = new ActionsManager(api); - await manager.executeActions({ action: 'none' }); + await manager.executeActions({ actions: { action: 'none' } }); expect(handler).toBeCalledWith(expect.objectContaining({ detail: 'success' })); }); @@ -340,7 +337,9 @@ describe('ActionsManager', () => { vi.stubGlobal('confirm', vi.fn().mockReturnValue(false)); - await manager.executeActions({ action: 'none', confirmation: true }); + await manager.executeActions({ + actions: { action: 'none', confirmation: true }, + }); expect(handler).toBeCalledWith(expect.objectContaining({ detail: 'warning' })); }); @@ -360,16 +359,18 @@ describe('ActionsManager', () => { const manager = new ActionsManager(api); const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); - const promise = manager.executeActions([ - { - action: 'fire-dom-event', - advanced_camera_card_action: 'sleep', - duration: { - m: 1, + const promise = manager.executeActions({ + actions: [ + { + action: 'fire-dom-event', + advanced_camera_card_action: 'sleep', + duration: { + m: 1, + }, }, - }, - createLogAction('Hello, world!'), - ]); + createLogAction('Hello, world!'), + ], + }); // Stop inflight actions. await manager.uninitialize(); diff --git a/tests/card-controller/actions/actions/ptz.test.ts b/tests/card-controller/actions/actions/ptz.test.ts index bfe98082..0606f852 100644 --- a/tests/card-controller/actions/actions/ptz.test.ts +++ b/tests/card-controller/actions/actions/ptz.test.ts @@ -1,5 +1,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { Capabilities } from '../../../../src/camera-manager/capabilities'; import { PTZAction } from '../../../../src/card-controller/actions/actions/ptz'; +import { PTZMovementType } from '../../../../src/types'; import { createCameraConfig, createCameraManager, @@ -7,7 +9,6 @@ import { createStore, createView, } from '../../../test-utils'; -import { Capabilities } from '../../../../src/camera-manager/capabilities'; describe('should handle ptz action', () => { it('should execute simple action', async () => { @@ -20,7 +21,7 @@ describe('should handle ptz action', () => { const store = createStore([ { cameraID: 'camera.office', - capabilities: new Capabilities({ ptz: { left: ['relative'] } }), + capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }), }, ]); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); @@ -53,7 +54,7 @@ describe('should handle ptz action', () => { const store = createStore([ { cameraID: 'camera.office', - capabilities: new Capabilities({ ptz: { left: ['relative'] } }), + capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }), }, ]); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); @@ -93,7 +94,7 @@ describe('should handle ptz action', () => { }, { cameraID: 'camera.office_hd', - capabilities: new Capabilities({ ptz: { left: ['relative'] } }), + capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }), }, ]); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); @@ -210,7 +211,7 @@ describe('should handle ptz action', () => { cameraID: 'camera.office', capabilities: new Capabilities({ ptz: { - left: ['relative'], + left: [PTZMovementType.Relative], presets: [], }, }), @@ -281,7 +282,7 @@ describe('should handle ptz action', () => { cameraID: 'camera.office', capabilities: new Capabilities({ ptz: { - left: ['continuous'], + left: [PTZMovementType.Continuous], }, }), }, @@ -329,7 +330,7 @@ describe('should handle ptz action', () => { cameraID: 'camera.office', capabilities: new Capabilities({ ptz: { - left: ['continuous'], + left: [PTZMovementType.Continuous], }, }), }, @@ -375,7 +376,7 @@ describe('should handle ptz action', () => { cameraID: 'camera.office', capabilities: new Capabilities({ ptz: { - left: ['continuous'], + left: [PTZMovementType.Continuous], }, }), }, @@ -419,7 +420,7 @@ describe('should handle ptz action', () => { cameraID: 'camera.office', capabilities: new Capabilities({ ptz: { - left: ['relative'], + left: [PTZMovementType.Relative], }, }), }, @@ -473,7 +474,7 @@ describe('should handle ptz action', () => { cameraID: 'camera.office', capabilities: new Capabilities({ ptz: { - left: ['relative'], + left: [PTZMovementType.Relative], }, }), }, diff --git a/tests/card-controller/automations-manager.test.ts b/tests/card-controller/automations-manager.test.ts index 0b1cc964..dc09fff6 100644 --- a/tests/card-controller/automations-manager.test.ts +++ b/tests/card-controller/automations-manager.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ActionsExecutionRequest } from '../../src/card-controller/actions/types.js'; import { AutomationsManager } from '../../src/card-controller/automations-manager.js'; import { ConditionStateManager } from '../../src/conditions/state-manager.js'; -import { ActionConfig } from '../../src/config/schema/actions/types.js'; import { createCardAPI } from '../test-utils.js'; describe('AutomationsManager', () => { @@ -150,9 +150,7 @@ describe('AutomationsManager', () => { vi.mocked(api.getActionsManager().executeActions).mockImplementation( async ( // eslint-disable-next-line @typescript-eslint/no-unused-vars - _action: ActionConfig | ActionConfig[], - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _options?: unknown, + _request: ActionsExecutionRequest, ): Promise => { fullscreen = !fullscreen; stateManager.setState({ fullscreen: fullscreen }); diff --git a/tests/card-controller/controller.test.ts b/tests/card-controller/controller.test.ts index 788ae302..4bcfd2fe 100644 --- a/tests/card-controller/controller.test.ts +++ b/tests/card-controller/controller.test.ts @@ -29,7 +29,7 @@ import { ViewManager } from '../../src/card-controller/view/view-manager'; import { ConditionStateManager } from '../../src/conditions/state-manager'; import { AdvancedCameraCardEditor } from '../../src/editor'; import { DeviceRegistryManager } from '../../src/utils/ha/registry/device'; -import { EntityRegistryManager } from '../../src/utils/ha/registry/entity'; +import { EntityRegistryManagerLive } from '../../src/utils/ha/registry/entity'; import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media'; vi.mock('../../src/camera-manager/manager'); @@ -160,7 +160,7 @@ describe('CardController', () => { it('getEntityRegistryManager', () => { expect(createController().getEntityRegistryManager()).toBe( - vi.mocked(EntityRegistryManager).mock.instances[0], + vi.mocked(EntityRegistryManagerLive).mock.instances[0], ); }); diff --git a/tests/card-controller/media-player-manager.test.ts b/tests/card-controller/media-player-manager.test.ts index b5c0d02a..8cfb89ef 100644 --- a/tests/card-controller/media-player-manager.test.ts +++ b/tests/card-controller/media-player-manager.test.ts @@ -7,7 +7,7 @@ import { MEDIA_PLAYER_SUPPORT_TURN_OFF, } from '../../src/const'; import { HomeAssistant } from '../../src/ha/types.js'; -import { EntityRegistryManager } from '../../src/utils/ha/registry/entity/index.js'; +import { EntityRegistryManager } from '../../src/utils/ha/registry/entity/types.js'; import { createCameraConfig, createCameraManager, @@ -19,6 +19,7 @@ import { createStore, TestViewMedia, } from '../test-utils.js'; +import { EntityRegistryManagerMock } from '../utils/ha/registry/entity/mock.js'; const createHASSWithMediaPlayers = (): HomeAssistant => { const attributesSupported = { @@ -68,13 +69,16 @@ describe('MediaPlayerManager', () => { describe('should initialize', () => { it('correctly', async () => { - const entityRegistryManager = mock(); - entityRegistryManager.getEntities.mockResolvedValue( - new Map([ - ['media_player.ok1', createRegistryEntity({ hidden_by: '' })], - ['media_player.ok2', createRegistryEntity({ hidden_by: 'user' })], - ]), - ); + const entityRegistryManager = new EntityRegistryManagerMock([ + createRegistryEntity({ + entity_id: 'media_player.ok1', + hidden_by: '', + }), + createRegistryEntity({ + entity_id: 'media_player.ok2', + hidden_by: 'user', + }), + ]); const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue( @@ -150,13 +154,16 @@ describe('MediaPlayerManager', () => { }); it('should reinitialize when there is a config change', async () => { - const entityRegistryManager = mock(); - entityRegistryManager.getEntities.mockResolvedValue( - new Map([ - ['media_player.ok1', createRegistryEntity({ hidden_by: '' })], - ['media_player.ok2', createRegistryEntity({ hidden_by: 'user' })], - ]), - ); + const entityRegistryManager = new EntityRegistryManagerMock([ + createRegistryEntity({ + entity_id: 'media_player.ok1', + hidden_by: '', + }), + createRegistryEntity({ + entity_id: 'media_player.ok2', + hidden_by: 'user', + }), + ]); const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue( diff --git a/tests/card-controller/query-string-manager.test.ts b/tests/card-controller/query-string-manager.test.ts index a035d648..0bcc61b8 100644 --- a/tests/card-controller/query-string-manager.test.ts +++ b/tests/card-controller/query-string-manager.test.ts @@ -77,13 +77,15 @@ describe('QueryStringManager', () => { expect(manager.hasViewRelatedActionsToRun()).toBeFalsy(); await manager.executeIfNecessary(); - expect(api.getActionsManager().executeActions).toBeCalledWith([ - { - action: 'fire-dom-event', - card_id: 'id', - advanced_camera_card_action: action, - }, - ]); + expect(api.getActionsManager().executeActions).toBeCalledWith({ + actions: [ + { + action: 'fire-dom-event', + card_id: 'id', + advanced_camera_card_action: action, + }, + ], + }); }); }); diff --git a/tests/components-lib/menu-controller.test.ts b/tests/components-lib/menu-controller.test.ts index 51584f4a..a3e82dd2 100644 --- a/tests/components-lib/menu-controller.test.ts +++ b/tests/components-lib/menu-controller.test.ts @@ -384,7 +384,7 @@ describe('MenuController', () => { controller.handleAction(createInteractionActionEvent('tap'), tapActionConfig); expect(handler).toBeCalledWith( expect.objectContaining({ - detail: { action: [action], config: tapActionConfig }, + detail: { actions: [action], config: tapActionConfig }, }), ); expect(controller.isExpanded()).toBeFalsy(); @@ -402,7 +402,7 @@ describe('MenuController', () => { ); expect(handler).toBeCalledWith( expect.objectContaining({ - detail: { action: [action], config: tapActionConfig }, + detail: { actions: [action], config: tapActionConfig }, }), ); }); @@ -418,7 +418,7 @@ describe('MenuController', () => { expect(handler).toBeCalledWith( expect.objectContaining({ - detail: { action: [action, action, action], config: tapActionConfigMulti }, + detail: { actions: [action, action, action], config: tapActionConfigMulti }, }), ); }); diff --git a/tests/components-lib/ptz/ptz-controller.test.ts b/tests/components-lib/ptz/ptz-controller.test.ts index 240f84a1..ecd8e3d3 100644 --- a/tests/components-lib/ptz/ptz-controller.test.ts +++ b/tests/components-lib/ptz/ptz-controller.test.ts @@ -6,6 +6,7 @@ import { PTZControlsConfig, ptzControlsConfigSchema, } from '../../../src/config/schema/common/controls/ptz'; +import { PTZMovementType } from '../../../src/types'; import { createCameraManager, createCapabilities, createStore } from '../../test-utils'; const createConfig = (config?: Partial): PTZControlsConfig => { @@ -122,14 +123,14 @@ describe('PTZController', () => { const store = createStore([ { cameraID: 'camera.office', - capabilities: new Capabilities({ ptz: { left: ['relative'] } }), + capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }), }, ]); const cameraManager = createCameraManager(store); vi.mocked(cameraManager).getCameraCapabilities.mockReturnValue( createCapabilities({ ptz: { - left: ['relative'], + left: [PTZMovementType.Relative], }, }), ); @@ -158,12 +159,12 @@ describe('PTZController', () => { cameraID: 'camera.office', capabilities: new Capabilities({ ptz: { - left: ['relative'], - right: ['relative'], - up: ['relative'], - down: ['relative'], - zoomIn: ['relative'], - zoomOut: ['relative'], + left: [PTZMovementType.Relative], + right: [PTZMovementType.Relative], + up: [PTZMovementType.Relative], + down: [PTZMovementType.Relative], + zoomIn: [PTZMovementType.Relative], + zoomOut: [PTZMovementType.Relative], }, }), }, @@ -197,12 +198,12 @@ describe('PTZController', () => { cameraID: 'camera.office', capabilities: new Capabilities({ ptz: { - left: ['relative'], - right: ['relative'], - up: ['relative'], - down: ['relative'], - zoomIn: ['relative'], - zoomOut: ['relative'], + left: [PTZMovementType.Relative], + right: [PTZMovementType.Relative], + up: [PTZMovementType.Relative], + down: [PTZMovementType.Relative], + zoomIn: [PTZMovementType.Relative], + zoomOut: [PTZMovementType.Relative], presets: ['door', 'window'], }, }), @@ -387,7 +388,7 @@ describe('PTZController', () => { expect(handler).toBeCalledWith( expect.objectContaining({ detail: { - action: action, + actions: action, config: config, }, }), diff --git a/tests/components-lib/status-bar-controller.test.ts b/tests/components-lib/status-bar-controller.test.ts index a10dd7d5..ae599cd3 100644 --- a/tests/components-lib/status-bar-controller.test.ts +++ b/tests/components-lib/status-bar-controller.test.ts @@ -325,7 +325,7 @@ describe('StatusBarController', () => { expect(handler).toBeCalledWith( expect.objectContaining({ - detail: { action: [action], config: tapActionConfig }, + detail: { actions: [action], config: tapActionConfig }, }), ); }); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 439a7b8d..57057a78 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -54,8 +54,7 @@ import { CurrentUser, HomeAssistant } from '../src/ha/types'; import { CapabilitiesRaw, Interaction, MediaLoadedInfo } from '../src/types'; import { HassStateDifference } from '../src/utils/ha'; import { Device } from '../src/utils/ha/registry/device/types'; -import { EntityRegistryManager } from '../src/utils/ha/registry/entity'; -import { Entity } from '../src/utils/ha/registry/entity/types'; +import { Entity, EntityRegistryManager } from '../src/utils/ha/registry/entity/types'; import { ViewMedia, ViewMediaType } from '../src/view/media'; import { MediaQueriesResults } from '../src/view/media-queries-results'; import { View, ViewParameters } from '../src/view/view'; diff --git a/tests/utils/ha/registry/entity/index.test.ts b/tests/utils/ha/registry/entity/index.test.ts index bef5d447..a106c045 100644 --- a/tests/utils/ha/registry/entity/index.test.ts +++ b/tests/utils/ha/registry/entity/index.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createEntityRegistryCache, - EntityRegistryManager, + EntityRegistryManagerLive, } from '../../../../../src/utils/ha/registry/entity'; import { homeAssistantWSRequest } from '../../../../../src/utils/ha/ws-request'; import { createHASS, createRegistryEntity } from '../../../../test-utils.js'; @@ -21,7 +21,7 @@ describe('EntityRegistryManager', () => { cache.add(testEntity); - const manager = new EntityRegistryManager(cache); + const manager = new EntityRegistryManagerLive(cache); expect(await manager.getEntity(createHASS(), 'test')).toEqual(testEntity); expect(homeAssistantWSRequest).not.toHaveBeenCalled(); @@ -30,7 +30,7 @@ describe('EntityRegistryManager', () => { it('should fetch and cache when not cached', async () => { const testEntity = createRegistryEntity({ entity_id: 'test' }); - const manager = new EntityRegistryManager(createEntityRegistryCache()); + const manager = new EntityRegistryManagerLive(createEntityRegistryCache()); vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce(testEntity); expect(await manager.getEntity(createHASS(), 'test')).toEqual(testEntity); @@ -43,7 +43,7 @@ describe('EntityRegistryManager', () => { it('should return null when entity does not exist', async () => { vi.mocked(homeAssistantWSRequest).mockRejectedValueOnce(new Error('Not found')); - const manager = new EntityRegistryManager(createEntityRegistryCache()); + const manager = new EntityRegistryManagerLive(createEntityRegistryCache()); expect(await manager.getEntity(createHASS(), 'missing')).toBeNull(); vi.mocked(expect(console.warn)).toBeCalledWith('Not found'); @@ -57,7 +57,7 @@ describe('EntityRegistryManager', () => { const cache = createEntityRegistryCache(); cache.add(cachedEntity); - const manager = new EntityRegistryManager(cache); + const manager = new EntityRegistryManagerLive(cache); vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce(notCachedEntity); vi.mocked(homeAssistantWSRequest).mockRejectedValueOnce(new Error('Not found')); @@ -79,7 +79,7 @@ describe('EntityRegistryManager', () => { const entity = createRegistryEntity({ entity_id: 'cached' }); vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce([entity]); - const manager = new EntityRegistryManager(createEntityRegistryCache()); + const manager = new EntityRegistryManagerLive(createEntityRegistryCache()); await manager.fetchEntityList(hass); @@ -103,7 +103,7 @@ describe('EntityRegistryManager', () => { const hass = createHASS(); vi.mocked(homeAssistantWSRequest).mockRejectedValueOnce(new Error('Fetch error')); - const manager = new EntityRegistryManager(createEntityRegistryCache()); + const manager = new EntityRegistryManagerLive(createEntityRegistryCache()); await manager.fetchEntityList(hass); @@ -121,7 +121,7 @@ describe('EntityRegistryManager', () => { notMatchingEntity, ]); - const manager = new EntityRegistryManager(createEntityRegistryCache()); + const manager = new EntityRegistryManagerLive(createEntityRegistryCache()); expect( await manager.getMatchingEntities( hass, diff --git a/tests/utils/ha/registry/entity/mock.ts b/tests/utils/ha/registry/entity/mock.ts new file mode 100644 index 00000000..23452750 --- /dev/null +++ b/tests/utils/ha/registry/entity/mock.ts @@ -0,0 +1,47 @@ +import { HomeAssistant } from '../../../../../src/ha/types'; +import { RegistryCache } from '../../../../../src/utils/ha/registry/cache'; +import { + Entity, + EntityRegistryManager, +} from '../../../../../src/utils/ha/registry/entity/types'; + +export class EntityRegistryManagerMock implements EntityRegistryManager { + protected _cache: RegistryCache; + protected _fetchedEntityList = false; + + constructor(data?: Entity[]) { + this._cache = new RegistryCache((ent) => ent.entity_id); + this._cache.add(data ?? []); + } + + public async getEntity( + _hass: HomeAssistant, + entityID: string, + ): Promise { + return this._cache.get(entityID); + } + + public async getMatchingEntities( + _hass: HomeAssistant, + func: (arg: Entity) => boolean, + ): Promise { + return this._cache.getMatches(func); + } + + public async getEntities( + hass: HomeAssistant, + entityIDs: string[], + ): Promise> { + const output: Map = new Map(); + for (const entityID of entityIDs) { + const entityData = await this.getEntity(hass, entityID); + if (entityData) { + output.set(entityID, entityData); + } + } + return output; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async fetchEntityList(_hass: HomeAssistant): Promise {} +}