Files
advanced-camera-card/src/camera-manager/reolink/camera.ts
T
Dermot Duffy 596a37903f 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 -->
2025-12-07 16:57:33 -08:00

322 lines
10 KiB
TypeScript

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 { localize } from '../../localize/localize';
import { PTZCapabilities, PTZMovementType } from '../../types';
import { createSelectOptionAction } from '../../utils/action.js';
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';
// Reolink channels are zero indexed.
const REOLINK_DEFAULT_CHANNEL = 0;
interface ReolinkCameraInitializationOptions extends CameraInitializationOptions {
entityRegistryManager: EntityRegistryManager;
hass: HomeAssistant;
}
class ReolinkInitializationError extends CameraInitializationError {}
interface PTZEntities {
stop?: string;
left?: string;
right?: string;
up?: string;
down?: string;
zoom_in?: string;
zoom_out?: string;
presets?: string;
}
type PTZEntity = keyof PTZEntities;
export class ReolinkCamera extends EntityCamera {
// The HostID identifying the camera or NVR.
protected _reolinkHostID: string | null = null;
// For NVRs, the Camera UID.
protected _reolinkCameraUID: string | null = null;
// The channel number as used by the Reolink integration.
protected _reolinkChannel: number | null = null;
// Entities used for PTZ control.
protected _ptzEntities: PTZEntities | null = null;
public async initialize(options: ReolinkCameraInitializationOptions): Promise<Camera> {
await super.initialize(options);
this._initializeChannel();
await this._initializeCapabilities(
options.hass,
options.entityRegistryManager,
options.stateWatcher,
);
return this;
}
protected _initializeChannel(): void {
const uniqueID = this._entity?.unique_id;
// Reolink camera unique IDs are dual-mode, they may be in either of these
// forms:
// - Directly connected cameras: [HostID]_[Channel #]_[...]
// (e.g. `95270002FS8D4RUP_0_sub`)
// - NVR/Hub connected cameras: [HostID]_[Camera UID]_[...]
// (e.g. `9527000HXU4V1VHZ_9527000I7E5F1GYU_sub`)
//
// The channel number is always numeric and assumed to be <1000, see similar
// comparisons in the integration itself:
// https://github.com/home-assistant/core/blob/dev/homeassistant/components/reolink/media_source.py#L174
//
// In the latter form, the channel number cannot be inferred from the entity
// and must only be taken from the user config instead.
const match = uniqueID
? String(uniqueID).match(
/^(?<hostid>[A-Za-z0-9]+)_(?<channel_or_uid>[A-Za-z0-9]+)_/,
)
: null;
const hostid = match?.groups?.hostid ?? null;
const channelOrUID = match?.groups?.channel_or_uid ?? null;
if (hostid === null || channelOrUID === null) {
throw new ReolinkInitializationError(
localize('error.camera_initialization_reolink'),
this.getConfig(),
);
}
const channelCandidate = Number(channelOrUID);
const isValidChannel = !isNaN(channelCandidate) && channelCandidate <= 999;
const channel =
this._config.reolink.channel ??
(isValidChannel ? channelCandidate : REOLINK_DEFAULT_CHANNEL);
const reolinkCameraUID = !isValidChannel ? channelOrUID : null;
this._reolinkChannel = channel;
this._reolinkHostID = hostid;
this._reolinkCameraUID = reolinkCameraUID;
}
protected async _initializeCapabilities(
hass: HomeAssistant,
entityRegistry: EntityRegistryManager,
stateWatcher: StateWatcherSubscriptionInterface,
): Promise<void> {
const config = this.getConfig();
const configPTZCapabilities = getPTZCapabilitiesFromCameraConfig(this.getConfig());
this._ptzEntities = await this._getPTZEntities(hass, entityRegistry);
const reolinkPTZCapabilities = this._ptzEntities
? this._entitiesToCapabilities(hass, this._ptzEntities)
: null;
const combinedPTZCapabilities: PTZCapabilities | null =
configPTZCapabilities || reolinkPTZCapabilities
? {
...reolinkPTZCapabilities,
...configPTZCapabilities,
}
: null;
this._capabilities = new Capabilities(
{
'favorite-events': false,
'favorite-recordings': false,
'remote-control-entity': true,
clips: true,
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 _entitiesToCapabilities(
hass: HomeAssistant,
ptzEntities: PTZEntities,
): PTZCapabilities | null {
const reolinkPTZCapabilities: PTZCapabilities = {};
for (const key of Object.keys(ptzEntities)) {
switch (key) {
case 'left':
case 'right':
case 'up':
case 'down':
reolinkPTZCapabilities[key] = [PTZMovementType.Continuous];
break;
case 'zoom_in':
reolinkPTZCapabilities.zoomIn = [PTZMovementType.Continuous];
break;
case 'zoom_out':
reolinkPTZCapabilities.zoomOut = [PTZMovementType.Continuous];
break;
}
}
const ptzPresetsEntityState = ptzEntities?.presets
? hass.states[ptzEntities.presets]
: null;
if (Array.isArray(ptzPresetsEntityState?.attributes.options)) {
reolinkPTZCapabilities.presets = ptzPresetsEntityState.attributes.options;
}
/* istanbul ignore next: this path cannot be reached as ptzEntities will
always have contents when this function is called -- @preserve */
return Object.keys(reolinkPTZCapabilities).length ? reolinkPTZCapabilities : null;
}
protected async _getPTZEntities(
hass: HomeAssistant,
entityRegistry: EntityRegistryManager,
): Promise<PTZEntities | null> {
/* istanbul ignore next: this path cannot be reached as an exception is
thrown in initialize() if this value is not found -- @preserve */
if (!this._reolinkHostID) {
return null;
}
const uniqueIDPrefix = this._getPTZEntityUniqueIDPrefix();
const allRelevantEntities = await entityRegistry.getMatchingEntities(
hass,
(ent: Entity) =>
ent.config_entry_id === this._entity?.config_entry_id &&
!!ent.unique_id &&
String(ent.unique_id).startsWith(uniqueIDPrefix) &&
!ent.disabled_by,
);
const buttonEntities = allRelevantEntities.filter((ent: Entity) =>
ent.entity_id.startsWith('button.'),
);
const ptzPresetEntities = allRelevantEntities.filter(
(ent: Entity) =>
ent.unique_id === `${uniqueIDPrefix}ptz_preset` &&
ent.entity_id.startsWith('select.'),
);
const uniqueSuffixes: PTZEntity[] = [
'stop',
'left',
'right',
'up',
'down',
'zoom_in',
'zoom_out',
];
const ptzEntities: PTZEntities = {};
for (const buttonEntity of buttonEntities) {
for (const uniqueIDSuffix of uniqueSuffixes) {
if (
buttonEntity.unique_id &&
String(buttonEntity.unique_id).endsWith(uniqueIDSuffix)
) {
ptzEntities[uniqueIDSuffix] = buttonEntity.entity_id;
}
}
}
if (ptzPresetEntities.length === 1) {
ptzEntities.presets = ptzPresetEntities[0].entity_id;
}
return Object.keys(ptzEntities).length ? ptzEntities : null;
}
public getChannel(): number | null {
return this._reolinkChannel;
}
protected _getPTZEntityUniqueIDPrefix(): string {
return `${this._reolinkHostID}_${this._reolinkCameraUID ?? this._reolinkChannel}_`;
}
public getProxyConfig(): CameraProxyConfig {
return {
...super.getProxyConfig(),
// For reolink, media is always proxied unless explicitly turned off.
media: this._config.proxy.media === 'auto' ? true : this._config.proxy.media,
// Reolink does not verify SSL certificates since they may be self-signed.
ssl_verification:
this._config.proxy.ssl_verification === 'auto'
? false
: this._config.proxy.ssl_verification,
// Through experimentation 'intermediate' is the "highest
// lowest-common-denominator" Reolink devices appear to support.
ssl_ciphers:
this._config.proxy.ssl_ciphers === 'auto'
? 'intermediate'
: this._config.proxy.ssl_ciphers,
};
}
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;
}
if (action === 'preset') {
const entityID = this._ptzEntities.presets;
const preset = options?.preset;
if (!preset || !entityID) {
return false;
}
await executor.executeActions({
actions: [createSelectOptionAction('select', entityID, preset)],
});
return true;
}
const entityID =
options?.phase === 'start'
? this._ptzEntities[action]
: options?.phase === 'stop'
? this._ptzEntities.stop
: null;
if (!entityID) {
return false;
}
await executor.executeActions({
actions: [
{
action: 'perform-action',
perform_action: 'button.press',
target: { entity_id: entityID },
},
],
});
return true;
}
}