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 { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize'; import { localize } from '../../localize/localize';
import { PTZCapabilities, PTZMovementType } from '../../types'; import { PTZCapabilities, PTZMovementType } from '../../types';
import { createSelectOptionAction } from '../../utils/action.js';
import { Entity, EntityRegistryManager } from '../../utils/ha/registry/entity/types'; import { Entity, EntityRegistryManager } from '../../utils/ha/registry/entity/types';
import { BrowseMediaCamera } from '../browse-media/camera'; import { BrowseMediaCamera } from '../browse-media/camera';
import { Camera, CameraInitializationOptions } from '../camera'; import { Camera, CameraInitializationOptions } from '../camera';
@@ -18,7 +19,7 @@ interface ReolinkCameraInitializationOptions extends CameraInitializationOptions
class ReolinkInitializationError extends CameraInitializationError {} class ReolinkInitializationError extends CameraInitializationError {}
interface PTZButtonEntities { interface PTZEntities {
stop?: string; stop?: string;
left?: string; left?: string;
right?: string; right?: string;
@@ -26,13 +27,14 @@ interface PTZButtonEntities {
down?: string; down?: string;
zoom_in?: string; zoom_in?: string;
zoom_out?: string; zoom_out?: string;
presets?: string;
} }
type PTZButton = keyof PTZButtonEntities; type PTZEntity = keyof PTZEntities;
export class ReolinkCamera extends BrowseMediaCamera { export class ReolinkCamera extends BrowseMediaCamera {
protected _channel: number | null = null; protected _channel: number | null = null;
protected _reolinkUniqueID: string | null = null; protected _reolinkUniqueID: string | null = null;
protected _ptzButtons: PTZButtonEntities | null = null; protected _ptzEntities: PTZEntities | null = null;
public async initialize(options: ReolinkCameraInitializationOptions): Promise<Camera> { public async initialize(options: ReolinkCameraInitializationOptions): Promise<Camera> {
await super.initialize(options); await super.initialize(options);
@@ -65,30 +67,14 @@ export class ReolinkCamera extends BrowseMediaCamera {
entityRegistry: EntityRegistryManager, entityRegistry: EntityRegistryManager,
): Promise<void> { ): Promise<void> {
const config = this.getConfig(); const config = this.getConfig();
const ptzButtons = await this._getPTZButtons(hass, entityRegistry);
const configPTZCapabilities = getPTZCapabilitiesFromCameraConfig(this.getConfig()); const configPTZCapabilities = getPTZCapabilitiesFromCameraConfig(this.getConfig());
const ptzEntities = await this._getPTZEntities(hass, entityRegistry);
const reolinkPTZCapabilities: PTZCapabilities = {}; const reolinkPTZCapabilities = ptzEntities
for (const key of Object.keys(ptzButtons ?? {})) { ? this._entitiesToCapabilities(hass, ptzEntities)
switch (key) { : null;
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 combinedPTZCapabilities: PTZCapabilities | null = const combinedPTZCapabilities: PTZCapabilities | null =
configPTZCapabilities || Object.keys(reolinkPTZCapabilities).length configPTZCapabilities || reolinkPTZCapabilities
? { ? {
...reolinkPTZCapabilities, ...reolinkPTZCapabilities,
...configPTZCapabilities, ...configPTZCapabilities,
@@ -115,13 +101,47 @@ export class ReolinkCamera extends BrowseMediaCamera {
disableExcept: config.capabilities?.disable_except, 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, hass: HomeAssistant,
entityRegistry: EntityRegistryManager, entityRegistry: EntityRegistryManager,
): Promise<PTZButtonEntities | null> { ): Promise<PTZEntities | null> {
/* istanbul ignore next: this path cannot be reached as an exception is /* istanbul ignore next: this path cannot be reached as an exception is
thrown in initialize() if this value is not found -- @preserve */ thrown in initialize() if this value is not found -- @preserve */
if (!this._reolinkUniqueID) { if (!this._reolinkUniqueID) {
@@ -129,17 +149,24 @@ export class ReolinkCamera extends BrowseMediaCamera {
} }
const uniqueIDPrefix = `${this._reolinkUniqueID}_${this._channel}_`; const uniqueIDPrefix = `${this._reolinkUniqueID}_${this._channel}_`;
const buttonEntities = await entityRegistry.getMatchingEntities( const allRelevantEntities = await entityRegistry.getMatchingEntities(
hass, hass,
(ent: Entity) => (ent: Entity) =>
ent.config_entry_id === this._entity?.config_entry_id && ent.config_entry_id === this._entity?.config_entry_id &&
!!ent.unique_id && !!ent.unique_id &&
String(ent.unique_id).startsWith(uniqueIDPrefix) && String(ent.unique_id).startsWith(uniqueIDPrefix) &&
!ent.disabled_by && !ent.disabled_by,
ent.entity_id.startsWith('button.'), );
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', 'stop',
'left', 'left',
'right', 'right',
@@ -149,19 +176,23 @@ export class ReolinkCamera extends BrowseMediaCamera {
'zoom_out', 'zoom_out',
]; ];
const buttons: PTZButtonEntities = {}; const ptzEntities: PTZEntities = {};
for (const buttonEntity of buttonEntities) { for (const buttonEntity of buttonEntities) {
for (const uniqueIDSuffix of uniqueSuffixes) { for (const uniqueIDSuffix of uniqueSuffixes) {
if ( if (
buttonEntity.unique_id && buttonEntity.unique_id &&
String(buttonEntity.unique_id).endsWith(uniqueIDSuffix) 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 { public getChannel(): number | null {
@@ -202,11 +233,28 @@ export class ReolinkCamera extends BrowseMediaCamera {
return true; 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 = const entityID =
options?.phase === 'start' options?.phase === 'start'
? this._ptzButtons?.[action] ? this._ptzEntities[action]
: options?.phase === 'stop' : options?.phase === 'stop'
? this._ptzButtons?.stop ? this._ptzEntities.stop
: null; : null;
if (!entityID) { if (!entityID) {
return false; return false;
@@ -3,7 +3,7 @@ import { RemoteControlEntityPriority } from '../../config/schema/remote-control'
import { import {
createCameraAction, createCameraAction,
createInternalCallbackAction, createInternalCallbackAction,
createPerformAction, createSelectOptionAction,
} from '../../utils/action'; } from '../../utils/action';
import { CardActionsAPI, CardConfigLoaderAPI, TaggedAutomation } from '../types'; import { CardActionsAPI, CardConfigLoaderAPI, TaggedAutomation } from '../types';
@@ -21,16 +21,6 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
const cameraPriority: RemoteControlEntityPriority = const cameraPriority: RemoteControlEntityPriority =
remoteControlConfig.entities.camera_priority; remoteControlConfig.entities.camera_priority;
const createSelectOptionAction = (option: string) =>
createPerformAction('input_select.select_option', {
target: {
entity_id: cameraControlEntity,
},
data: {
option: option,
},
});
// Control entities functionality is implemented entirely by populating // Control entities functionality is implemented entirely by populating
// automations. // automations.
@@ -59,7 +49,11 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
], ],
actions: [ actions: [
// When the camera changes, update the entity to match. // When the camera changes, update the entity to match.
createSelectOptionAction('{{ advanced_camera_card.trigger.camera.to }}'), createSelectOptionAction(
'input_select',
cameraControlEntity,
'{{ advanced_camera_card.trigger.camera.to }}',
),
], ],
tag: automationTag, tag: automationTag,
}, },
@@ -82,7 +76,11 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
`{{ hass.states["${cameraControlEntity}"].state }}`, `{{ hass.states["${cameraControlEntity}"].state }}`,
) )
: // Set the selected option in the entity to the current camera ID. : // Set the selected option in the entity to the current camera ID.
createSelectOptionAction('{{ advanced_camera_card.camera }}'), createSelectOptionAction(
'input_select',
cameraControlEntity,
'{{ advanced_camera_card.camera }}',
),
], ],
tag: automationTag, tag: automationTag,
}, },
+5 -13
View File
@@ -10,9 +10,11 @@ import { customElement, property, state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js'; import { styleMap } from 'lit/directives/style-map.js';
import { MenuSubmenuSelect } from '../../config/schema/elements/custom/menu/submenu-select.js'; import { MenuSubmenuSelect } from '../../config/schema/elements/custom/menu/submenu-select.js';
import { MenuSubmenuItem } from '../../config/schema/elements/custom/menu/submenu.js'; import { MenuSubmenuItem } from '../../config/schema/elements/custom/menu/submenu.js';
import { computeDomain } from '../../ha/compute-domain.js';
import { HomeAssistant } from '../../ha/types.js'; import { HomeAssistant } from '../../ha/types.js';
import menuButtonStyle from '../../scss/menu-button.scss'; import menuButtonStyle from '../../scss/menu-button.scss';
import { Icon } from '../../types.js'; import { Icon } from '../../types.js';
import { createSelectOptionAction } from '../../utils/action.js';
import { getEntityTitle, isHassDifferent } from '../../utils/ha'; import { getEntityTitle, isHassDifferent } from '../../utils/ha';
import { getEntityStateTranslation } from '../../utils/ha/entity-state-translation.js'; import { getEntityStateTranslation } from '../../utils/ha/entity-state-translation.js';
import { EntityRegistryManager } from '../../utils/ha/registry/entity/types.js'; import { EntityRegistryManager } from '../../utils/ha/registry/entity/types.js';
@@ -83,6 +85,7 @@ export class AdvancedCameraCardSubmenuSelectButton extends LitElement {
} }
const entityID = this.submenuSelect.entity; const entityID = this.submenuSelect.entity;
const entityDomain = computeDomain(entityID);
const stateObj = this.hass.states[entityID]; const stateObj = this.hass.states[entityID];
const options = stateObj?.attributes?.options; const options = stateObj?.attributes?.options;
if (!stateObj || !options) { if (!stateObj || !options) {
@@ -98,19 +101,8 @@ export class AdvancedCameraCardSubmenuSelectButton extends LitElement {
selected: stateObj.state === option, selected: stateObj.state === option,
enabled: true, enabled: true,
title: title || option, title: title || option,
...((entityID.startsWith('select.') || entityID.startsWith('input_select.')) && { ...((entityDomain === 'select' || entityDomain === 'input_select') && {
tap_action: { tap_action: createSelectOptionAction(entityDomain, entityID, option),
action: 'perform-action',
perform_action: entityID.startsWith('select.')
? 'select.select_option'
: 'input_select.select_option',
target: {
entity_id: entityID,
},
data: {
option: option,
},
},
}), }),
// Apply overrides the user may have specified for a given option. // Apply overrides the user may have specified for a given option.
...(this.submenuSelect.options && this.submenuSelect.options[option]), ...(this.submenuSelect.options && this.submenuSelect.options[option]),
+19
View File
@@ -218,6 +218,25 @@ export function createPerformAction(
}; };
} }
export function createSelectOptionAction(
domain: 'select' | 'input_select',
entityID: string,
option: string,
options?: {
cardID?: string;
},
): PerformActionActionConfig {
return createPerformAction(`${domain}.select_option`, {
...options,
target: {
entity_id: entityID,
},
data: {
option: option,
},
});
}
/** /**
* Get an action configuration given a config and an interaction (e.g. 'tap'). * Get an action configuration given a config and an interaction (e.g. 'tap').
* @param interaction The interaction: `tap`, `hold` or `double_tap` * @param interaction The interaction: `tap`, `hold` or `double_tap`
+115 -1
View File
@@ -7,7 +7,12 @@ import { ActionsExecutor } from '../../../src/card-controller/actions/types';
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
import { ProxyConfig } from '../../../src/config/schema/cameras'; import { ProxyConfig } from '../../../src/config/schema/cameras';
import { EntityRegistryManagerLive } from '../../../src/utils/ha/registry/entity'; import { EntityRegistryManagerLive } from '../../../src/utils/ha/registry/entity';
import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils'; import {
createCameraConfig,
createHASS,
createRegistryEntity,
createStateEntity,
} from '../../test-utils';
import { EntityRegistryManagerMock } from '../../utils/ha/registry/entity/mock'; import { EntityRegistryManagerMock } from '../../utils/ha/registry/entity/mock';
describe('ReolinkCamera', () => { describe('ReolinkCamera', () => {
@@ -51,6 +56,11 @@ describe('ReolinkCamera', () => {
unique_id: '85270002TS7D4RUP_0_ptz_stop', unique_id: '85270002TS7D4RUP_0_ptz_stop',
platform: 'reolink', platform: 'reolink',
}); });
const selectEntityPTZ = createRegistryEntity({
entity_id: 'select.office_reolink_ptz_preset',
unique_id: '85270002TS7D4RUP_0_ptz_preset',
platform: 'reolink',
});
const ptzPopulatedEntityRegistryManager = new EntityRegistryManagerMock([ const ptzPopulatedEntityRegistryManager = new EntityRegistryManagerMock([
cameraEntity, cameraEntity,
@@ -61,6 +71,7 @@ describe('ReolinkCamera', () => {
buttonEntityPTZZoomIn, buttonEntityPTZZoomIn,
buttonEntityPTZZoomOut, buttonEntityPTZZoomOut,
buttonEntityPTZStop, buttonEntityPTZStop,
selectEntityPTZ,
// Unrelated button. // Unrelated button.
createRegistryEntity({ createRegistryEntity({
@@ -178,6 +189,36 @@ describe('ReolinkCamera', () => {
}); });
}); });
it('should find PTZ select entity', async () => {
const config = createCameraConfig({
camera_entity: 'camera.office_reolink',
});
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
await camera.initialize({
hass: createHASS({
'select.office_reolink_ptz_preset': createStateEntity({
state: 'foo',
attributes: {
options: ['preset-one', 'preset-two'],
},
}),
}),
entityRegistryManager: ptzPopulatedEntityRegistryManager,
stateWatcher: mock<StateWatcher>(),
});
expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({
left: ['continuous'],
right: ['continuous'],
up: ['continuous'],
down: ['continuous'],
zoomIn: ['continuous'],
zoomOut: ['continuous'],
presets: ['preset-one', 'preset-two'],
});
});
it('should allow configured PTZ actions to override', async () => { it('should allow configured PTZ actions to override', async () => {
const config = createCameraConfig({ const config = createCameraConfig({
camera_entity: 'camera.office_reolink', camera_entity: 'camera.office_reolink',
@@ -428,5 +469,78 @@ describe('ReolinkCamera', () => {
], ],
}); });
}); });
it('should ignore relative actions', async () => {
const config = createCameraConfig({
camera_entity: 'camera.office_reolink',
});
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
await camera.initialize({
hass: createHASS(),
entityRegistryManager: ptzPopulatedEntityRegistryManager,
stateWatcher: mock<StateWatcher>(),
});
const executor = mock<ActionsExecutor>();
await camera.executePTZAction(executor, 'left');
expect(executor.executeActions).not.toHaveBeenCalled();
});
describe('should execute preset', () => {
it('for existing preset', async () => {
const config = createCameraConfig({
camera_entity: 'camera.office_reolink',
});
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
await camera.initialize({
hass: createHASS({
'select.office_reolink_ptz_preset': createStateEntity({
state: 'foo',
attributes: {
options: ['preset-one', 'preset-two'],
},
}),
}),
entityRegistryManager: ptzPopulatedEntityRegistryManager,
stateWatcher: mock<StateWatcher>(),
});
const executor = mock<ActionsExecutor>();
await camera.executePTZAction(executor, 'preset', { preset: 'preset-two' });
expect(executor.executeActions).toHaveBeenLastCalledWith({
actions: [
{
action: 'perform-action',
perform_action: 'select.select_option',
target: {
entity_id: 'select.office_reolink_ptz_preset',
},
data: {
option: 'preset-two',
},
},
],
});
});
it('for non-existant preset', async () => {
const config = createCameraConfig({
camera_entity: 'camera.office_reolink',
});
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
await camera.initialize({
hass: createHASS(),
entityRegistryManager: ptzPopulatedEntityRegistryManager,
stateWatcher: mock<StateWatcher>(),
});
const executor = mock<ActionsExecutor>();
await camera.executePTZAction(executor, 'preset');
expect(executor.executeActions).not.toHaveBeenCalled();
});
});
}); });
}); });
+19
View File
@@ -14,6 +14,7 @@ import {
createPTZControlsAction, createPTZControlsAction,
createPTZDigitalAction, createPTZDigitalAction,
createPTZMultiAction, createPTZMultiAction,
createSelectOptionAction,
createViewAction, createViewAction,
getActionConfigGivenAction, getActionConfigGivenAction,
hasAction, hasAction,
@@ -267,6 +268,24 @@ describe('createPerformAction', () => {
}); });
}); });
describe('createSelectOptionAction', () => {
it('should create select option action', () => {
expect(
createSelectOptionAction('select', 'select.foo', 'option', {
cardID: 'card_id',
}),
).toEqual({
action: 'perform-action',
perform_action: 'select.select_option',
card_id: 'card_id',
target: { entity_id: 'select.foo' },
data: {
option: 'option',
},
});
});
});
describe('getActionConfigGivenAction', () => { describe('getActionConfigGivenAction', () => {
const action = createViewAction('clips'); const action = createViewAction('clips');