fix: Reolink PTZ support should detect presets (#1997)

- Related: #1964
This commit is contained in:
Dermot Duffy
2025-04-06 17:47:43 +01:00
committed by GitHub
parent e7a9e20c9e
commit a24c9628c5
6 changed files with 253 additions and 63 deletions
+84 -36
View File
@@ -3,6 +3,7 @@ import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/pt
import { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize';
import { PTZCapabilities, PTZMovementType } from '../../types';
import { createSelectOptionAction } from '../../utils/action.js';
import { Entity, EntityRegistryManager } from '../../utils/ha/registry/entity/types';
import { BrowseMediaCamera } from '../browse-media/camera';
import { Camera, CameraInitializationOptions } from '../camera';
@@ -18,7 +19,7 @@ interface ReolinkCameraInitializationOptions extends CameraInitializationOptions
class ReolinkInitializationError extends CameraInitializationError {}
interface PTZButtonEntities {
interface PTZEntities {
stop?: string;
left?: string;
right?: string;
@@ -26,13 +27,14 @@ interface PTZButtonEntities {
down?: string;
zoom_in?: string;
zoom_out?: string;
presets?: string;
}
type PTZButton = keyof PTZButtonEntities;
type PTZEntity = keyof PTZEntities;
export class ReolinkCamera extends BrowseMediaCamera {
protected _channel: number | null = null;
protected _reolinkUniqueID: string | null = null;
protected _ptzButtons: PTZButtonEntities | null = null;
protected _ptzEntities: PTZEntities | null = null;
public async initialize(options: ReolinkCameraInitializationOptions): Promise<Camera> {
await super.initialize(options);
@@ -65,30 +67,14 @@ export class ReolinkCamera extends BrowseMediaCamera {
entityRegistry: EntityRegistryManager,
): Promise<void> {
const config = this.getConfig();
const ptzButtons = await this._getPTZButtons(hass, entityRegistry);
const configPTZCapabilities = getPTZCapabilitiesFromCameraConfig(this.getConfig());
const reolinkPTZCapabilities: PTZCapabilities = {};
for (const key of Object.keys(ptzButtons ?? {})) {
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 ptzEntities = await this._getPTZEntities(hass, entityRegistry);
const reolinkPTZCapabilities = ptzEntities
? this._entitiesToCapabilities(hass, ptzEntities)
: null;
const combinedPTZCapabilities: PTZCapabilities | null =
configPTZCapabilities || Object.keys(reolinkPTZCapabilities).length
configPTZCapabilities || reolinkPTZCapabilities
? {
...reolinkPTZCapabilities,
...configPTZCapabilities,
@@ -115,13 +101,47 @@ export class ReolinkCamera extends BrowseMediaCamera {
disableExcept: config.capabilities?.disable_except,
},
);
this._ptzButtons = ptzButtons;
this._ptzEntities = ptzEntities;
}
protected async _getPTZButtons(
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<PTZButtonEntities | null> {
): 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._reolinkUniqueID) {
@@ -129,17 +149,24 @@ export class ReolinkCamera extends BrowseMediaCamera {
}
const uniqueIDPrefix = `${this._reolinkUniqueID}_${this._channel}_`;
const buttonEntities = await entityRegistry.getMatchingEntities(
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 &&
ent.entity_id.startsWith('button.'),
!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: PTZButton[] = [
const uniqueSuffixes: PTZEntity[] = [
'stop',
'left',
'right',
@@ -149,19 +176,23 @@ export class ReolinkCamera extends BrowseMediaCamera {
'zoom_out',
];
const buttons: PTZButtonEntities = {};
const ptzEntities: PTZEntities = {};
for (const buttonEntity of buttonEntities) {
for (const uniqueIDSuffix of uniqueSuffixes) {
if (
buttonEntity.unique_id &&
String(buttonEntity.unique_id).endsWith(uniqueIDSuffix)
) {
buttons[uniqueIDSuffix] = buttonEntity.entity_id;
ptzEntities[uniqueIDSuffix] = buttonEntity.entity_id;
}
}
}
return Object.keys(buttons).length ? buttons : null;
if (ptzPresetEntities.length === 1) {
ptzEntities.presets = ptzPresetEntities[0].entity_id;
}
return Object.keys(ptzEntities).length ? ptzEntities : null;
}
public getChannel(): number | null {
@@ -202,11 +233,28 @@ export class ReolinkCamera extends BrowseMediaCamera {
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._ptzButtons?.[action]
? this._ptzEntities[action]
: options?.phase === 'stop'
? this._ptzButtons?.stop
? this._ptzEntities.stop
: null;
if (!entityID) {
return false;