fix: Reolink PTZ support should detect presets (#1997)
- Related: #1964
This commit is contained in:
@@ -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.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;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { RemoteControlEntityPriority } from '../../config/schema/remote-control'
|
||||
import {
|
||||
createCameraAction,
|
||||
createInternalCallbackAction,
|
||||
createPerformAction,
|
||||
createSelectOptionAction,
|
||||
} from '../../utils/action';
|
||||
import { CardActionsAPI, CardConfigLoaderAPI, TaggedAutomation } from '../types';
|
||||
|
||||
@@ -21,16 +21,6 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
|
||||
const cameraPriority: RemoteControlEntityPriority =
|
||||
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
|
||||
// automations.
|
||||
|
||||
@@ -59,7 +49,11 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
|
||||
],
|
||||
actions: [
|
||||
// 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,
|
||||
},
|
||||
@@ -82,7 +76,11 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
|
||||
`{{ hass.states["${cameraControlEntity}"].state }}`,
|
||||
)
|
||||
: // 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,
|
||||
},
|
||||
|
||||
@@ -10,9 +10,11 @@ import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { MenuSubmenuSelect } from '../../config/schema/elements/custom/menu/submenu-select.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 menuButtonStyle from '../../scss/menu-button.scss';
|
||||
import { Icon } from '../../types.js';
|
||||
import { createSelectOptionAction } from '../../utils/action.js';
|
||||
import { getEntityTitle, isHassDifferent } from '../../utils/ha';
|
||||
import { getEntityStateTranslation } from '../../utils/ha/entity-state-translation.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 entityDomain = computeDomain(entityID);
|
||||
const stateObj = this.hass.states[entityID];
|
||||
const options = stateObj?.attributes?.options;
|
||||
if (!stateObj || !options) {
|
||||
@@ -98,19 +101,8 @@ export class AdvancedCameraCardSubmenuSelectButton extends LitElement {
|
||||
selected: stateObj.state === option,
|
||||
enabled: true,
|
||||
title: title || option,
|
||||
...((entityID.startsWith('select.') || entityID.startsWith('input_select.')) && {
|
||||
tap_action: {
|
||||
action: 'perform-action',
|
||||
perform_action: entityID.startsWith('select.')
|
||||
? 'select.select_option'
|
||||
: 'input_select.select_option',
|
||||
target: {
|
||||
entity_id: entityID,
|
||||
},
|
||||
data: {
|
||||
option: option,
|
||||
},
|
||||
},
|
||||
...((entityDomain === 'select' || entityDomain === 'input_select') && {
|
||||
tap_action: createSelectOptionAction(entityDomain, entityID, option),
|
||||
}),
|
||||
// Apply overrides the user may have specified for a given option.
|
||||
...(this.submenuSelect.options && this.submenuSelect.options[option]),
|
||||
|
||||
@@ -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').
|
||||
* @param interaction The interaction: `tap`, `hold` or `double_tap`
|
||||
|
||||
@@ -7,7 +7,12 @@ import { ActionsExecutor } from '../../../src/card-controller/actions/types';
|
||||
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
|
||||
import { ProxyConfig } from '../../../src/config/schema/cameras';
|
||||
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';
|
||||
|
||||
describe('ReolinkCamera', () => {
|
||||
@@ -51,6 +56,11 @@ describe('ReolinkCamera', () => {
|
||||
unique_id: '85270002TS7D4RUP_0_ptz_stop',
|
||||
platform: 'reolink',
|
||||
});
|
||||
const selectEntityPTZ = createRegistryEntity({
|
||||
entity_id: 'select.office_reolink_ptz_preset',
|
||||
unique_id: '85270002TS7D4RUP_0_ptz_preset',
|
||||
platform: 'reolink',
|
||||
});
|
||||
|
||||
const ptzPopulatedEntityRegistryManager = new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
@@ -61,6 +71,7 @@ describe('ReolinkCamera', () => {
|
||||
buttonEntityPTZZoomIn,
|
||||
buttonEntityPTZZoomOut,
|
||||
buttonEntityPTZStop,
|
||||
selectEntityPTZ,
|
||||
|
||||
// Unrelated button.
|
||||
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 () => {
|
||||
const config = createCameraConfig({
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
createPTZControlsAction,
|
||||
createPTZDigitalAction,
|
||||
createPTZMultiAction,
|
||||
createSelectOptionAction,
|
||||
createViewAction,
|
||||
getActionConfigGivenAction,
|
||||
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', () => {
|
||||
const action = createViewAction('clips');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user