feat: Add TPLink camera engine support (#2257)
* Related #2183 <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduce `tplink` camera engine with relative PTZ via button entities, add icons/docs support, refactor to `EntityCamera`, and extend tests/schema/factory for auto-detection. > > - **Engines** > - **New `tplink` engine**: > - Add `Engine.TPLink`, schema support (`engine: 'tplink'`). > - Implement `TPLinkCameraManagerEngine` and `TPLinkCamera` (relative PTZ via `button.*` entities; no presets/zoom; stop is no-op). > - Update `engine-factory` to create/auto-detect `tplink` (platform `tplink`). > - **Refactor** > - Replace `BrowseMediaCamera` with `EntityCamera`; update `motioneye` and `reolink` cameras and `motioneye` engine type checks. > - **UI/Icons** > - Add `tplink.svg`; wire into `IconController`; update docs icon copy script. > - **Docs** > - Add `tplink` to engine capability and live-provider matrices; new `tplink` section incl. PTZ note; add custom icon entry. > - **Tests** > - Add TPLink camera/engine tests and factory tests; remove obsolete `BrowseMediaCamera` test. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 74a688c3bd602912d63a22c3c6fa2f4b60ec14f9. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="256" height="256">
|
||||
<path d="M0 0 C26.14434533 -0.72288512 49.9600937 7.64611281 69.38671875 25.3125 C88.2717809 43.40139596 96.55079638 68.42490419 97.265625 94.09375 C96.94352486 121.47226222 86.49734687 145.15852541 67.640625 164.84375 C48.24601431 183.64464812 23.75721126 190.61232158 -2.734375 190.28125 C-7.436875 190.24 -12.139375 190.19875 -16.984375 190.15625 C-16.984375 172.33625 -16.984375 154.51625 -16.984375 136.15625 C-9.394375 135.82625 -1.804375 135.49625 6.015625 135.15625 C18.02265058 133.29309086 26.95933822 129.60855193 34.515625 119.78125 C41.36238612 109.58811688 43.30819935 98.25170438 41.58984375 86.16015625 C39.51844005 76.38770565 35.29278822 68.22349741 27.203125 62.15625 C16.80968654 55.65840316 4.80544663 54.2307569 -7.140625 56.84765625 C-18.33687784 60.60594899 -26.67094881 67.09315849 -32.26953125 77.55859375 C-35.05540172 83.55969825 -36.08102174 88.92660162 -36.30078125 95.55078125 C-36.33888916 96.63464111 -36.37699707 97.71850098 -36.41625977 98.83520508 C-36.45936279 100.1992749 -36.50246582 101.56334473 -36.546875 102.96875 C-36.69125 107.320625 -36.835625 111.6725 -36.984375 116.15625 C-54.804375 116.15625 -72.624375 116.15625 -90.984375 116.15625 C-91.5861874 83.05656809 -88.43663153 54.43892655 -64.984375 29.15625 C-47.57427445 11.32086455 -24.75288588 1.29596261 0 0 Z " fill="#48C8D3" transform="translate(155.984375,0.84375)"/>
|
||||
<path d="M0 0 C39.93 0 79.86 0 121 0 C121 39.93 121 79.86 121 121 C71 121 71 121 66 117 C63.85529247 113.85132353 63.7556918 111.25918777 63.77294922 107.49707031 C63.77310028 106.39623108 63.77325134 105.29539185 63.77340698 104.16119385 C63.78372955 102.97878113 63.79405212 101.79636841 63.8046875 100.578125 C63.8075174 99.36300232 63.81034729 98.14787964 63.81326294 96.89593506 C63.82446046 93.01385665 63.84956466 89.13200871 63.875 85.25 C63.88503034 82.61849291 63.89415609 79.98698221 63.90234375 77.35546875 C63.92439843 70.90356716 63.95787196 64.45180005 64 58 C63.04612648 58.00999275 63.04612648 58.00999275 62.07298279 58.02018738 C55.4379483 58.08669947 48.80300897 58.13186656 42.16772461 58.16479492 C39.6934016 58.1798556 37.21910518 58.20030825 34.74487305 58.22631836 C31.1827131 58.2628203 27.620907 58.27975404 24.05859375 58.29296875 C22.95728622 58.30845261 21.8559787 58.32393646 20.72129822 58.33988953 C14.69081959 58.34120274 10.28924103 57.9927906 5 55 C-8.49681274 40.79282869 0 18.01550479 0 0 Z " fill="#49C8D3" transform="translate(0,135)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -11,3 +11,4 @@ builtin custom items not found in the traditional [Material Design Icons
|
||||
| `iris` |  |
|
||||
| `motioneye` |  |
|
||||
| `reolink` |  |
|
||||
| `tplink` |  |
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<Camera> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Camera> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="256" height="256">
|
||||
<path d="M0 0 C26.14434533 -0.72288512 49.9600937 7.64611281 69.38671875 25.3125 C88.2717809 43.40139596 96.55079638 68.42490419 97.265625 94.09375 C96.94352486 121.47226222 86.49734687 145.15852541 67.640625 164.84375 C48.24601431 183.64464812 23.75721126 190.61232158 -2.734375 190.28125 C-7.436875 190.24 -12.139375 190.19875 -16.984375 190.15625 C-16.984375 172.33625 -16.984375 154.51625 -16.984375 136.15625 C-9.394375 135.82625 -1.804375 135.49625 6.015625 135.15625 C18.02265058 133.29309086 26.95933822 129.60855193 34.515625 119.78125 C41.36238612 109.58811688 43.30819935 98.25170438 41.58984375 86.16015625 C39.51844005 76.38770565 35.29278822 68.22349741 27.203125 62.15625 C16.80968654 55.65840316 4.80544663 54.2307569 -7.140625 56.84765625 C-18.33687784 60.60594899 -26.67094881 67.09315849 -32.26953125 77.55859375 C-35.05540172 83.55969825 -36.08102174 88.92660162 -36.30078125 95.55078125 C-36.33888916 96.63464111 -36.37699707 97.71850098 -36.41625977 98.83520508 C-36.45936279 100.1992749 -36.50246582 101.56334473 -36.546875 102.96875 C-36.69125 107.320625 -36.835625 111.6725 -36.984375 116.15625 C-54.804375 116.15625 -72.624375 116.15625 -90.984375 116.15625 C-91.5861874 83.05656809 -88.43663153 54.43892655 -64.984375 29.15625 C-47.57427445 11.32086455 -24.75288588 1.29596261 0 0 Z " fill="#48C8D3" transform="translate(155.984375,0.84375)"/>
|
||||
<path d="M0 0 C39.93 0 79.86 0 121 0 C121 39.93 121 79.86 121 121 C71 121 71 121 66 117 C63.85529247 113.85132353 63.7556918 111.25918777 63.77294922 107.49707031 C63.77310028 106.39623108 63.77325134 105.29539185 63.77340698 104.16119385 C63.78372955 102.97878113 63.79405212 101.79636841 63.8046875 100.578125 C63.8075174 99.36300232 63.81034729 98.14787964 63.81326294 96.89593506 C63.82446046 93.01385665 63.84956466 89.13200871 63.875 85.25 C63.88503034 82.61849291 63.89415609 79.98698221 63.90234375 77.35546875 C63.92439843 70.90356716 63.95787196 64.45180005 64 58 C63.04612648 58.00999275 63.04612648 58.00999275 62.07298279 58.02018738 C55.4379483 58.08669947 48.80300897 58.13186656 42.16772461 58.16479492 C39.6934016 58.1798556 37.21910518 58.20030825 34.74487305 58.22631836 C31.1827131 58.2628203 27.620907 58.27975404 24.05859375 58.29296875 C22.95728622 58.30845261 21.8559787 58.32393646 20.72129822 58.33988953 C14.69081959 58.34120274 10.28924103 57.9927906 5 55 C-8.49681274 40.79282869 0 18.01550479 0 0 Z " fill="#49C8D3" transform="translate(0,135)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -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<Camera> {
|
||||
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<void> {
|
||||
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<PTZEntities | null> {
|
||||
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<string, PTZEntity> = {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<Camera> {
|
||||
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export enum Engine {
|
||||
Generic = 'generic',
|
||||
MotionEye = 'motioneye',
|
||||
Reolink = 'reolink',
|
||||
TPLink = 'tplink',
|
||||
}
|
||||
|
||||
export interface CameraQuery {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<CameraManagerEngine>(),
|
||||
);
|
||||
|
||||
expect(
|
||||
async () =>
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
}),
|
||||
).rejects.toThrowError(/Could not find camera entity/);
|
||||
});
|
||||
|
||||
it('with a camera_entity', async () => {
|
||||
const camera = new BrowseMediaCamera(
|
||||
createCameraConfig({
|
||||
camera_entity: 'camera.foo',
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const entityRegistryManager = mock<EntityRegistryManager>();
|
||||
const entity = mock<Entity>();
|
||||
entityRegistryManager.getEntity.mockResolvedValue(entity);
|
||||
|
||||
expect(
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
entityRegistryManager: entityRegistryManager,
|
||||
}),
|
||||
).toBe(camera);
|
||||
expect(camera.getEntity()).toBe(entity);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<StateWatcherSubscriptionInterface>(),
|
||||
resolvedMediaCache: mock<ResolvedMediaCache>(),
|
||||
}),
|
||||
).toBeInstanceOf(TPLinkCameraManagerEngine);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<CameraManagerEngine>());
|
||||
|
||||
expect(
|
||||
async () =>
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock(),
|
||||
stateWatcher: mock<StateWatcher>(),
|
||||
}),
|
||||
).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<CameraManagerEngine>());
|
||||
|
||||
expect(
|
||||
async () =>
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock(),
|
||||
stateWatcher: mock<StateWatcher>(),
|
||||
}),
|
||||
).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<CameraManagerEngine>());
|
||||
|
||||
const entityRegistryManager = new EntityRegistryManagerMock([cameraEntity]);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
entityRegistryManager,
|
||||
stateWatcher: mock<StateWatcher>(),
|
||||
});
|
||||
|
||||
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<CameraManagerEngine>());
|
||||
|
||||
const entityRegistryManager = new EntityRegistryManagerMock([cameraEntity]);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
entityRegistryManager,
|
||||
stateWatcher: mock<StateWatcher>(),
|
||||
});
|
||||
|
||||
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<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
stateWatcher: mock<StateWatcher>(),
|
||||
});
|
||||
|
||||
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<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
stateWatcher: mock<StateWatcher>(),
|
||||
});
|
||||
|
||||
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<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]),
|
||||
stateWatcher: mock<StateWatcher>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
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<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
stateWatcher: mock<StateWatcher>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
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<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
stateWatcher: mock<StateWatcher>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
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<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
stateWatcher: mock<StateWatcher>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
// 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<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
stateWatcher: mock<StateWatcher>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
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<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
stateWatcher: mock<StateWatcher>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
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<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
buttonEntityPanLeft,
|
||||
]),
|
||||
stateWatcher: mock<StateWatcher>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
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<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraWithDifferentUniqueId,
|
||||
buttonEntityPanLeft,
|
||||
]),
|
||||
stateWatcher: mock<StateWatcher>(),
|
||||
});
|
||||
|
||||
// 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<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraWithoutUniqueId,
|
||||
buttonEntityPanLeft,
|
||||
]),
|
||||
stateWatcher: mock<StateWatcher>(),
|
||||
});
|
||||
|
||||
// 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<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
buttonEntityPanLeft, // Only left button available
|
||||
]),
|
||||
stateWatcher: mock<StateWatcher>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
// 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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<StateWatcher>(),
|
||||
);
|
||||
};
|
||||
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user