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:
Dermot Duffy
2025-12-07 16:57:33 -08:00
committed by GitHub
parent 3279481b0e
commit 596a37903f
20 changed files with 917 additions and 88 deletions
@@ -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);
});
});
+462
View File
@@ -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',
});
});
});