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
+173
View File
@@ -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;
}
}