diff --git a/src/camera-manager/reolink/camera.ts b/src/camera-manager/reolink/camera.ts index 9eb07e4e..ada9dc75 100644 --- a/src/camera-manager/reolink/camera.ts +++ b/src/camera-manager/reolink/camera.ts @@ -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 { await super.initialize(options); @@ -65,30 +67,14 @@ export class ReolinkCamera extends BrowseMediaCamera { entityRegistry: EntityRegistryManager, ): Promise { 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 { + ): Promise { /* 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; diff --git a/src/card-controller/config/load-control-entities.ts b/src/card-controller/config/load-control-entities.ts index 59498d1f..2f3c444b 100644 --- a/src/card-controller/config/load-control-entities.ts +++ b/src/card-controller/config/load-control-entities.ts @@ -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, }, diff --git a/src/components/submenu/select-button.ts b/src/components/submenu/select-button.ts index f603e50f..95a19ff6 100644 --- a/src/components/submenu/select-button.ts +++ b/src/components/submenu/select-button.ts @@ -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]), diff --git a/src/utils/action.ts b/src/utils/action.ts index 8f3c9bac..5defa5ef 100644 --- a/src/utils/action.ts +++ b/src/utils/action.ts @@ -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` diff --git a/tests/camera-manager/reolink/camera.test.ts b/tests/camera-manager/reolink/camera.test.ts index 818cacda..41d6d8d6 100644 --- a/tests/camera-manager/reolink/camera.test.ts +++ b/tests/camera-manager/reolink/camera.test.ts @@ -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()); + + await camera.initialize({ + hass: createHASS({ + 'select.office_reolink_ptz_preset': createStateEntity({ + state: 'foo', + attributes: { + options: ['preset-one', 'preset-two'], + }, + }), + }), + entityRegistryManager: ptzPopulatedEntityRegistryManager, + stateWatcher: mock(), + }); + + 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()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: ptzPopulatedEntityRegistryManager, + stateWatcher: mock(), + }); + const executor = mock(); + + 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()); + + await camera.initialize({ + hass: createHASS({ + 'select.office_reolink_ptz_preset': createStateEntity({ + state: 'foo', + attributes: { + options: ['preset-one', 'preset-two'], + }, + }), + }), + entityRegistryManager: ptzPopulatedEntityRegistryManager, + stateWatcher: mock(), + }); + const executor = mock(); + + 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()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: ptzPopulatedEntityRegistryManager, + stateWatcher: mock(), + }); + const executor = mock(); + + await camera.executePTZAction(executor, 'preset'); + expect(executor.executeActions).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/tests/utils/action.test.ts b/tests/utils/action.test.ts index 4b234dd0..1fee145e 100644 --- a/tests/utils/action.test.ts +++ b/tests/utils/action.test.ts @@ -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');