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` |  |
| `motioneye` |  |
| `reolink` |  |
+| `tplink` |  |
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',
+ });
+ });
+});