diff --git a/docs/configuration/remote-control.md b/docs/configuration/remote-control.md index 7fb10bcc..5ed071f4 100644 --- a/docs/configuration/remote-control.md +++ b/docs/configuration/remote-control.md @@ -17,16 +17,12 @@ remote_control: # [...] ``` -| Option | Default | Description | -| -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `camera` | | An `input_select` entity that the card will use for bidirectional control. When the selected camera on the card changes the entity will be updated to match. Likewise, when the entity state changes, the selected camera on the card will be updated to match. When the card is first started, the `input_select` entity will be updated to only have valid camera IDs from this card. Values must start with `input_select.`. | +| Option | Default | Description | +| ----------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `camera` | | An `input_select` entity that the card will use for bidirectional control. When the selected camera on the card changes the entity will be updated to match. Likewise, when the entity state changes, the selected camera on the card will be updated to match. When the card is first started, the `input_select` entity will be updated to only have valid camera IDs from this card and the selected camera on the card will be updated to the existing entity value. Entities used for camera remote control must start with `input_select.`. | +| `camera_priority` | `card` | Controls whether the `card` or the `entity` has priority on initial card load. If `card`, the entity state is updated to match the camera shown on load. If `entity`, the card will select the camera shown by the entity on load. | -?> To create an `input_select` entity to use in this manner, in the visual card -editor, under `Remote Control -> Remote Control Entities`, choose `Create a new -Dropdown helper`. Give the new entity an entity name (e.g. `my_selected_camera`) -and an optional icon. You must specify at least one option -- you can use any -placeholder value (e.g. `camera`) then choose `Add` (the card will automatically -reset the allowable options on start). Finally, click `Create`. +?> To create an `input_select` entity to use in this manner, in the visual card editor, under `Remote Control -> Remote Control Entities`, choose `Create a new Dropdown helper`. Give the new entity an entity name (e.g. `my_selected_camera`) and an optional icon. You must specify at least one option -- you can use any placeholder value (e.g. `camera`) then choose `Add` (the card will automatically reset the allowable options on start). Finally, click `Create`. ## Related Topics @@ -40,4 +36,5 @@ reset the allowable options on start). Finally, click `Create`. remote_control: entities: camera: input_select.my_selected_camera + camera_priority: card ``` diff --git a/src/card-controller/config/load-control-entities.ts b/src/card-controller/config/load-control-entities.ts index 2293ba46..59498d1f 100644 --- a/src/card-controller/config/load-control-entities.ts +++ b/src/card-controller/config/load-control-entities.ts @@ -1,3 +1,5 @@ +import { isEqual } from 'lodash-es'; +import { RemoteControlEntityPriority } from '../../config/schema/remote-control'; import { createCameraAction, createInternalCallbackAction, @@ -10,12 +12,15 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => { api.getAutomationsManager().deleteAutomations(automationTag); - const cameraControlEntity = api.getConfigManager().getConfig()?.remote_control - ?.entities?.camera; + const remoteControlConfig = api.getConfigManager().getConfig()?.remote_control; + const cameraControlEntity = remoteControlConfig?.entities.camera; if (!cameraControlEntity) { return; } + const cameraPriority: RemoteControlEntityPriority = + remoteControlConfig.entities.camera_priority; + const createSelectOptionAction = (option: string) => createPerformAction('input_select.select_option', { target: { @@ -34,7 +39,7 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => { conditions: [ { condition: 'config' as const, - paths: ['remote_control.entities.camera'], + paths: ['cameras', 'remote_control.entities.camera'], }, ], actions: [ @@ -43,8 +48,6 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => { createInternalCallbackAction((api: CardActionsAPI) => setCameraOptionsOnEntity(cameraControlEntity, api), ), - // Set the selected option to the current camera ID. - createSelectOptionAction('{{ advanced_camera_card.camera }}'), ], tag: automationTag, }, @@ -60,6 +63,29 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => { ], tag: automationTag, }, + { + // Immediately on the start, the HA state for the entity will be updated. + // However, that will almost certainly not trigger the condition below + // this one, as automations only run *after* the card is initialized (and + // it very likely will not yet be). Instead, wait to be initialized, then + // set the camera. + conditions: [ + { + condition: 'initialized' as const, + }, + ], + actions: [ + cameraPriority === 'entity' + ? // Set the currently selected camera to the state of the entity. + createCameraAction( + 'camera_select', + `{{ hass.states["${cameraControlEntity}"].state }}`, + ) + : // Set the selected option in the entity to the current camera ID. + createSelectOptionAction('{{ advanced_camera_card.camera }}'), + ], + tag: automationTag, + }, { conditions: [ { @@ -85,11 +111,18 @@ const setCameraOptionsOnEntity = async (entity: string, api: CardActionsAPI) => const hass = api.getHASSManager().getHASS(); const cameraIDs = api.getCameraManager().getStore().getCameraIDs(); + const existingOptions = (hass?.states[entity]?.attributes?.options ?? []).sort(); + const desiredOptions = [...cameraIDs].sort(); + + if (isEqual(existingOptions, desiredOptions)) { + return; + } + await hass?.callService( 'input_select', 'set_options', { - options: [...cameraIDs], + options: desiredOptions, }, { entity_id: entity, diff --git a/src/config/schema/remote-control.ts b/src/config/schema/remote-control.ts index 2b068afa..64a9282a 100644 --- a/src/config/schema/remote-control.ts +++ b/src/config/schema/remote-control.ts @@ -1,11 +1,23 @@ import { z } from 'zod'; +export const remoteControlConfigDefault = { + entities: { + camera_priority: 'card' as const, + }, +}; + +const entityPrioritySchema = z.enum(['card', 'entity']); +export type RemoteControlEntityPriority = z.infer; + export const remoteControlConfigSchema = z .object({ entities: z .object({ camera: z.string().startsWith('input_select.').optional(), + camera_priority: entityPrioritySchema.default( + remoteControlConfigDefault.entities.camera_priority, + ), }) - .optional(), + .default(remoteControlConfigDefault.entities), }) - .optional(); + .default(remoteControlConfigDefault); diff --git a/src/config/schema/types.ts b/src/config/schema/types.ts index 9ca0f408..7c7ba1f4 100644 --- a/src/config/schema/types.ts +++ b/src/config/schema/types.ts @@ -19,7 +19,7 @@ import { performanceConfigSchema, } from './performance'; import { profilesSchema } from './profiles'; -import { remoteControlConfigSchema } from './remote-control'; +import { remoteControlConfigDefault, remoteControlConfigSchema } from './remote-control'; import { statusBarConfigDefault, statusBarConfigSchema } from './status-bar'; import { timelineConfigSchema } from './timeline'; import { viewConfigDefault, viewConfigSchema } from './view'; @@ -84,4 +84,5 @@ export const configDefaults = { timeline: timelineConfigDefault, performance: performanceConfigDefault, debug: debugConfigDefault, + remote_control: remoteControlConfigDefault, }; diff --git a/tests/card-controller/config/load-control-entities.test.ts b/tests/card-controller/config/load-control-entities.test.ts index f18b9e0e..e2180a47 100644 --- a/tests/card-controller/config/load-control-entities.test.ts +++ b/tests/card-controller/config/load-control-entities.test.ts @@ -2,7 +2,13 @@ import { assert, describe, expect, it, vi } from 'vitest'; import { setRemoteControlEntityFromConfig } from '../../../src/card-controller/config/load-control-entities'; import { INTERNAL_CALLBACK_ACTION } from '../../../src/config/schema/actions/custom/internal'; import { isAdvancedCameraCardCustomAction } from '../../../src/utils/action'; -import { createCardAPI, createConfig, createHASS, createStore } from '../../test-utils'; +import { + createCardAPI, + createConfig, + createHASS, + createStateEntity, + createStore, +} from '../../test-utils'; describe('setRemoteControlEntityFromConfig', () => { it('without control entity', () => { @@ -13,13 +19,14 @@ describe('setRemoteControlEntityFromConfig', () => { expect(api.getAutomationsManager().addAutomations).not.toBeCalled(); }); - it('with control entity', () => { + it('with control entity and card priority', () => { const api = createCardAPI(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ remote_control: { entities: { camera: 'input_select.camera', + camera_priority: 'card', }, }, }), @@ -36,6 +43,37 @@ describe('setRemoteControlEntityFromConfig', () => { advanced_camera_card_action: '__INTERNAL_CALLBACK_ACTION__', callback: expect.any(Function), }, + ], + conditions: [ + { + condition: 'config', + paths: ['cameras', 'remote_control.entities.camera'], + }, + ], + tag: setRemoteControlEntityFromConfig, + }, + { + actions: [ + { + action: 'perform-action', + data: { + option: '{{ advanced_camera_card.trigger.camera.to }}', + }, + perform_action: 'input_select.select_option', + target: { + entity_id: 'input_select.camera', + }, + }, + ], + conditions: [ + { + condition: 'camera', + }, + ], + tag: setRemoteControlEntityFromConfig, + }, + { + actions: [ { action: 'perform-action', data: { @@ -47,10 +85,61 @@ describe('setRemoteControlEntityFromConfig', () => { }, }, ], + conditions: [ + { + condition: 'initialized', + }, + ], + tag: setRemoteControlEntityFromConfig, + }, + { + actions: [ + { + action: 'fire-dom-event', + advanced_camera_card_action: 'camera_select', + camera: '{{ advanced_camera_card.trigger.state.to }}', + }, + ], + conditions: [ + { + condition: 'state', + entity: 'input_select.camera', + }, + ], + tag: setRemoteControlEntityFromConfig, + }, + ]); + }); + + it('with control entity and entity priority', () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + remote_control: { + entities: { + camera: 'input_select.camera', + camera_priority: 'entity', + }, + }, + }), + ); + + setRemoteControlEntityFromConfig(api); + + expect(api.getAutomationsManager().deleteAutomations).toBeCalled(); + expect(api.getAutomationsManager().addAutomations).toBeCalledWith([ + { + actions: [ + { + action: 'fire-dom-event', + advanced_camera_card_action: '__INTERNAL_CALLBACK_ACTION__', + callback: expect.any(Function), + }, + ], conditions: [ { condition: 'config', - paths: ['remote_control.entities.camera'], + paths: ['cameras', 'remote_control.entities.camera'], }, ], tag: setRemoteControlEntityFromConfig, @@ -75,6 +164,21 @@ describe('setRemoteControlEntityFromConfig', () => { ], tag: setRemoteControlEntityFromConfig, }, + { + actions: [ + { + action: 'fire-dom-event', + advanced_camera_card_action: 'camera_select', + camera: '{{ hass.states["input_select.camera"].state }}', + }, + ], + conditions: [ + { + condition: 'initialized', + }, + ], + tag: setRemoteControlEntityFromConfig, + }, { actions: [ { @@ -94,46 +198,86 @@ describe('setRemoteControlEntityFromConfig', () => { ]); }); - it('internal action should set options', () => { - const hass = createHASS(); - const api = createCardAPI(); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - remote_control: { - entities: { - camera: 'input_select.camera', + describe('should set options', () => { + it('should set options when they are incorrect', () => { + const hass = createHASS(); + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + remote_control: { + entities: { + camera: 'input_select.camera', + }, }, + }), + ); + const store = createStore([ + { + cameraID: 'camera.one', }, - }), - ); - const store = createStore([ - { - cameraID: 'camera.one', - }, - { - cameraID: 'camera.two', - }, - ]); - vi.mocked(api.getCameraManager().getStore).mockReturnValue(store); + { + cameraID: 'camera.two', + }, + ]); + vi.mocked(api.getCameraManager().getStore).mockReturnValue(store); - setRemoteControlEntityFromConfig(api); + setRemoteControlEntityFromConfig(api); - const addOptionsAction = vi.mocked(api.getAutomationsManager().addAutomations).mock - .calls[0][0][0].actions?.[0]; - assert(addOptionsAction && isAdvancedCameraCardCustomAction(addOptionsAction)); - assert(addOptionsAction.advanced_camera_card_action === INTERNAL_CALLBACK_ACTION); + const addOptionsAction = vi.mocked(api.getAutomationsManager().addAutomations).mock + .calls[0][0][0].actions?.[0]; + assert(addOptionsAction && isAdvancedCameraCardCustomAction(addOptionsAction)); + assert(addOptionsAction.advanced_camera_card_action === INTERNAL_CALLBACK_ACTION); - addOptionsAction.callback(api); - expect(hass.callService).toBeCalledWith( - 'input_select', - 'set_options', - { - options: ['camera.one', 'camera.two'], - }, - { - entity_id: 'input_select.camera', - }, - ); + addOptionsAction.callback(api); + expect(hass.callService).toBeCalledWith( + 'input_select', + 'set_options', + { + options: ['camera.one', 'camera.two'], + }, + { + entity_id: 'input_select.camera', + }, + ); + }); + + it('should not set options when they are already correct', () => { + const hass = createHASS({ + 'input_select.camera': createStateEntity({ + attributes: { options: ['camera.one', 'camera.two'] }, + }), + }); + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + remote_control: { + entities: { + camera: 'input_select.camera', + }, + }, + }), + ); + const store = createStore([ + { + cameraID: 'camera.one', + }, + { + cameraID: 'camera.two', + }, + ]); + vi.mocked(api.getCameraManager().getStore).mockReturnValue(store); + + setRemoteControlEntityFromConfig(api); + + const addOptionsAction = vi.mocked(api.getAutomationsManager().addAutomations).mock + .calls[0][0][0].actions?.[0]; + assert(addOptionsAction && isAdvancedCameraCardCustomAction(addOptionsAction)); + assert(addOptionsAction.advanced_camera_card_action === INTERNAL_CALLBACK_ACTION); + + addOptionsAction.callback(api); + expect(hass.callService).not.toBeCalled(); + }); }); }); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 6c986735..e7469040 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -291,6 +291,11 @@ describe('config defaults', () => { box_shadow: true, }, }, + remote_control: { + entities: { + camera_priority: 'card', + }, + }, status_bar: { height: 40, items: {