fix: Allow entity to take remote control priority (#1981)

- Closes: #1958


[skip ci]
This commit is contained in:
Dermot Duffy
2025-03-24 18:24:56 -07:00
committed by GitHub
parent a3b17d8cb2
commit f6bb8001e3
6 changed files with 249 additions and 57 deletions
+6 -9
View File
@@ -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
```
@@ -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,
+14 -2
View File
@@ -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<typeof entityPrioritySchema>;
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);
+2 -1
View File
@@ -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,
};
@@ -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();
});
});
});
+5
View File
@@ -291,6 +291,11 @@ describe('config defaults', () => {
box_shadow: true,
},
},
remote_control: {
entities: {
camera_priority: 'card',
},
},
status_bar: {
height: 40,
items: {