diff --git a/docs/configuration/_sidebar.md b/docs/configuration/_sidebar.md index 1e65dac7..6d0abdac 100644 --- a/docs/configuration/_sidebar.md +++ b/docs/configuration/_sidebar.md @@ -14,6 +14,7 @@ - [`overrides`](overrides.md) - [`performance`](performance.md) - [`profiles`](profiles.md) + - [`remote_control`](remote-control.md) - [`status_bar`](status-bar.md) - [`timeline`](timeline.md) - [`view`](view.md) diff --git a/docs/configuration/actions/_sidebar.md b/docs/configuration/actions/_sidebar.md index c97d52cf..a4f890a9 100644 --- a/docs/configuration/actions/_sidebar.md +++ b/docs/configuration/actions/_sidebar.md @@ -17,6 +17,7 @@ - [`overrides`](../overrides.md) - [`performance`](../performance.md) - [`profiles`](../profiles.md) + - [`remote_control`](../remote-control.md) - [`status_bar`](../status-bar.md) - [`timeline`](../timeline.md) - [`view`](../view.md) diff --git a/docs/configuration/actions/custom/_sidebar.md b/docs/configuration/actions/custom/_sidebar.md index 13896495..c2339fbb 100644 --- a/docs/configuration/actions/custom/_sidebar.md +++ b/docs/configuration/actions/custom/_sidebar.md @@ -17,6 +17,7 @@ - [`overrides`](../../overrides.md) - [`performance`](../../performance.md) - [`profiles`](../../profiles.md) + - [`remote_control`](../../remote-control.md) - [`status_bar`](../../status-bar.md) - [`timeline`](../../timeline.md) - [`view`](../../view.md) diff --git a/docs/configuration/actions/stock/_sidebar.md b/docs/configuration/actions/stock/_sidebar.md index 1bdc196b..53c99816 100644 --- a/docs/configuration/actions/stock/_sidebar.md +++ b/docs/configuration/actions/stock/_sidebar.md @@ -17,6 +17,7 @@ - [`overrides`](../../overrides.md) - [`performance`](../../performance.md) - [`profiles`](../../profiles.md) + - [`remote_control`](../../remote-control.md) - [`status_bar`](../../status-bar.md) - [`timeline`](../../timeline.md) - [`view`](../../view.md) diff --git a/docs/configuration/cameras/README.md b/docs/configuration/cameras/README.md index 175b89dd..b947cf48 100644 --- a/docs/configuration/cameras/README.md +++ b/docs/configuration/cameras/README.md @@ -52,19 +52,20 @@ cameras: ### Capabilities -| Capability | Purpose | -| --------------------- | -------------------------------------------------------- | -| `clips` | Clips can be fetched from the camera. | -| `favorite-events` | Events can be favorited. | -| `favorite-recordings` | Recordings can be favorited. | -| `live` | Live video can be received from the camera. | -| `menu` | The camera should show up in the card camera menu. | -| `ptz` | The camera can be PTZ controlled. | -| `recordings` | Recordings can be fetched from the camera. | -| `seek` | Clips can be seeked / scrubbed by the timeline. | -| `snapshots` | Snapshots can be fetched from the camera. | -| `substream` | The camera can be used as a substream on another camera. | -| `trigger` | The camera can be triggered. | +| Capability | Purpose | +| ----------------------- | ------------------------------------------------------------------------------------------------- | +| `clips` | Clips can be fetched from the camera. | +| `remote-control-entity` | The camera can be selected by a [Camera Remote Control Entity](../remote-control.md?id=entities). | +| `favorite-events` | Events can be favorited. | +| `favorite-recordings` | Recordings can be favorited. | +| `live` | Live video can be received from the camera. | +| `menu` | The camera should show up in the card camera menu. | +| `ptz` | The camera can be PTZ controlled. | +| `recordings` | Recordings can be fetched from the camera. | +| `seek` | Clips can be seeked / scrubbed by the timeline. | +| `snapshots` | Snapshots can be fetched from the camera. | +| `substream` | The camera can be used as a substream on another camera. | +| `trigger` | The camera can be triggered. | ## `cast` diff --git a/docs/configuration/cameras/_sidebar.md b/docs/configuration/cameras/_sidebar.md index 5c901437..50b09dd5 100644 --- a/docs/configuration/cameras/_sidebar.md +++ b/docs/configuration/cameras/_sidebar.md @@ -16,6 +16,7 @@ - [`overrides`](../overrides.md) - [`performance`](../performance.md) - [`profiles`](../profiles.md) + - [`remote_control`](../remote-control.md) - [`status_bar`](../status-bar.md) - [`timeline`](../timeline.md) - [`view`](../view.md) diff --git a/docs/configuration/elements/_sidebar.md b/docs/configuration/elements/_sidebar.md index f570d19b..1de639f0 100644 --- a/docs/configuration/elements/_sidebar.md +++ b/docs/configuration/elements/_sidebar.md @@ -16,6 +16,7 @@ - [`overrides`](../overrides.md) - [`performance`](../performance.md) - [`profiles`](../profiles.md) + - [`remote_control`](../remote-control.md) - [`status_bar`](../status-bar.md) - [`timeline`](../timeline.md) - [`view`](../view.md) diff --git a/docs/configuration/elements/custom/_sidebar.md b/docs/configuration/elements/custom/_sidebar.md index 0ea893ea..01f793f0 100644 --- a/docs/configuration/elements/custom/_sidebar.md +++ b/docs/configuration/elements/custom/_sidebar.md @@ -16,6 +16,7 @@ - [`overrides`](../../overrides.md) - [`performance`](../../performance.md) - [`profiles`](../../profiles.md) + - [`remote_control`](../../remote-control.md) - [`status_bar`](../../status-bar.md) - [`timeline`](../../timeline.md) - [`view`](../../view.md) diff --git a/docs/configuration/elements/stock/_sidebar.md b/docs/configuration/elements/stock/_sidebar.md index 9edbaf3a..e85b15f2 100644 --- a/docs/configuration/elements/stock/_sidebar.md +++ b/docs/configuration/elements/stock/_sidebar.md @@ -16,6 +16,7 @@ - [`overrides`](../../overrides.md) - [`performance`](../../performance.md) - [`profiles`](../../profiles.md) + - [`remote_control`](../../remote-control.md) - [`status_bar`](../../status-bar.md) - [`timeline`](../../timeline.md) - [`view`](../../view.md) diff --git a/docs/configuration/remote-control.md b/docs/configuration/remote-control.md new file mode 100644 index 00000000..7fb10bcc --- /dev/null +++ b/docs/configuration/remote-control.md @@ -0,0 +1,43 @@ +# `remote_control` + +The `remote_control` configuration options control how a card instance can be remotely controlled. + +```yaml +remote_control: + # [...] +``` + +## `entities` + +Sets entities that are used to control different aspects of the card. + +```yaml +remote_control: + entities: + # [...] +``` + +| 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.`. | + +?> 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 + +- [Passing actions to the card in the URL](../usage/url-actions.md). + +## Fully expanded reference + +[](common/expanded-warning.md ':include') + +```yaml +remote_control: + entities: + camera: input_select.my_selected_camera +``` diff --git a/docs/images/control-entities.gif b/docs/images/control-entities.gif new file mode 100644 index 00000000..993fc982 Binary files /dev/null and b/docs/images/control-entities.gif differ diff --git a/docs/screenshots.md b/docs/screenshots.md index d8c6f587..5d9e8a73 100644 --- a/docs/screenshots.md +++ b/docs/screenshots.md @@ -14,6 +14,12 @@ A dashboard with the card can be cast onto a suitable device (such as the Nest H ![](images/card-on-nest-hub.jpg 'Card casting :size=400') +## Control via Entities + +Bidirectional control of camera selection via an entity. + +![](images/control-entities.gif 'Control via Entities :size=400') + ## Dark mode ![](images/dark-mode.gif 'Dark mode :size=400') diff --git a/src/camera-manager/frigate/camera.ts b/src/camera-manager/frigate/camera.ts index 9d94fefc..726832b8 100644 --- a/src/camera-manager/frigate/camera.ts +++ b/src/camera-manager/frigate/camera.ts @@ -139,6 +139,7 @@ export class FrigateCamera extends Camera { { 'favorite-events': !birdseye, 'favorite-recordings': false, + 'remote-control-entity': true, seek: !birdseye, clips: !birdseye, snapshots: !birdseye, diff --git a/src/camera-manager/generic/engine-generic.ts b/src/camera-manager/generic/engine-generic.ts index 7dcf9888..a9af9264 100644 --- a/src/camera-manager/generic/engine-generic.ts +++ b/src/camera-manager/generic/engine-generic.ts @@ -63,6 +63,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { { 'favorite-events': false, 'favorite-recordings': false, + 'remote-control-entity': true, clips: false, live: true, menu: true, diff --git a/src/camera-manager/motioneye/engine-motioneye.ts b/src/camera-manager/motioneye/engine-motioneye.ts index 47abd42b..2cbcdbc4 100644 --- a/src/camera-manager/motioneye/engine-motioneye.ts +++ b/src/camera-manager/motioneye/engine-motioneye.ts @@ -83,6 +83,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine { 'favorite-events': false, 'favorite-recordings': false, + 'remote-control-entity': true, clips: true, live: true, menu: true, diff --git a/src/camera-manager/reolink/engine-reolink.ts b/src/camera-manager/reolink/engine-reolink.ts index 78852cda..90aa2567 100644 --- a/src/camera-manager/reolink/engine-reolink.ts +++ b/src/camera-manager/reolink/engine-reolink.ts @@ -137,6 +137,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine { { 'favorite-events': false, 'favorite-recordings': false, + 'remote-control-entity': true, clips: true, live: true, menu: true, diff --git a/src/card-controller/actions/actions/camera-select.ts b/src/card-controller/actions/actions/camera-select.ts index e62a1b81..2dc9d557 100644 --- a/src/card-controller/actions/actions/camera-select.ts +++ b/src/card-controller/actions/actions/camera-select.ts @@ -12,7 +12,11 @@ export class CameraSelectAction extends AdvancedCameraCardAction { + public async execute(api: CardActionsAPI): Promise { + await this._action.callback(api); + } +} diff --git a/src/card-controller/actions/factory.ts b/src/card-controller/actions/factory.ts index c632fd81..57f95cf7 100644 --- a/src/card-controller/actions/factory.ts +++ b/src/card-controller/actions/factory.ts @@ -1,6 +1,6 @@ import { ActionConfig } from '@dermotduffy/custom-card-helpers'; import { ActionContext } from 'action'; -import { ActionType } from '../../config/types'; +import { ActionType, INTERNAL_CALLBACK_ACTION } from '../../config/types'; import { convertActionToCardCustomAction } from '../../utils/action'; import { CameraSelectAction } from './actions/camera-select'; import { CameraUIAction } from './actions/camera-ui'; @@ -10,6 +10,7 @@ import { DownloadAction } from './actions/download'; import { ExpandAction } from './actions/expand'; import { FullscreenAction } from './actions/fullscreen'; import { GenericAction } from './actions/generic'; +import { InternalCallbackAction } from './actions/internal-callback'; import { LogAction } from './actions/log'; import { MediaPlayerAction } from './actions/media-player'; import { MenuToggleAction } from './actions/menu-toggle'; @@ -132,6 +133,8 @@ export class ActionFactory { return new LogAction(context, cardCustomAction, options?.config); case 'status_bar': return new StatusBarAction(context, cardCustomAction, options?.config); + case INTERNAL_CALLBACK_ACTION: + return new InternalCallbackAction(context, cardCustomAction, options?.config); } /* istanbul ignore next: this path cannot be reached -- @preserve */ diff --git a/src/card-controller/config/config-manager.ts b/src/card-controller/config/config-manager.ts index 806dcded..3d71e49d 100644 --- a/src/card-controller/config/config-manager.ts +++ b/src/card-controller/config/config-manager.ts @@ -14,6 +14,7 @@ import { InitializationAspect } from '../initialization-manager.js'; import { CardConfigAPI } from '../types.js'; import { getOverriddenConfig } from './get-overridden-config.js'; import { setAutomationsFromConfig } from './load-automations.js'; +import { setRemoteControlEntityFromConfig } from './load-control-entities.js'; import { setKeyboardShortcutsFromConfig } from './load-keyboard-shortcuts.js'; export class ConfigManager { @@ -109,7 +110,8 @@ export class ConfigManager { this._api.getMessageManager().reset(); this._api.getStatusBarItemManager().removeAllDynamicStatusBarItems(); - setKeyboardShortcutsFromConfig(this._api, this); + setKeyboardShortcutsFromConfig(this._api); + setRemoteControlEntityFromConfig(this._api); setAutomationsFromConfig(this._api); this._processOverrideConfig(); diff --git a/src/card-controller/config/load-control-entities.ts b/src/card-controller/config/load-control-entities.ts new file mode 100644 index 00000000..2293ba46 --- /dev/null +++ b/src/card-controller/config/load-control-entities.ts @@ -0,0 +1,98 @@ +import { + createCameraAction, + createInternalCallbackAction, + createPerformAction, +} from '../../utils/action'; +import { CardActionsAPI, CardConfigLoaderAPI, TaggedAutomation } from '../types'; + +export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => { + const automationTag = setRemoteControlEntityFromConfig; + + api.getAutomationsManager().deleteAutomations(automationTag); + + const cameraControlEntity = api.getConfigManager().getConfig()?.remote_control + ?.entities?.camera; + if (!cameraControlEntity) { + return; + } + + 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. + + const automations: TaggedAutomation[] = [ + { + conditions: [ + { + condition: 'config' as const, + paths: ['remote_control.entities.camera'], + }, + ], + actions: [ + // Set the possible options on the entity to the camera IDs via a + // callback to `setCameraOptionsOnEntity` (below). + createInternalCallbackAction((api: CardActionsAPI) => + setCameraOptionsOnEntity(cameraControlEntity, api), + ), + // Set the selected option to the current camera ID. + createSelectOptionAction('{{ advanced_camera_card.camera }}'), + ], + tag: automationTag, + }, + { + conditions: [ + { + condition: 'camera' as const, + }, + ], + actions: [ + // When the camera changes, update the entity to match. + createSelectOptionAction('{{ advanced_camera_card.trigger.camera.to }}'), + ], + tag: automationTag, + }, + { + conditions: [ + { + condition: 'state' as const, + entity: cameraControlEntity, + }, + ], + actions: [ + // When the entity state changes, updated the selected option. + createCameraAction( + 'camera_select', + '{{ advanced_camera_card.trigger.state.to }}', + ), + ], + tag: automationTag, + }, + ]; + + api.getAutomationsManager().addAutomations(automations); +}; + +const setCameraOptionsOnEntity = async (entity: string, api: CardActionsAPI) => { + const hass = api.getHASSManager().getHASS(); + const cameraIDs = api.getCameraManager().getStore().getCameraIDs(); + + await hass?.callService( + 'input_select', + 'set_options', + { + options: [...cameraIDs], + }, + { + entity_id: entity, + }, + ); +}; diff --git a/src/card-controller/config/load-keyboard-shortcuts.ts b/src/card-controller/config/load-keyboard-shortcuts.ts index 42589674..4be3f673 100644 --- a/src/card-controller/config/load-keyboard-shortcuts.ts +++ b/src/card-controller/config/load-keyboard-shortcuts.ts @@ -6,18 +6,16 @@ import { PTZAction } from '../../config/ptz'; import { createPTZMultiAction } from '../../utils/action'; import { CardConfigLoaderAPI, TaggedAutomation } from '../types'; -export const setKeyboardShortcutsFromConfig = ( - api: CardConfigLoaderAPI, - tag: unknown, -) => { - api.getAutomationsManager().deleteAutomations(tag); +export const setKeyboardShortcutsFromConfig = (api: CardConfigLoaderAPI) => { + const automationTag = setKeyboardShortcutsFromConfig; + api.getAutomationsManager().deleteAutomations(automationTag); const shortcuts = api.getConfigManager().getConfig()?.view.keyboard_shortcuts; if (!shortcuts) { return; } - const automations = convertKeyboardShortcutsToAutomations(tag, shortcuts); + const automations = convertKeyboardShortcutsToAutomations(automationTag, shortcuts); if (automations.length) { api.getAutomationsManager().addAutomations(automations); } diff --git a/src/card-controller/query-string-manager.ts b/src/card-controller/query-string-manager.ts index d4398426..fee5e9d1 100644 --- a/src/card-controller/query-string-manager.ts +++ b/src/card-controller/query-string-manager.ts @@ -1,5 +1,9 @@ import { AdvancedCameraCardCustomAction, ViewActionConfig } from '../config/types'; -import { createCameraAction, createGeneralAction } from '../utils/action.js'; +import { + createCameraAction, + createGeneralAction, + createViewAction, +} from '../utils/action.js'; import { ViewParameters } from '../view/view'; import { CardQueryStringAPI } from './types'; import { SubstreamSelectViewModifier } from './view/modifiers/substream-select'; @@ -113,21 +117,25 @@ export class QueryStringManager { } break; case 'camera_ui': - case 'clip': - case 'clips': case 'default': - case 'diagnostics': case 'download': case 'expand': + case 'menu_toggle': + customAction = createGeneralAction(action, { + cardID: cardID, + }); + break; + case 'clip': + case 'clips': + case 'diagnostics': case 'image': case 'live': - case 'menu_toggle': case 'recording': case 'recordings': case 'snapshot': case 'snapshots': case 'timeline': - customAction = createGeneralAction(action, { + customAction = createViewAction(action, { cardID: cardID, }); break; diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index e5aae2e8..0e17b62e 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -90,6 +90,7 @@ export interface CardConfigAPI { getConditionStateManager(): ConditionStateManager; getConfigManager(): ConfigManager; getDefaultManager(): DefaultManager; + getHASSManager(): HASSManager; getInitializationManager(): InitializationManager; getMediaLoadedInfoManager(): MediaLoadedInfoManager; getMediaPlayerManager(): MediaPlayerManager; @@ -101,8 +102,9 @@ export interface CardConfigAPI { } export interface CardConfigLoaderAPI { - getConfigManager(): ConfigManager; getAutomationsManager(): AutomationsManager; + getConfigManager(): ConfigManager; + getHASSManager(): HASSManager; } export interface CardDefaultManagerAPI { @@ -178,6 +180,7 @@ export interface CardInitializerAPI { getCardElementManager(): CardElementManager; getConditionStateManager(): ConditionStateManager; getConfigManager(): ConfigManager; + getConditionStateManager(): ConditionStateManager; getDefaultManager(): DefaultManager; getEntityRegistryManager(): EntityRegistryManager; getHASSManager(): HASSManager; diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index 75e0505f..96539d69 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -20,6 +20,7 @@ import { createMediaPlayerAction, createPTZControlsAction, createPTZMultiAction, + createViewAction, } from '../utils/action'; import { isTruthy } from '../utils/basic'; import { isBeingCasted } from '../utils/casting'; @@ -119,7 +120,7 @@ export class MenuButtonController { config.menu?.style === 'hidden' ? (createGeneralAction('menu_toggle') as AdvancedCameraCardCustomAction) : (createGeneralAction('default') as AdvancedCameraCardCustomAction), - hold_action: createGeneralAction('diagnostics') as AdvancedCameraCardCustomAction, + hold_action: createViewAction('diagnostics') as AdvancedCameraCardCustomAction, }; } @@ -233,7 +234,7 @@ export class MenuButtonController { type: 'custom:advanced-camera-card-menu-icon', title: localize('config.view.views.live'), style: view.is('live') ? this._getEmphasizedStyle() : {}, - tap_action: createGeneralAction('live') as AdvancedCameraCardCustomAction, + tap_action: createViewAction('live') as AdvancedCameraCardCustomAction, } : null; } @@ -250,8 +251,8 @@ export class MenuButtonController { type: 'custom:advanced-camera-card-menu-icon', title: localize('config.view.views.clips'), style: view?.is('clips') ? this._getEmphasizedStyle() : {}, - tap_action: createGeneralAction('clips') as AdvancedCameraCardCustomAction, - hold_action: createGeneralAction('clip') as AdvancedCameraCardCustomAction, + tap_action: createViewAction('clips') as AdvancedCameraCardCustomAction, + hold_action: createViewAction('clip') as AdvancedCameraCardCustomAction, } : null; } @@ -268,8 +269,8 @@ export class MenuButtonController { type: 'custom:advanced-camera-card-menu-icon', title: localize('config.view.views.snapshots'), style: view?.is('snapshots') ? this._getEmphasizedStyle() : {}, - tap_action: createGeneralAction('snapshots') as AdvancedCameraCardCustomAction, - hold_action: createGeneralAction('snapshot') as AdvancedCameraCardCustomAction, + tap_action: createViewAction('snapshots') as AdvancedCameraCardCustomAction, + hold_action: createViewAction('snapshot') as AdvancedCameraCardCustomAction, } : null; } @@ -286,12 +287,8 @@ export class MenuButtonController { type: 'custom:advanced-camera-card-menu-icon', title: localize('config.view.views.recordings'), style: view.is('recordings') ? this._getEmphasizedStyle() : {}, - tap_action: createGeneralAction( - 'recordings', - ) as AdvancedCameraCardCustomAction, - hold_action: createGeneralAction( - 'recording', - ) as AdvancedCameraCardCustomAction, + tap_action: createViewAction('recordings') as AdvancedCameraCardCustomAction, + hold_action: createViewAction('recording') as AdvancedCameraCardCustomAction, } : null; } @@ -308,7 +305,7 @@ export class MenuButtonController { type: 'custom:advanced-camera-card-menu-icon', title: localize('config.view.views.image'), style: view?.is('image') ? this._getEmphasizedStyle() : {}, - tap_action: createGeneralAction('image') as AdvancedCameraCardCustomAction, + tap_action: createViewAction('image') as AdvancedCameraCardCustomAction, } : null; } @@ -325,7 +322,7 @@ export class MenuButtonController { type: 'custom:advanced-camera-card-menu-icon', title: localize('config.view.views.timeline'), style: view.is('timeline') ? this._getEmphasizedStyle() : {}, - tap_action: createGeneralAction('timeline') as AdvancedCameraCardCustomAction, + tap_action: createViewAction('timeline') as AdvancedCameraCardCustomAction, } : null; } diff --git a/src/config/types.ts b/src/config/types.ts index ad3e1de9..7ba6c7fd 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -397,7 +397,7 @@ const logActionConfigSchema = advancedCameraCardCustomActionsBaseSchema.extend({ }); export type LogActionConfig = z.infer; -export const advancedCameraCardCustomActionSchema = z.union([ +const advancedCameraCardCustomActionSchema = z.union([ cameraSelectActionConfigSchema, generalActionConfigSchema, substreamSelectActionConfigSchema, @@ -416,6 +416,26 @@ export type AdvancedCameraCardCustomAction = z.infer< typeof advancedCameraCardCustomActionSchema >; +// An action that can be used internally to call a callback. +// Note: The internal callback action is kept out of schemas that can be user-specified. +export const INTERNAL_CALLBACK_ACTION = '__INTERNAL_CALLBACK_ACTION__'; +const internalCallbackActionConfigSchema = + advancedCameraCardCustomActionsBaseSchema.extend({ + advanced_camera_card_action: z.literal(INTERNAL_CALLBACK_ACTION), + + // The callback is expected to be called with a CardController API object. + callback: z.function().args(z.any()).returns(z.promise(z.void())), + }); +export type InternalCallbackActionConfig = z.infer< + typeof internalCallbackActionConfigSchema +>; + +export const internalAdvancedCameraCardCustomActionSchema = + advancedCameraCardCustomActionSchema.or(internalCallbackActionConfigSchema); +export type InternalAdvancedCameraCardCustomAction = z.infer< + typeof internalAdvancedCameraCardCustomActionSchema +>; + // Cannot use discriminatedUnion since advancedCameraCardCustomActionSchema uses // a transform on the discriminated union key. export const actionSchema = z.union([ @@ -429,7 +449,9 @@ export const actionSchema = z.union([ customActionSchema, advancedCameraCardCustomActionSchema, ]); -export type ActionType = z.infer; + +const internalActionSchema = actionSchema.or(internalCallbackActionConfigSchema); +export type ActionType = z.infer; const actionsBaseSchema = z .object({ @@ -2075,6 +2097,20 @@ const PROFILES = ['casting', 'low-performance', 'scrubbing'] as const; export type ProfileType = (typeof PROFILES)[number]; export const profilesSchema = z.enum(PROFILES).array().optional(); +// ************************************************************************* +// *** Remote Control Configuration *** +// ************************************************************************* + +const remoteControlConfigSchema = z + .object({ + entities: z + .object({ + camera: z.string().startsWith('input_select.').optional(), + }) + .optional(), + }) + .optional(); + // ************************************************************************* // *** Card Configuration *** // ************************************************************************* @@ -2114,6 +2150,8 @@ export const advancedCameraCardConfigSchema = z.object({ // that be easily used in a URL. card_id: z.string().regex(cardIDRegex).optional(), + remote_control: remoteControlConfigSchema, + // Stock lovelace card config. type: z.string(), }); diff --git a/src/const.ts b/src/const.ts index 2140b53a..feddd406 100644 --- a/src/const.ts +++ b/src/const.ts @@ -382,6 +382,10 @@ export const CONF_PERFORMANCE_STYLE_BORDER_RADIUS = `${CONF_PERFORMANCE}.style.b export const CONF_PROFILES = 'profiles' as const; +export const CONF_REMOTE_CONTROL = 'remote_control' as const; +export const CONF_REMOTE_CONTROL_ENTITIES_CAMERA = + `${CONF_REMOTE_CONTROL}.entities.camera` as const; + // Taken from https://github.com/home-assistant/frontend/blob/a759767d794f02527d127802831e68d3caf0cb7a/src/data/media-player.ts#L82 export const MEDIA_PLAYER_SUPPORT_TURN_OFF = 256; export const MEDIA_PLAYER_SUPPORT_STOP = 4096; diff --git a/src/editor.ts b/src/editor.ts index cd16aa95..114fd557 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -190,6 +190,7 @@ import { CONF_PERFORMANCE_STYLE_BORDER_RADIUS, CONF_PERFORMANCE_STYLE_BOX_SHADOW, CONF_PROFILES, + CONF_REMOTE_CONTROL_ENTITIES_CAMERA, CONF_STATUS_BAR_HEIGHT, CONF_STATUS_BAR_ITEMS, CONF_STATUS_BAR_POPUP_SECONDS, @@ -281,6 +282,7 @@ const MENU_MENU_BUTTONS = 'menu.buttons'; const MENU_OPTIONS = 'options'; const MENU_PERFORMANCE_FEATURES = 'performance.features'; const MENU_PERFORMANCE_STYLE = 'performance.style'; +const MENU_REMOTE_CONTROL_ENTITIES = 'remote_control.entities'; const MENU_STATUS_BAR_ITEMS = 'status_bar.items'; const MENU_TIMELINE_FORMAT = 'timeline.format'; const MENU_TIMELINE_CONTROLS_THUMBNAILS = 'timeline.controls.thumbnails'; @@ -369,6 +371,11 @@ const options: EditorOptions = { name: localize('editor.profiles'), secondary: localize('editor.profiles_secondary'), }, + remote_control: { + icon: 'remote', + name: localize('editor.remote_control'), + secondary: localize('editor.remote_control_secondary'), + }, overrides: { icon: 'file-replace', name: localize('editor.overrides'), @@ -3051,6 +3058,23 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard )} ` : ''} + ${this._renderOptionSetHeader('remote_control')} + ${this._expandedMenus[MENU_OPTIONS] === 'remote_control' + ? html`
+ ${this._putInSubmenu( + MENU_REMOTE_CONTROL_ENTITIES, + true, + 'config.remote_control.entities.editor_label', + 'mdi:devices', + html` + ${this._renderEntitySelector( + CONF_REMOTE_CONTROL_ENTITIES_CAMERA, + 'input_select', + )} + `, + )} +
` + : ''} ${this._config['overrides'] !== undefined ? html` ${this._renderOptionSetHeader('overrides')} ${this._expandedMenus[MENU_OPTIONS] === 'overrides' diff --git a/src/localize/languages/ca.json b/src/localize/languages/ca.json index 2552071b..7c7e7793 100644 --- a/src/localize/languages/ca.json +++ b/src/localize/languages/ca.json @@ -459,6 +459,12 @@ "low-performance": "", "scrubbing": "" }, + "remote_control": { + "entities": { + "camera": "", + "editor_label": "" + } + }, "status_bar": { "height": "", "items": { @@ -596,6 +602,8 @@ "performance_secondary": "Opcions de rendiment de la targeta", "profiles": "", "profiles_secondary": "", + "remote_control": "", + "remote_control_secondary": "", "status_bar": "", "status_bar_secondary": "", "timeline": "Cronologia", diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index da9942ed..6c3b3701 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -459,6 +459,12 @@ "low-performance": "Low performance", "scrubbing": "Video scrubbing" }, + "remote_control": { + "entities": { + "editor_label": "Remote Control Entities", + "camera": "Input Select entity to control camera" + } + }, "status_bar": { "height": "Status bar height in pixels", "items": { @@ -596,6 +602,8 @@ "performance_secondary": "Card performance options", "profiles": "Configuration profiles", "profiles_secondary": "Choose pre-configured sets of defaults", + "remote_control": "Remote Control", + "remote_control_secondary": "Options for remote controlling the card", "status_bar": "Status bar", "status_bar_secondary": "Status bar look & feel options", "timeline": "Timeline", diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json index 63d6b7d4..f35974dc 100644 --- a/src/localize/languages/fr.json +++ b/src/localize/languages/fr.json @@ -459,6 +459,12 @@ "low-performance": "Basse performance", "scrubbing": "Balayage vidéo" }, + "remote_control": { + "entities": { + "camera": "", + "editor_label": "" + } + }, "status_bar": { "height": "Hauteur de la barre d'état en pixels", "items": { @@ -596,6 +602,8 @@ "performance_secondary": "Options de performances de la carte", "profiles": "Profils de configuration", "profiles_secondary": "Choisir des ensembles de paramètres par défaut pré-configurés", + "remote_control": "", + "remote_control_secondary": "", "status_bar": "Barre d'état", "status_bar_secondary": "Options d'apparence et de comportement de la barre d'état", "timeline": "Chronologie", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 7f5f40d8..de1b0372 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -459,6 +459,12 @@ "low-performance": "", "scrubbing": "" }, + "remote_control": { + "entities": { + "camera": "", + "editor_label": "" + } + }, "status_bar": { "height": "", "items": { @@ -596,6 +602,8 @@ "performance_secondary": "", "profiles": "", "profiles_secondary": "", + "remote_control": "", + "remote_control_secondary": "", "status_bar": "", "status_bar_secondary": "", "timeline": "Timeline", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 5b870621..957f1087 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -459,6 +459,12 @@ "low-performance": "", "scrubbing": "" }, + "remote_control": { + "entities": { + "camera": "", + "editor_label": "" + } + }, "status_bar": { "height": "", "items": { @@ -596,6 +602,8 @@ "performance_secondary": "Opções de desempenho do cartão", "profiles": "", "profiles_secondary": "", + "remote_control": "", + "remote_control_secondary": "", "status_bar": "", "status_bar_secondary": "", "timeline": "Linha do tempo", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index 37d49ce4..f4995ede 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -459,6 +459,12 @@ "low-performance": "", "scrubbing": "" }, + "remote_control": { + "entities": { + "camera": "", + "editor_label": "" + } + }, "status_bar": { "height": "", "items": { @@ -596,6 +602,8 @@ "performance_secondary": "", "profiles": "", "profiles_secondary": "", + "remote_control": "", + "remote_control_secondary": "", "status_bar": "", "status_bar_secondary": "", "timeline": "Linha do tempo", diff --git a/src/types.ts b/src/types.ts index b8b1d5d4..c05aba43 100644 --- a/src/types.ts +++ b/src/types.ts @@ -111,6 +111,8 @@ export interface CapabilitiesRaw { 'favorite-events'?: boolean; 'favorite-recordings'?: boolean; + 'remote-control-entity'?: boolean; + seek?: boolean; ptz?: PTZCapabilities; @@ -123,6 +125,7 @@ export interface CapabilitiesRaw { export type CapabilityKey = keyof CapabilitiesRaw; export const capabilityKeys: readonly [CapabilityKey, ...CapabilityKey[]] = [ 'clips', + 'remote-control-entity', 'favorite-events', 'favorite-recordings', 'live', diff --git a/src/utils/action.ts b/src/utils/action.ts index 7eb15108..f955e45b 100644 --- a/src/utils/action.ts +++ b/src/utils/action.ts @@ -1,22 +1,33 @@ import { ActionConfig, + ServiceCallRequest, hasAction as customCardHasAction, } from '@dermotduffy/custom-card-helpers'; +import { CardActionsAPI } from '../card-controller/types.js'; import { ZoomSettingsBase } from '../components-lib/zoom/types.js'; import { PTZAction } from '../config/ptz.js'; import { ActionPhase, ActionType, Actions, - AdvancedCameraCardCustomAction, AdvancedCameraCardGeneralAction, AdvancedCameraCardUserSpecifiedView, + CameraSelectActionConfig, + DisplayModeActionConfig, + GeneralActionConfig, + INTERNAL_CALLBACK_ACTION, + InternalAdvancedCameraCardCustomAction, + InternalCallbackActionConfig, LogActionConfig, LogActionLevel, + MediaPlayerActionConfig, PTZActionConfig, + PTZControlsActionConfig, PTZDigitialActionConfig, PTZMultiActionConfig, - advancedCameraCardCustomActionSchema, + SubstreamSelectActionConfig, + ViewActionConfig, + internalAdvancedCameraCardCustomActionSchema, } from '../config/types.js'; import { arrayify } from './basic.js'; @@ -27,22 +38,35 @@ import { arrayify } from './basic.js'; */ export function convertActionToCardCustomAction( action: unknown, -): AdvancedCameraCardCustomAction | null { +): InternalAdvancedCameraCardCustomAction | null { if (!action) { return null; } // Parse a custom event as other things could generate ll-custom events that // are not related to Advanced Camera Card. - const parseResult = advancedCameraCardCustomActionSchema.safeParse(action); + const parseResult = internalAdvancedCameraCardCustomActionSchema.safeParse(action); return parseResult.success ? parseResult.data : null; } export function createGeneralAction( - action: AdvancedCameraCardGeneralAction | AdvancedCameraCardUserSpecifiedView, + action: AdvancedCameraCardGeneralAction, options?: { cardID?: string; }, -): AdvancedCameraCardCustomAction { +): GeneralActionConfig { + return { + action: 'fire-dom-event', + advanced_camera_card_action: action, + ...(options?.cardID && { card_id: options.cardID }), + }; +} + +export function createViewAction( + action: AdvancedCameraCardUserSpecifiedView, + options?: { + cardID?: string; + }, +): ViewActionConfig { return { action: 'fire-dom-event', advanced_camera_card_action: action, @@ -56,7 +80,7 @@ export function createCameraAction( options?: { cardID?: string; }, -): AdvancedCameraCardCustomAction { +): CameraSelectActionConfig | SubstreamSelectActionConfig { return { action: 'fire-dom-event', advanced_camera_card_action: action, @@ -71,7 +95,7 @@ export function createMediaPlayerAction( options?: { cardID?: string; }, -): AdvancedCameraCardCustomAction { +): MediaPlayerActionConfig { return { action: 'fire-dom-event', advanced_camera_card_action: 'media_player', @@ -86,7 +110,7 @@ export function createDisplayModeAction( options?: { cardID?: string; }, -): AdvancedCameraCardCustomAction { +): DisplayModeActionConfig { return { action: 'fire-dom-event', advanced_camera_card_action: 'display_mode_select', @@ -100,7 +124,7 @@ export function createPTZControlsAction( options?: { cardID?: string; }, -): AdvancedCameraCardCustomAction { +): PTZControlsActionConfig { return { action: 'fire-dom-event', advanced_camera_card_action: 'ptz_controls', @@ -179,6 +203,35 @@ export function createLogAction( }; } +export function createInternalCallbackAction( + callback: (api: CardActionsAPI) => Promise, + options?: { + cardID?: string; + }, +): InternalCallbackActionConfig { + return { + action: 'fire-dom-event', + advanced_camera_card_action: INTERNAL_CALLBACK_ACTION, + callback: callback, + ...(options?.cardID && { card_id: options.cardID }), + }; +} + +export function createPerformAction( + perform_action: string, + options?: { + data?: ServiceCallRequest['serviceData']; + target?: ServiceCallRequest['target']; + }, +): ActionType { + return { + action: 'perform-action' as const, + perform_action: perform_action, + ...(options?.target && { target: options.target }), + ...(options?.data && { data: options.data }), + }; +} + /** * 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/engine-reolink.test.ts b/tests/camera-manager/reolink/engine-reolink.test.ts index 0046d2fa..1fe4c507 100644 --- a/tests/camera-manager/reolink/engine-reolink.test.ts +++ b/tests/camera-manager/reolink/engine-reolink.test.ts @@ -242,6 +242,7 @@ describe('ReolinkCameraManagerEngine', () => { 'favorite-events': false, 'favorite-recordings': false, clips: true, + 'remote-control-entity': true, live: true, menu: true, recordings: false, diff --git a/tests/card-controller/actions/actions/camera-select.test.ts b/tests/card-controller/actions/actions/camera-select.test.ts index d7c5acd2..5bca6514 100644 --- a/tests/card-controller/actions/actions/camera-select.test.ts +++ b/tests/card-controller/actions/actions/camera-select.test.ts @@ -13,7 +13,7 @@ describe('should handle camera_select action', () => { { action: 'fire-dom-event', advanced_camera_card_action: 'camera_select', - camera: 'camera', + camera: 'camera.office', }, ); @@ -23,13 +23,36 @@ describe('should handle camera_select action', () => { expect.objectContaining({ params: { view: 'live', - camera: 'camera', + camera: 'camera.office', }, failSafe: true, }), ); }); + it('should ignore requests to change to the current camera', async () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + camera: 'camera.office', + }), + ); + vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); + + const action = new CameraSelectAction( + {}, + { + action: 'fire-dom-event', + advanced_camera_card_action: 'camera_select', + camera: 'camera.office', + }, + ); + + await action.execute(api); + + expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); + }); + it('without config', async () => { const api = createCardAPI(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(null); @@ -45,7 +68,7 @@ describe('should handle camera_select action', () => { { action: 'fire-dom-event', advanced_camera_card_action: 'camera_select', - camera: 'camera', + camera: 'camera.office', }, ); @@ -55,7 +78,7 @@ describe('should handle camera_select action', () => { expect.objectContaining({ params: { view: 'timeline', - camera: 'camera', + camera: 'camera.office', }, failSafe: true, }), @@ -84,7 +107,7 @@ describe('should handle camera_select action', () => { { action: 'fire-dom-event', advanced_camera_card_action: 'camera_select', - camera: 'camera', + camera: 'camera.office', }, ); @@ -94,7 +117,7 @@ describe('should handle camera_select action', () => { expect.objectContaining({ params: { view: 'clips', - camera: 'camera', + camera: 'camera.office', }, failSafe: true, }), @@ -106,7 +129,7 @@ describe('should handle camera_select action', () => { vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); vi.mocked(api.getTriggersManager().getMostRecentlyTriggeredCameraID).mockReturnValue( - 'camera', + 'camera.office', ); const action = new CameraSelectAction( @@ -124,7 +147,7 @@ describe('should handle camera_select action', () => { expect.objectContaining({ params: { view: 'live', - camera: 'camera', + camera: 'camera.office', }, failSafe: true, }), @@ -136,7 +159,7 @@ describe('should handle camera_select action', () => { vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); vi.mocked(api.getTriggersManager().getMostRecentlyTriggeredCameraID).mockReturnValue( - 'camera', + 'camera.office', ); const action = new CameraSelectAction( diff --git a/tests/card-controller/actions/actions/internal-callback.test.ts b/tests/card-controller/actions/actions/internal-callback.test.ts new file mode 100644 index 00000000..63547e6b --- /dev/null +++ b/tests/card-controller/actions/actions/internal-callback.test.ts @@ -0,0 +1,21 @@ +import { expect, it, vi } from 'vitest'; +import { InternalCallbackAction } from '../../../../src/card-controller/actions/actions/internal-callback'; +import { INTERNAL_CALLBACK_ACTION } from '../../../../src/config/types'; +import { createCardAPI } from '../../../test-utils'; + +it('should handle internal callback action', async () => { + const api = createCardAPI(); + const callback = vi.fn(); + const action = new InternalCallbackAction( + {}, + { + action: 'fire-dom-event', + advanced_camera_card_action: INTERNAL_CALLBACK_ACTION, + callback: callback, + }, + ); + + await action.execute(api); + + expect(callback).toBeCalledWith(api); +}); diff --git a/tests/card-controller/actions/factory.test.ts b/tests/card-controller/actions/factory.test.ts index 9107ccf7..e2b733e3 100644 --- a/tests/card-controller/actions/factory.test.ts +++ b/tests/card-controller/actions/factory.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { CameraSelectAction } from '../../../src/card-controller/actions/actions/camera-select'; import { CameraUIAction } from '../../../src/card-controller/actions/actions/camera-ui'; import { DefaultAction } from '../../../src/card-controller/actions/actions/default'; @@ -7,6 +7,7 @@ import { DownloadAction } from '../../../src/card-controller/actions/actions/dow import { ExpandAction } from '../../../src/card-controller/actions/actions/expand'; import { FullscreenAction } from '../../../src/card-controller/actions/actions/fullscreen'; import { GenericAction } from '../../../src/card-controller/actions/actions/generic'; +import { InternalCallbackAction } from '../../../src/card-controller/actions/actions/internal-callback'; import { LogAction } from '../../../src/card-controller/actions/actions/log'; import { MediaPlayerAction } from '../../../src/card-controller/actions/actions/media-player'; import { MenuToggleAction } from '../../../src/card-controller/actions/actions/menu-toggle'; @@ -30,7 +31,10 @@ import { SubstreamSelectAction } from '../../../src/card-controller/actions/acti import { UnmuteAction } from '../../../src/card-controller/actions/actions/unmute'; import { ViewAction } from '../../../src/card-controller/actions/actions/view'; import { ActionFactory } from '../../../src/card-controller/actions/factory'; -import { AdvancedCameraCardCustomAction } from '../../../src/config/types'; +import { + AdvancedCameraCardCustomAction, + INTERNAL_CALLBACK_ACTION, +} from '../../../src/config/types'; // @vitest-environment jsdom describe('ActionFactory', () => { @@ -165,6 +169,13 @@ describe('ActionFactory', () => { }, StatusBarAction, ], + [ + { + advanced_camera_card_action: INTERNAL_CALLBACK_ACTION, + callback: vi.fn(), + }, + InternalCallbackAction, + ], ])( 'advanced_camera_card_action: $advanced_camera_card_action', (action: Partial, classObject: object) => { diff --git a/tests/card-controller/config/load-control-entities.test.ts b/tests/card-controller/config/load-control-entities.test.ts new file mode 100644 index 00000000..75797ac7 --- /dev/null +++ b/tests/card-controller/config/load-control-entities.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from 'vitest'; +import { setRemoteControlEntityFromConfig } from '../../../src/card-controller/config/load-control-entities'; +import { createCardAPI, createConfig, createHASS, createStore } from '../../test-utils'; +import { INTERNAL_CALLBACK_ACTION } from '../../../src/config/types'; + +describe('setRemoteControlEntityFromConfig', () => { + it('without control entity', () => { + const api = createCardAPI(); + setRemoteControlEntityFromConfig(api); + + expect(api.getAutomationsManager().deleteAutomations).toBeCalled(); + expect(api.getAutomationsManager().addAutomations).not.toBeCalled(); + }); + + it('with control entity', () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + remote_control: { + entities: { + camera: 'input_select.camera', + }, + }, + }), + ); + + 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), + }, + { + action: 'perform-action', + data: { + option: '{{ advanced_camera_card.camera }}', + }, + perform_action: 'input_select.select_option', + target: { + entity_id: 'input_select.camera', + }, + }, + ], + conditions: [ + { + condition: 'config', + paths: ['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: '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('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', + }, + }, + }), + ); + 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]; + expect(addOptionsAction.advanced_camera_card_action).toBe(INTERNAL_CALLBACK_ACTION); + + addOptionsAction.callback(api); + expect(hass.callService).toBeCalledWith( + 'input_select', + 'set_options', + { + options: ['camera.one', 'camera.two'], + }, + { + entity_id: 'input_select.camera', + }, + ); + }); +}); diff --git a/tests/card-controller/config/load-keyboard-shortcuts.test.ts b/tests/card-controller/config/load-keyboard-shortcuts.test.ts index bfa8dc1e..996342c7 100644 --- a/tests/card-controller/config/load-keyboard-shortcuts.test.ts +++ b/tests/card-controller/config/load-keyboard-shortcuts.test.ts @@ -7,9 +7,11 @@ import { PTZAction } from '../../../src/config/ptz'; describe('setKeyboardShortcutsFromConfig', () => { it('without shortcuts', () => { const api = createCardAPI(); - setKeyboardShortcutsFromConfig(api, 'tag'); + setKeyboardShortcutsFromConfig(api); - expect(api.getAutomationsManager().deleteAutomations).toBeCalledWith('tag'); + expect(api.getAutomationsManager().deleteAutomations).toBeCalledWith( + setKeyboardShortcutsFromConfig, + ); expect(api.getAutomationsManager().addAutomations).not.toBeCalled(); }); @@ -24,9 +26,11 @@ describe('setKeyboardShortcutsFromConfig', () => { }, }), ); - setKeyboardShortcutsFromConfig(api, 'tag'); + setKeyboardShortcutsFromConfig(api); - expect(api.getAutomationsManager().deleteAutomations).toBeCalledWith('tag'); + expect(api.getAutomationsManager().deleteAutomations).toBeCalledWith( + setKeyboardShortcutsFromConfig, + ); expect(api.getAutomationsManager().addAutomations).not.toBeCalled(); }); @@ -59,9 +63,11 @@ describe('setKeyboardShortcutsFromConfig', () => { }), ); - setKeyboardShortcutsFromConfig(api, 'tag'); + setKeyboardShortcutsFromConfig(api); - expect(api.getAutomationsManager().deleteAutomations).toBeCalledWith('tag'); + expect(api.getAutomationsManager().deleteAutomations).toBeCalledWith( + setKeyboardShortcutsFromConfig, + ); expect(api.getAutomationsManager().addAutomations).toBeCalledWith([ { actions: [ @@ -83,7 +89,7 @@ describe('setKeyboardShortcutsFromConfig', () => { state: 'down', }, ], - tag: 'tag', + tag: setKeyboardShortcutsFromConfig, }, { actions: [ @@ -101,7 +107,7 @@ describe('setKeyboardShortcutsFromConfig', () => { state: 'up', }, ], - tag: 'tag', + tag: setKeyboardShortcutsFromConfig, }, ]); }); @@ -110,9 +116,11 @@ describe('setKeyboardShortcutsFromConfig', () => { const api = createCardAPI(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - setKeyboardShortcutsFromConfig(api, 'tag'); + setKeyboardShortcutsFromConfig(api); - expect(api.getAutomationsManager().deleteAutomations).toBeCalledWith('tag'); + expect(api.getAutomationsManager().deleteAutomations).toBeCalledWith( + setKeyboardShortcutsFromConfig, + ); expect(api.getAutomationsManager().addAutomations).toBeCalledWith( expect.arrayContaining([ { @@ -133,7 +141,7 @@ describe('setKeyboardShortcutsFromConfig', () => { state: 'down', }, ], - tag: 'tag', + tag: setKeyboardShortcutsFromConfig, }, ]), ); diff --git a/tests/components-lib/menu-button-controller.test.ts b/tests/components-lib/menu-button-controller.test.ts index 36841e51..015fe1f2 100644 --- a/tests/components-lib/menu-button-controller.test.ts +++ b/tests/components-lib/menu-button-controller.test.ts @@ -20,7 +20,7 @@ import { ViewDisplayMode, } from '../../src/config/types'; import { AdvancedCameraCardMediaPlayer } from '../../src/types'; -import { createGeneralAction } from '../../src/utils/action'; +import { createGeneralAction, createViewAction } from '../../src/utils/action'; import { ViewMedia } from '../../src/view/media'; import { MediaQueriesResults } from '../../src/view/media-queries-results'; import { View } from '../../src/view/view'; @@ -94,7 +94,7 @@ describe('MenuButtonController', () => { type: 'custom:advanced-camera-card-menu-icon', title: 'Iris / Default View / Unhide menu', tap_action: createGeneralAction('menu_toggle'), - hold_action: createGeneralAction('diagnostics'), + hold_action: createViewAction('diagnostics'), }); }); @@ -111,7 +111,7 @@ describe('MenuButtonController', () => { type: 'custom:advanced-camera-card-menu-icon', title: 'Iris / Default View / Unhide menu', tap_action: createGeneralAction('default'), - hold_action: createGeneralAction('diagnostics'), + hold_action: createViewAction('diagnostics'), }); }); }); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index cccd51ff..2d6e2206 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -6,7 +6,7 @@ import { dimensionsConfigSchema, advancedCameraCardConditionSchema, advancedCameraCardCustomActionsBaseSchema, - advancedCameraCardCustomActionSchema, + internalAdvancedCameraCardCustomActionSchema, } from '../../src/config/types'; import { createConfig } from '../test-utils'; @@ -641,7 +641,7 @@ describe('should lazy evaluate schemas', () => { }, ], }; - expect(advancedCameraCardCustomActionSchema.parse(input)).toEqual(input); + expect(internalAdvancedCameraCardCustomActionSchema.parse(input)).toEqual(input); }); }); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 57797aea..75c71f74 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -19,7 +19,6 @@ import { ActionsManager } from '../src/card-controller/actions/actions-manager'; import { AutomationsManager } from '../src/card-controller/automations-manager'; import { CameraURLManager } from '../src/card-controller/camera-url-manager'; import { CardElementManager } from '../src/card-controller/card-element-manager'; -import { ConditionStateManager } from '../src/conditions/state-manager'; import { ConfigManager } from '../src/card-controller/config/config-manager'; import { CardController } from '../src/card-controller/controller'; import { DefaultManager } from '../src/card-controller/default-manager'; @@ -40,18 +39,17 @@ import { StatusBarItemManager } from '../src/card-controller/status-bar-item-man import { StyleManager } from '../src/card-controller/style-manager'; import { TriggersManager } from '../src/card-controller/triggers-manager'; import { ViewManager } from '../src/card-controller/view/view-manager'; +import { ConditionStateManager } from '../src/conditions/state-manager'; import { ActionsConfig, - AdvancedCameraCardCondition, AdvancedCameraCardConfig, - AdvancedCameraCardCustomAction, CameraConfig, + InternalAdvancedCameraCardCustomAction, PerformanceConfig, RawAdvancedCameraCardConfig, - advancedCameraCardConditionSchema, advancedCameraCardConfigSchema, - advancedCameraCardCustomActionSchema, cameraConfigSchema, + internalAdvancedCameraCardCustomActionSchema, performanceConfigSchema, } from '../src/config/types'; import { CapabilitiesRaw, ExtendedHomeAssistant, MediaLoadedInfo } from '../src/types'; @@ -65,8 +63,8 @@ import { View, ViewParameters } from '../src/view/view'; export const createAction = ( action: Record, -): AdvancedCameraCardCustomAction | null => { - const result = advancedCameraCardCustomActionSchema.safeParse({ +): InternalAdvancedCameraCardCustomAction | null => { + const result = internalAdvancedCameraCardCustomActionSchema.safeParse({ action: 'custom:advanced-camera-card-action', ...action, }); @@ -260,6 +258,7 @@ export const createCapabilities = (capabilities?: CapabilitiesRaw): Capabilities return new Capabilities({ 'favorite-events': false, 'favorite-recordings': false, + 'remote-control-entity': true, clips: false, live: false, recordings: false, diff --git a/tests/utils/action.test.ts b/tests/utils/action.test.ts index 9c9731a4..b097616e 100644 --- a/tests/utils/action.test.ts +++ b/tests/utils/action.test.ts @@ -1,18 +1,20 @@ import { hasAction as customCardHasAction } from '@dermotduffy/custom-card-helpers'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { actionSchema } from '../../src/config/types'; +import { actionSchema, INTERNAL_CALLBACK_ACTION } from '../../src/config/types'; import { convertActionToCardCustomAction, createCameraAction, createDisplayModeAction, createGeneralAction, + createInternalCallbackAction, createLogAction, createMediaPlayerAction, createPTZAction, createPTZControlsAction, createPTZDigitalAction, createPTZMultiAction, + createViewAction, getActionConfigGivenAction, hasAction, stopEventFromActivatingCardWideActions, @@ -45,7 +47,21 @@ describe('convertActionToAdvancedCameraCardCustomAction', () => { describe('createGeneralAction', () => { it('should create general action', () => { expect( - createGeneralAction('clips', { + createGeneralAction('camera_ui', { + cardID: 'card_id', + }), + ).toEqual({ + action: 'fire-dom-event', + advanced_camera_card_action: 'camera_ui', + card_id: 'card_id', + }); + }); +}); + +describe('createViewAction', () => { + it('should create view action', () => { + expect( + createViewAction('clips', { cardID: 'card_id', }), ).toEqual({ @@ -241,6 +257,22 @@ describe('createLogAction', () => { }); }); +describe('createInternalCallbackAction', () => { + it('should create internal callback action', () => { + const callback = vi.fn(); + expect( + createInternalCallbackAction(callback, { + cardID: 'card_id', + }), + ).toEqual({ + action: 'fire-dom-event', + advanced_camera_card_action: INTERNAL_CALLBACK_ACTION, + callback: callback, + card_id: 'card_id', + }); + }); +}); + describe('getActionConfigGivenAction', () => { const action = actionSchema.parse({ action: 'fire-dom-event',