diff --git a/docs/configuration/cameras/engine.md b/docs/configuration/cameras/engine.md index cecc24af..dae483f8 100644 --- a/docs/configuration/cameras/engine.md +++ b/docs/configuration/cameras/engine.md @@ -10,6 +10,7 @@ A "Camera Engine" defines what "type" of camera is being configured (e.g. `friga | `generic` | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | | `motioneye` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :white_check_mark: | :white_check_mark: | | `reolink` | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :white_check_mark: | :eight_spoked_asterisk: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | +| `tplink` | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :eight_spoked_asterisk: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | ### Live providers supported per Engine @@ -19,6 +20,7 @@ A "Camera Engine" defines what "type" of camera is being configured (e.g. `friga | `generic` | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :white_check_mark: | | `motioneye` | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | | `reolink` | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | +| `tplink` | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :white_check_mark: | See [Live Provider Configuration](live-provider.md) for more details on live providers. @@ -112,6 +114,21 @@ Zero-configuration PTZ support is available for Reolink if your camera supports > [!IMPORTANT] > For Home Assistant control of Reolink camera PTZ functions, the relevent `button` entities must be enabled. To verify, navigate to `Settings -> Devices & services -> Reolink -> [Choose Device]`, and ensure the `PTZ` entities are enabled. Disabled entities are shown under the `+X disabled entities` label. +## `tplink` + +[](../common/experimental-warning.md ':include') + +The `tplink` engine provides support for TPLink/Tapo cameras. + +```yaml +cameras: + - camera_entity: camera.tapo_xxx_live_view +``` + +### PTZ Support + +Zero-configuration PTZ support is available for TPLink/Tapo cameras that have pan/tilt capabilities. + ## Fully expanded reference [](../common/expanded-warning.md ':include') diff --git a/docs/images/icons/tplink.svg b/docs/images/icons/tplink.svg new file mode 100644 index 00000000..f5ad54e0 --- /dev/null +++ b/docs/images/icons/tplink.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/docs/usage/custom-icons.md b/docs/usage/custom-icons.md index ce309191..5724a9e9 100644 --- a/docs/usage/custom-icons.md +++ b/docs/usage/custom-icons.md @@ -11,3 +11,4 @@ builtin custom items not found in the traditional [Material Design Icons | `iris` | ![Iris Icon](../images/icons/iris.svg ':size=24 :class=svg-icon') | | `motioneye` | ![motionEye Icon](../images/icons/motioneye.svg ':size=24 :class=svg-icon') | | `reolink` | ![Reolink Icon](../images/icons/reolink.svg ':size=24 :class=svg-icon') | +| `tplink` | ![TPLink Icon](../images/icons/tplink.svg ':size=24 :class=svg-icon') | diff --git a/scripts/docs-update-icons.sh b/scripts/docs-update-icons.sh index 0bb7de8a..1e6362e1 100755 --- a/scripts/docs-update-icons.sh +++ b/scripts/docs-update-icons.sh @@ -6,6 +6,8 @@ FRIGATE="./src/camera-manager/frigate/assets/frigate.svg" IRIS="./src/images/iris.svg" MOTIONEYE="./src/camera-manager/motioneye/assets/motioneye.svg" REOLINK="./src/camera-manager/reolink/assets/reolink.svg" +TPLINK="./src/camera-manager/tplink/assets/tplink.svg" + DIR_ICONS="./docs/images/icons" copy() { @@ -18,3 +20,4 @@ copy "$FRIGATE" copy "$IRIS" copy "$MOTIONEYE" copy "$REOLINK" +copy "$TPLINK" diff --git a/src/camera-manager/browse-media/camera.ts b/src/camera-manager/browse-media/camera.ts deleted file mode 100644 index df2e8763..00000000 --- a/src/camera-manager/browse-media/camera.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types'; -import { HomeAssistant } from '../../ha/types'; -import { localize } from '../../localize/localize'; -import { Camera, CameraInitializationOptions } from '../camera'; -import { CameraInitializationError } from '../error'; - -interface BrowseMediaCameraInitializationOptions extends CameraInitializationOptions { - entityRegistryManager: EntityRegistryManager; - hass: HomeAssistant; -} - -export class BrowseMediaCamera extends Camera { - protected _entity: Entity | null = null; - - public async initialize( - options: BrowseMediaCameraInitializationOptions, - ): Promise { - const config = this.getConfig(); - const entity = config.camera_entity - ? await options.entityRegistryManager.getEntity(options.hass, config.camera_entity) - : null; - - if (!entity || !config.camera_entity) { - throw new CameraInitializationError(localize('error.no_camera_entity'), config); - } - this._entity = entity; - return await super.initialize(options); - } - - public getEntity(): Entity | null { - return this._entity; - } -} diff --git a/src/camera-manager/engine-factory.ts b/src/camera-manager/engine-factory.ts index 5c889729..989b5068 100644 --- a/src/camera-manager/engine-factory.ts +++ b/src/camera-manager/engine-factory.ts @@ -71,6 +71,15 @@ export class CameraManagerEngineFactory { new CameraManagerRequestCache(), options.eventCallback, ); + break; + case Engine.TPLink: + const { TPLinkCameraManagerEngine } = await import('./tplink/engine-tplink'); + cameraManagerEngine = new TPLinkCameraManagerEngine( + this._entityRegistryManager, + options.stateWatcher, + options.eventCallback, + ); + break; } return cameraManagerEngine; } @@ -88,6 +97,8 @@ export class CameraManagerEngineFactory { engine = Engine.Generic; } else if (cameraConfig.engine === 'reolink') { engine = Engine.Reolink; + } else if (cameraConfig.engine === 'tplink') { + engine = Engine.TPLink; } else { const cameraEntity = getCameraEntityFromConfig(cameraConfig); @@ -116,6 +127,9 @@ export class CameraManagerEngineFactory { case 'reolink': engine = Engine.Reolink; break; + case 'tplink': + engine = Engine.TPLink; + break; default: engine = Engine.Generic; } diff --git a/src/camera-manager/entity-camera.ts b/src/camera-manager/entity-camera.ts new file mode 100644 index 00000000..0bb1cf07 --- /dev/null +++ b/src/camera-manager/entity-camera.ts @@ -0,0 +1,33 @@ +import { Entity, EntityRegistryManager } from '../ha/registry/entity/types'; +import { HomeAssistant } from '../ha/types'; +import { localize } from '../localize/localize'; +import { Camera, CameraInitializationOptions } from './camera'; +import { CameraInitializationError } from './error'; +import { getCameraEntityFromConfig } from './utils/camera-entity-from-config'; + +export interface EntityCameraInitializationOptions extends CameraInitializationOptions { + entityRegistryManager: EntityRegistryManager; + hass: HomeAssistant; +} + +export class EntityCamera extends Camera { + protected _entity: Entity | null = null; + + public async initialize(options: EntityCameraInitializationOptions): Promise { + const config = this.getConfig(); + const cameraEntityID = getCameraEntityFromConfig(config); + const entity = cameraEntityID + ? await options.entityRegistryManager.getEntity(options.hass, cameraEntityID) + : null; + + if (!entity || !cameraEntityID) { + throw new CameraInitializationError(localize('error.no_camera_entity'), config); + } + this._entity = entity; + return await super.initialize(options); + } + + public getEntity(): Entity | null { + return this._entity; + } +} diff --git a/src/camera-manager/motioneye/camera.ts b/src/camera-manager/motioneye/camera.ts index 1b120554..298c47eb 100644 --- a/src/camera-manager/motioneye/camera.ts +++ b/src/camera-manager/motioneye/camera.ts @@ -1,7 +1,7 @@ -import { BrowseMediaCamera } from '../browse-media/camera'; +import { EntityCamera } from '../entity-camera'; import { CameraProxyConfig } from '../types'; -export class MotionEyeCamera extends BrowseMediaCamera { +export class MotionEyeCamera extends EntityCamera { public getProxyConfig(): CameraProxyConfig { return { ...super.getProxyConfig(), diff --git a/src/camera-manager/motioneye/engine-motioneye.ts b/src/camera-manager/motioneye/engine-motioneye.ts index 1cb50109..afa9454f 100644 --- a/src/camera-manager/motioneye/engine-motioneye.ts +++ b/src/camera-manager/motioneye/engine-motioneye.ts @@ -17,7 +17,7 @@ import { HomeAssistant } from '../../ha/types'; import { Endpoint } from '../../types'; import { allPromises, formatDate, isValidDate } from '../../utils/basic'; import { ViewMedia } from '../../view/item'; -import { BrowseMediaCamera } from '../browse-media/camera'; +import { EntityCamera } from '../entity-camera'; import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media'; import { Camera } from '../camera'; import { Capabilities } from '../capabilities'; @@ -173,7 +173,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine const camera = store.getCamera(cameraID); const cameraConfig = camera?.getConfig(); - if (!(camera instanceof BrowseMediaCamera) || !cameraConfig) { + if (!(camera instanceof EntityCamera) || !cameraConfig) { return null; } diff --git a/src/camera-manager/reolink/camera.ts b/src/camera-manager/reolink/camera.ts index bf1d5d61..bf1b3f11 100644 --- a/src/camera-manager/reolink/camera.ts +++ b/src/camera-manager/reolink/camera.ts @@ -6,9 +6,9 @@ import { HomeAssistant } from '../../ha/types'; import { localize } from '../../localize/localize'; import { PTZCapabilities, PTZMovementType } from '../../types'; import { createSelectOptionAction } from '../../utils/action.js'; -import { BrowseMediaCamera } from '../browse-media/camera'; import { Camera, CameraInitializationOptions } from '../camera'; import { Capabilities } from '../capabilities'; +import { EntityCamera } from '../entity-camera'; import { CameraInitializationError } from '../error'; import { CameraProxyConfig } from '../types'; import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; @@ -35,7 +35,7 @@ interface PTZEntities { } type PTZEntity = keyof PTZEntities; -export class ReolinkCamera extends BrowseMediaCamera { +export class ReolinkCamera extends EntityCamera { // The HostID identifying the camera or NVR. protected _reolinkHostID: string | null = null; diff --git a/src/camera-manager/tplink/assets/tplink.svg b/src/camera-manager/tplink/assets/tplink.svg new file mode 100644 index 00000000..f5ad54e0 --- /dev/null +++ b/src/camera-manager/tplink/assets/tplink.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/camera-manager/tplink/camera.ts b/src/camera-manager/tplink/camera.ts new file mode 100644 index 00000000..dcb642a0 --- /dev/null +++ b/src/camera-manager/tplink/camera.ts @@ -0,0 +1,173 @@ +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 { Entity, EntityRegistryManager } from '../../ha/registry/entity/types'; +import { HomeAssistant } from '../../ha/types'; +import { PTZCapabilities, PTZMovementType } from '../../types'; +import { Camera } from '../camera'; +import { Capabilities } from '../capabilities'; +import { EntityCamera, EntityCameraInitializationOptions } from '../entity-camera'; +import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; + +type TPLinkCameraInitializationOptions = EntityCameraInitializationOptions; + +interface PTZEntities { + left?: string; + right?: string; + up?: string; + down?: string; +} +type PTZEntity = keyof PTZEntities; + +export class TPLinkCamera extends EntityCamera { + protected _ptzEntities: PTZEntities | null = null; + + public async initialize(options: TPLinkCameraInitializationOptions): Promise { + await super.initialize(options); + await this._initializeCapabilities( + options.hass, + options.entityRegistryManager, + options.stateWatcher, + ); + return this; + } + + protected async _initializeCapabilities( + hass: HomeAssistant, + entityRegistry: EntityRegistryManager, + stateWatcher: StateWatcherSubscriptionInterface, + ): Promise { + const config = this.getConfig(); + const configPTZCapabilities = getPTZCapabilitiesFromCameraConfig(config); + this._ptzEntities = await this._getPTZEntities(hass, entityRegistry); + const tplinkPTZCapabilities = this._ptzEntities + ? this._entitiesToCapabilities(this._ptzEntities) + : null; + + const combinedPTZCapabilities: PTZCapabilities | null = + configPTZCapabilities || tplinkPTZCapabilities + ? { + ...tplinkPTZCapabilities, + ...configPTZCapabilities, + } + : null; + + this._capabilities = new Capabilities( + { + 'favorite-events': false, + 'favorite-recordings': false, + 'remote-control-entity': true, + clips: false, + 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._subscribeBasedOnCapabilities(stateWatcher); + } + + protected async _getPTZEntities( + hass: HomeAssistant, + entityRegistry: EntityRegistryManager, + ): Promise { + if (!this._entity?.device_id) { + return null; + } + + // Find all button entities on the same device + const buttonEntities = await entityRegistry.getMatchingEntities( + hass, + (ent: Entity) => + ent.device_id === this._entity?.device_id && + ent.entity_id.startsWith('button.') && + !ent.disabled_by, + ); + + // TPLink uses pan_right, pan_left, tilt_up, tilt_down button entity suffixes + const entitySuffixToAction: Record = { + pan_left: 'left', + pan_right: 'right', + tilt_up: 'up', + tilt_down: 'down', + }; + + const ptzEntities: PTZEntities = {}; + for (const buttonEntity of buttonEntities) { + for (const [suffix, action] of Object.entries(entitySuffixToAction)) { + if (buttonEntity.entity_id.endsWith(`_${suffix}`)) { + ptzEntities[action] = buttonEntity.entity_id; + } + } + } + + return Object.keys(ptzEntities).length ? ptzEntities : null; + } + + protected _entitiesToCapabilities(ptzEntities: PTZEntities): PTZCapabilities { + const tplinkPTZCapabilities: PTZCapabilities = {}; + // TPLink buttons perform relative movements (no stop button needed) + for (const key of Object.keys(ptzEntities) as PTZEntity[]) { + tplinkPTZCapabilities[key] = [PTZMovementType.Relative]; + } + return tplinkPTZCapabilities; + } + + public async executePTZAction( + executor: ActionsExecutor, + action: PTZAction, + options?: { + phase?: PTZActionPhase; + preset?: string; + }, + ): Promise { + if (await super.executePTZAction(executor, action, options)) { + return true; + } + + if (!this._ptzEntities) { + return false; + } + + // TPLink doesn't support presets + if (action === 'preset') { + return false; + } + + // TPLink doesn't have zoom capabilities + if (action === 'zoom_in' || action === 'zoom_out') { + return false; + } + + // TPLink doesn't have a stop button - the camera stops when the button + // press action completes. We return true to indicate the stop was "handled" + // (even though no action is actually taken). + if (options?.phase === 'stop') { + return true; + } + + const entityID = this._ptzEntities[action]; + 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/tplink/engine-tplink.ts b/src/camera-manager/tplink/engine-tplink.ts new file mode 100644 index 00000000..8c35cd0a --- /dev/null +++ b/src/camera-manager/tplink/engine-tplink.ts @@ -0,0 +1,49 @@ +import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher'; +import { CameraConfig } from '../../config/schema/cameras'; +import { EntityRegistryManager } from '../../ha/registry/entity/types'; +import { HomeAssistant } from '../../ha/types'; +import { Camera } from '../camera'; +import { GenericCameraManagerEngine } from '../generic/engine-generic'; +import { CameraEventCallback, CameraManagerCameraMetadata, Engine } from '../types'; +import { TPLinkCamera } from './camera'; + +export class TPLinkCameraManagerEngine extends GenericCameraManagerEngine { + protected _entityRegistryManager: EntityRegistryManager; + + constructor( + entityRegistryManager: EntityRegistryManager, + stateWatcher: StateWatcherSubscriptionInterface, + eventCallback?: CameraEventCallback, + ) { + super(stateWatcher, eventCallback); + this._entityRegistryManager = entityRegistryManager; + } + + public getEngineType(): Engine { + return Engine.TPLink; + } + + public async createCamera( + hass: HomeAssistant, + cameraConfig: CameraConfig, + ): Promise { + const camera = new TPLinkCamera(cameraConfig, this, { + eventCallback: this._eventCallback, + }); + return await camera.initialize({ + entityRegistryManager: this._entityRegistryManager, + hass, + stateWatcher: this._stateWatcher, + }); + } + + public getCameraMetadata( + hass: HomeAssistant, + cameraConfig: CameraConfig, + ): CameraManagerCameraMetadata { + return { + ...super.getCameraMetadata(hass, cameraConfig), + engineIcon: 'tplink', + }; + } +} diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts index d36c388a..f2450737 100644 --- a/src/camera-manager/types.ts +++ b/src/camera-manager/types.ts @@ -27,6 +27,7 @@ export enum Engine { Generic = 'generic', MotionEye = 'motioneye', Reolink = 'reolink', + TPLink = 'tplink', } export interface CameraQuery { diff --git a/src/components-lib/icon-controller.ts b/src/components-lib/icon-controller.ts index 9a5a4a4e..c798ec30 100644 --- a/src/components-lib/icon-controller.ts +++ b/src/components-lib/icon-controller.ts @@ -2,6 +2,7 @@ import { HassEntity } from 'home-assistant-js-websocket'; import frigateSVG from '../camera-manager/frigate/assets/frigate.svg'; import motioneyeSVG from '../camera-manager/motioneye/assets/motioneye.svg'; import reolinkSVG from '../camera-manager/reolink/assets/reolink.svg'; +import tplinkSVG from '../camera-manager/tplink/assets/tplink.svg'; import { HomeAssistant } from '../ha/types'; import irisSVG from '../images/iris.svg'; import { Icon } from '../types'; @@ -15,6 +16,8 @@ export class IconController { return motioneyeSVG; case 'reolink': return reolinkSVG; + case 'tplink': + return tplinkSVG; case 'iris': return irisSVG; default: diff --git a/src/config/schema/cameras.ts b/src/config/schema/cameras.ts index f9876e42..9d145924 100644 --- a/src/config/schema/cameras.ts +++ b/src/config/schema/cameras.ts @@ -88,7 +88,14 @@ const castSchema = z.object({ // Camera Configuration // ************************************************************************* -const ENGINES = ['auto', 'frigate', 'generic', 'motioneye', 'reolink'] as const; +const ENGINES = [ + 'auto', + 'frigate', + 'generic', + 'motioneye', + 'reolink', + 'tplink', +] as const; export const cameraConfigDefault = { dependencies: { diff --git a/tests/camera-manager/browse-media/camera.test.ts b/tests/camera-manager/browse-media/camera.test.ts deleted file mode 100644 index a8c0f264..00000000 --- a/tests/camera-manager/browse-media/camera.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from 'vitest'; -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 { Entity, EntityRegistryManager } from '../../../src/ha/registry/entity/types'; -import { createCameraConfig, createHASS } from '../../test-utils'; - -describe('BrowseMediaCamera', () => { - describe('should initialize', () => { - it('without a camera_entity', async () => { - const camera = new BrowseMediaCamera( - createCameraConfig(), - mock(), - ); - - expect( - async () => - await camera.initialize({ - hass: createHASS(), - stateWatcher: mock(), - entityRegistryManager: mock(), - }), - ).rejects.toThrowError(/Could not find camera entity/); - }); - - it('with a camera_entity', async () => { - const camera = new BrowseMediaCamera( - createCameraConfig({ - camera_entity: 'camera.foo', - }), - mock(), - ); - const entityRegistryManager = mock(); - const entity = mock(); - entityRegistryManager.getEntity.mockResolvedValue(entity); - - expect( - await camera.initialize({ - hass: createHASS(), - stateWatcher: mock(), - entityRegistryManager: entityRegistryManager, - }), - ).toBe(camera); - expect(camera.getEntity()).toBe(entity); - }); - }); -}); diff --git a/tests/camera-manager/engine-factory.test.ts b/tests/camera-manager/engine-factory.test.ts index cfbe4af7..0d64d48a 100644 --- a/tests/camera-manager/engine-factory.test.ts +++ b/tests/camera-manager/engine-factory.test.ts @@ -5,6 +5,7 @@ import { FrigateCameraManagerEngine } from '../../src/camera-manager/frigate/eng import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic'; import { MotionEyeCameraManagerEngine } from '../../src/camera-manager/motioneye/engine-motioneye'; import { ReolinkCameraManagerEngine } from '../../src/camera-manager/reolink/engine-reolink.js'; +import { TPLinkCameraManagerEngine } from '../../src/camera-manager/tplink/engine-tplink.js'; import { Engine } from '../../src/camera-manager/types.js'; import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js'; import { CardWideConfig } from '../../src/config/schema/types.js'; @@ -106,6 +107,28 @@ describe('getEngineForCamera()', () => { }); }); + describe('should get a tplink camera', () => { + it('from manually set engine', async () => { + const config = createCameraConfig({ engine: 'tplink' }); + expect(await createFactory().getEngineForCamera(createHASS(), config)).toBe( + Engine.TPLink, + ); + }); + + it('from auto detection', async () => { + const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); + const entityRegistryManager = new EntityRegistryManagerMock([ + createRegistryEntity({ entity_id: 'camera.foo', platform: 'tplink' }), + ]); + + expect( + await createFactory({ + entityRegistryManager: entityRegistryManager, + }).getEngineForCamera(createHASS(), config), + ).toBe(Engine.TPLink); + }); + }); + describe('should get a generic camera', () => { it('from manually set engine', async () => { const config = createCameraConfig({ engine: 'generic' }); @@ -235,4 +258,12 @@ describe('createEngine()', () => { }), ).toBeInstanceOf(ReolinkCameraManagerEngine); }); + it('should create tplink engine', async () => { + expect( + await createFactory().createEngine(Engine.TPLink, { + stateWatcher: mock(), + resolvedMediaCache: mock(), + }), + ).toBeInstanceOf(TPLinkCameraManagerEngine); + }); }); diff --git a/tests/camera-manager/tplink/camera.test.ts b/tests/camera-manager/tplink/camera.test.ts new file mode 100644 index 00000000..ddbaf0aa --- /dev/null +++ b/tests/camera-manager/tplink/camera.test.ts @@ -0,0 +1,462 @@ +import { describe, expect, it } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { CameraManagerEngine } from '../../../src/camera-manager/engine'; +import { TPLinkCamera } from '../../../src/camera-manager/tplink/camera'; +import { ActionsExecutor } from '../../../src/card-controller/actions/types'; +import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; +import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock'; +import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils'; + +describe('TPLinkCamera', () => { + // Entity patterns from: https://github.com/dermotduffy/advanced-camera-card/issues/2183 + const cameraEntity = createRegistryEntity({ + entity_id: 'camera.tapo_c520ws_39d3_live_view', + unique_id: '80115E1CF270233D6FC2FCD4028181A7206CDB30-live_view', + platform: 'tplink', + config_entry_id: 'tplink_config_entry_1', + device_id: 'tplink_device_1', + }); + const buttonEntityPanLeft = createRegistryEntity({ + entity_id: 'button.tapo_c520ws_39d3_pan_left', + unique_id: '80115E1CF270233D6FC2FCD4028181A7206CDB30-pan_left', + platform: 'tplink', + config_entry_id: 'tplink_config_entry_1', + device_id: 'tplink_device_1', + }); + const buttonEntityPanRight = createRegistryEntity({ + entity_id: 'button.tapo_c520ws_39d3_pan_right', + unique_id: '80115E1CF270233D6FC2FCD4028181A7206CDB30-pan_right', + platform: 'tplink', + config_entry_id: 'tplink_config_entry_1', + device_id: 'tplink_device_1', + }); + const buttonEntityTiltUp = createRegistryEntity({ + entity_id: 'button.tapo_c520ws_39d3_tilt_up', + unique_id: '80115E1CF270233D6FC2FCD4028181A7206CDB30-tilt_up', + platform: 'tplink', + config_entry_id: 'tplink_config_entry_1', + device_id: 'tplink_device_1', + }); + const buttonEntityTiltDown = createRegistryEntity({ + entity_id: 'button.tapo_c520ws_39d3_tilt_down', + unique_id: '80115E1CF270233D6FC2FCD4028181A7206CDB30-tilt_down', + platform: 'tplink', + config_entry_id: 'tplink_config_entry_1', + device_id: 'tplink_device_1', + }); + + const ptzPopulatedEntityRegistryManager = new EntityRegistryManagerMock([ + cameraEntity, + buttonEntityPanLeft, + buttonEntityPanRight, + buttonEntityTiltUp, + buttonEntityTiltDown, + + // Unrelated button for a different config entry + createRegistryEntity({ + entity_id: 'button.other_device_pan_left', + unique_id: 'other_device_pan_left', + platform: 'tplink', + config_entry_id: 'different_config_entry', + }), + + // Unrelated button without unique_id + createRegistryEntity({ + entity_id: 'button.tapo_c520ws_39d3_something', + platform: 'tplink', + config_entry_id: 'tplink_config_entry_1', + }), + ]); + + describe('should initialize config', () => { + it('without a camera_entity', async () => { + const config = createCameraConfig(); + const camera = new TPLinkCamera(config, mock()); + + expect( + async () => + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock(), + stateWatcher: mock(), + }), + ).rejects.toThrowError('Could not find camera entity'); + }); + + it('without a matching entity in registry', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.tapo_c520ws_39d3_live_view', + }); + const camera = new TPLinkCamera(config, mock()); + + expect( + async () => + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock(), + stateWatcher: mock(), + }), + ).rejects.toThrowError('Could not find camera entity'); + }); + + it('successfully with webrtc_card.entity fallback', async () => { + const config = createCameraConfig({ + webrtc_card: { + entity: 'camera.tapo_c520ws_39d3_live_view', + }, + }); + const camera = new TPLinkCamera(config, mock()); + + const entityRegistryManager = new EntityRegistryManagerMock([cameraEntity]); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager, + stateWatcher: mock(), + }); + + expect(camera.getEntity()).toBe(cameraEntity); + }); + + it('successfully without PTZ entities', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.tapo_c520ws_39d3_live_view', + }); + const camera = new TPLinkCamera(config, mock()); + + const entityRegistryManager = new EntityRegistryManagerMock([cameraEntity]); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager, + stateWatcher: mock(), + }); + + expect(camera.getEntity()).toBe(cameraEntity); + expect(camera.getCapabilities()?.getPTZCapabilities()).toBeNull(); + }); + + describe('successfully with PTZ', () => { + it('should find PTZ button entities', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.tapo_c520ws_39d3_live_view', + }); + const camera = new TPLinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: ptzPopulatedEntityRegistryManager, + stateWatcher: mock(), + }); + + expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ + left: ['relative'], + right: ['relative'], + up: ['relative'], + down: ['relative'], + }); + }); + + it('should allow configured PTZ actions to override', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.tapo_c520ws_39d3_live_view', + ptz: { + actions_left: { + action: 'perform-action', + perform_action: 'homeassistant.toggle', + target: { + entity_id: 'switch.camera_move_left', + }, + }, + }, + }); + const camera = new TPLinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: ptzPopulatedEntityRegistryManager, + stateWatcher: mock(), + }); + + expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ + left: ['relative'], + right: ['relative'], + up: ['relative'], + down: ['relative'], + }); + }); + }); + }); + + describe('should execute PTZ action', () => { + it('should ignore actions without matching button', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.tapo_c520ws_39d3_live_view', + }); + const camera = new TPLinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]), + stateWatcher: mock(), + }); + const executor = mock(); + + expect(await camera.executePTZAction(executor, 'left')).toBeFalsy(); + expect( + await camera.executePTZAction(executor, 'left', { phase: 'start' }), + ).toBeFalsy(); + + expect(executor.executeActions).not.toBeCalled(); + }); + + it('should ignore zoom actions (not supported by TPLink)', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.tapo_c520ws_39d3_live_view', + }); + const camera = new TPLinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: ptzPopulatedEntityRegistryManager, + stateWatcher: mock(), + }); + const executor = mock(); + + expect( + await camera.executePTZAction(executor, 'zoom_in', { phase: 'start' }), + ).toBeFalsy(); + expect( + await camera.executePTZAction(executor, 'zoom_out', { phase: 'start' }), + ).toBeFalsy(); + + expect(executor.executeActions).not.toBeCalled(); + }); + + it('should ignore preset actions (not supported by TPLink)', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.tapo_c520ws_39d3_live_view', + }); + const camera = new TPLinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: ptzPopulatedEntityRegistryManager, + stateWatcher: mock(), + }); + const executor = mock(); + + expect( + await camera.executePTZAction(executor, 'preset', { preset: 'home' }), + ).toBeFalsy(); + + expect(executor.executeActions).not.toBeCalled(); + }); + + it('should handle stop phase (no-op for TPLink)', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.tapo_c520ws_39d3_live_view', + }); + const camera = new TPLinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: ptzPopulatedEntityRegistryManager, + stateWatcher: mock(), + }); + const executor = mock(); + + // Stop should return true (handled) but not execute any actions + expect( + await camera.executePTZAction(executor, 'left', { phase: 'stop' }), + ).toBeTruthy(); + + expect(executor.executeActions).not.toBeCalled(); + }); + + it.each([ + ['left', 'button.tapo_c520ws_39d3_pan_left'], + ['right', 'button.tapo_c520ws_39d3_pan_right'], + ['up', 'button.tapo_c520ws_39d3_tilt_up'], + ['down', 'button.tapo_c520ws_39d3_tilt_down'], + ] as const)( + 'should execute %s action (relative movement)', + async (action, entityId) => { + const config = createCameraConfig({ + camera_entity: 'camera.tapo_c520ws_39d3_live_view', + }); + const camera = new TPLinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: ptzPopulatedEntityRegistryManager, + stateWatcher: mock(), + }); + const executor = mock(); + + await camera.executePTZAction(executor, action); + expect(executor.executeActions).toHaveBeenCalledWith({ + actions: [ + { + action: 'perform-action', + perform_action: 'button.press', + target: { + entity_id: entityId, + }, + }, + ], + }); + }, + ); + + it('should also work with phase start for compatibility', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.tapo_c520ws_39d3_live_view', + }); + const camera = new TPLinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: ptzPopulatedEntityRegistryManager, + stateWatcher: mock(), + }); + const executor = mock(); + + await camera.executePTZAction(executor, 'left', { phase: 'start' }); + expect(executor.executeActions).toHaveBeenCalledWith({ + actions: [ + { + action: 'perform-action', + perform_action: 'button.press', + target: { + entity_id: 'button.tapo_c520ws_39d3_pan_left', + }, + }, + ], + }); + }); + + it('should use configured action when provided', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.tapo_c520ws_39d3_live_view', + ptz: { + actions_left_start: { + action: 'perform-action', + perform_action: 'button.press', + target: { + entity_id: 'button.custom_left', + }, + }, + }, + }); + const camera = new TPLinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([ + cameraEntity, + buttonEntityPanLeft, + ]), + 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.custom_left', + }, + }, + }); + }); + }); + + describe('should handle camera without PTZ unique_id pattern', () => { + it('should not find PTZ entities when unique_id has no _live_view suffix', async () => { + const cameraWithDifferentUniqueId = createRegistryEntity({ + entity_id: 'camera.tapo_c520ws_39d3', + unique_id: 'c520ws_39d3_something_else', + platform: 'tplink', + config_entry_id: 'tplink_config_entry_1', + }); + + const config = createCameraConfig({ + camera_entity: 'camera.tapo_c520ws_39d3', + }); + const camera = new TPLinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([ + cameraWithDifferentUniqueId, + buttonEntityPanLeft, + ]), + stateWatcher: mock(), + }); + + // Should not find PTZ entities since unique_id doesn't end with _live_view + expect(camera.getCapabilities()?.getPTZCapabilities()).toBeNull(); + }); + + it('should not find PTZ entities when camera has no unique_id', async () => { + const cameraWithoutUniqueId = createRegistryEntity({ + entity_id: 'camera.tapo_c520ws_39d3_live_view', + platform: 'tplink', + config_entry_id: 'tplink_config_entry_1', + }); + + const config = createCameraConfig({ + camera_entity: 'camera.tapo_c520ws_39d3_live_view', + }); + const camera = new TPLinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([ + cameraWithoutUniqueId, + buttonEntityPanLeft, + ]), + stateWatcher: mock(), + }); + + // Should not find PTZ entities since camera has no unique_id + expect(camera.getCapabilities()?.getPTZCapabilities()).toBeNull(); + }); + }); + + describe('should handle partial PTZ button availability', () => { + it('should return false for direction without matching button', async () => { + // Only has pan_left button, trying to execute pan_right + const config = createCameraConfig({ + camera_entity: 'camera.tapo_c520ws_39d3_live_view', + }); + const camera = new TPLinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([ + cameraEntity, + buttonEntityPanLeft, // Only left button available + ]), + stateWatcher: mock(), + }); + const executor = mock(); + + // Left should work + expect( + await camera.executePTZAction(executor, 'left', { phase: 'start' }), + ).toBeTruthy(); + expect(executor.executeActions).toHaveBeenCalledTimes(1); + + // Right should fail (no button for it) + expect( + await camera.executePTZAction(executor, 'right', { phase: 'start' }), + ).toBeFalsy(); + expect(executor.executeActions).toHaveBeenCalledTimes(1); // Still only 1 call + }); + }); +}); diff --git a/tests/camera-manager/tplink/engine-tplink.test.ts b/tests/camera-manager/tplink/engine-tplink.test.ts new file mode 100644 index 00000000..274cd939 --- /dev/null +++ b/tests/camera-manager/tplink/engine-tplink.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { TPLinkCameraManagerEngine } from '../../../src/camera-manager/tplink/engine-tplink'; +import { Engine } from '../../../src/camera-manager/types'; +import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; +import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock'; +import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils'; + +const createEngine = (options?: { + entityRegistryManager?: EntityRegistryManagerMock; +}): TPLinkCameraManagerEngine => { + return new TPLinkCameraManagerEngine( + options?.entityRegistryManager ?? new EntityRegistryManagerMock(), + mock(), + ); +}; + +const cameraEntity = createRegistryEntity({ + entity_id: 'camera.tapo_c520ws_39d3_live_view', + unique_id: '80115E1CF270233D6FC2FCD4028181A7206CDB30-live_view', + platform: 'tplink', + config_entry_id: 'tplink_config_entry_1', +}); + +const createPopulatedEngine = (): TPLinkCameraManagerEngine => { + const entityRegistryManager = new EntityRegistryManagerMock([cameraEntity]); + return createEngine({ entityRegistryManager }); +}; + +describe('TPLinkCameraManagerEngine', () => { + it('should get correct engine type', () => { + const engine = createEngine(); + expect(engine.getEngineType()).toBe(Engine.TPLink); + }); + + it('should create camera', async () => { + const engine = createPopulatedEngine(); + const config = createCameraConfig({ + camera_entity: 'camera.tapo_c520ws_39d3_live_view', + id: 'tapo_office', + }); + + const camera = await engine.createCamera(createHASS(), config); + + expect(camera.getConfig()).toBe(config); + expect(camera.getEngine()).toBe(engine); + expect(camera.getCapabilities()?.getRawCapabilities()).toEqual({ + 'favorite-events': false, + 'favorite-recordings': false, + clips: false, + 'remote-control-entity': true, + live: true, + menu: true, + recordings: false, + seek: false, + snapshots: false, + substream: true, + trigger: true, + }); + }); + + it('should get camera metadata with tplink icon', () => { + const cameraConfig = createCameraConfig({ + title: 'Tapo Office', + camera_entity: 'camera.tapo_c520ws_39d3_live_view', + icon: 'mdi:camera', + }); + const engine = createEngine(); + expect(engine.getCameraMetadata(createHASS(), cameraConfig)).toEqual({ + engineIcon: 'tplink', + icon: { + icon: 'mdi:camera', + entity: 'camera.tapo_c520ws_39d3_live_view', + fallback: 'mdi:video', + }, + title: 'Tapo Office', + }); + }); + + it('should get camera metadata with auto-detected title', () => { + const cameraConfig = createCameraConfig({ + camera_entity: 'camera.tapo_c520ws_39d3_live_view', + }); + const hass = createHASS({ + 'camera.tapo_c520ws_39d3_live_view': { + entity_id: 'camera.tapo_c520ws_39d3_live_view', + state: 'idle', + attributes: { + friendly_name: 'Tapo C520WS Live View', + }, + last_changed: '', + last_updated: '', + context: { id: '', user_id: null, parent_id: null }, + }, + }); + const engine = createEngine(); + expect(engine.getCameraMetadata(hass, cameraConfig)).toEqual({ + engineIcon: 'tplink', + icon: { + entity: 'camera.tapo_c520ws_39d3_live_view', + fallback: 'mdi:video', + }, + title: 'Tapo C520WS Live View', + }); + }); +});