From 7ab545738dc037dc00e6ff577d3a0b25d97f6b6b Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Wed, 5 Jun 2024 21:39:52 -0700 Subject: [PATCH] Add initial keyboard shortcut support. --- docs/configuration/README.md | 2 +- docs/configuration/_sidebar.md | 32 +- docs/configuration/actions.md | 553 ----------- docs/configuration/actions/README.md | 73 ++ docs/configuration/actions/_sidebar.md | 27 + docs/configuration/actions/custom/README.md | 737 ++++++++++++++ docs/configuration/actions/custom/_sidebar.md | 27 + docs/configuration/actions/stock/README.md | 120 +++ docs/configuration/actions/stock/_sidebar.md | 27 + docs/configuration/automations.md | 6 +- docs/configuration/cameras/README.md | 117 ++- docs/configuration/cameras/_sidebar.md | 36 +- docs/configuration/conditions.md | 29 +- docs/configuration/elements.md | 8 +- docs/configuration/image.md | 6 +- docs/configuration/live.md | 85 +- docs/configuration/media-gallery.md | 2 +- docs/configuration/media-viewer.md | 35 +- docs/configuration/menu.md | 20 +- docs/configuration/view.md | 38 +- docs/examples.md | 49 +- docs/usage/_sidebar.md | 7 +- docs/usage/keyboard-shortcuts.md | 18 + docs/usage/url-actions.md | 6 +- package.json | 3 +- src/action-handler-directive.ts | 18 +- .../browse-media/engine-browse-media.ts | 2 + src/camera-manager/camera.ts | 2 +- src/camera-manager/capabilities.ts | 12 + src/camera-manager/engine.ts | 5 +- src/camera-manager/frigate/camera.ts | 34 +- src/camera-manager/frigate/engine-frigate.ts | 13 +- src/camera-manager/generic/engine-generic.ts | 9 +- src/camera-manager/manager.ts | 26 +- src/camera-manager/utils/ptz.ts | 82 ++ src/card-controller/actions-manager.ts | 285 ------ .../actions/actions-manager.ts | 121 +++ src/card-controller/actions/actions/base.ts | 29 + .../actions/actions/camera-select.ts | 26 + .../actions/actions/camera-ui.ts | 9 + .../actions/actions/default.ts | 9 + .../actions/actions/display-mode-select.ts | 9 + .../actions/actions/download.ts | 9 + src/card-controller/actions/actions/expand.ts | 9 + .../actions/actions/fullscreen.ts | 9 + .../actions/actions/generic.ts | 20 + src/card-controller/actions/actions/log.ts | 10 + .../actions/actions/media-player.ts | 21 + .../actions/actions/menu-toggle.ts | 9 + .../actions/actions/microphone-mute.ts | 9 + .../actions/actions/microphone-unmute.ts | 9 + src/card-controller/actions/actions/mute.ts | 9 + src/card-controller/actions/actions/pause.ts | 9 + src/card-controller/actions/actions/play.ts | 9 + .../actions/actions/ptz-controls.ts | 11 + .../actions/actions/ptz-digital.ts | 126 +++ .../actions/actions/ptz-multi.ts | 60 ++ src/card-controller/actions/actions/ptz.ts | 121 +++ .../actions/actions/screenshot.ts | 9 + src/card-controller/actions/actions/set.ts | 44 + src/card-controller/actions/actions/sleep.ts | 12 + .../actions/actions/substream-off.ts | 9 + .../actions/actions/substream-on.ts | 9 + .../actions/actions/substream-select.ts | 9 + src/card-controller/actions/actions/unmute.ts | 9 + src/card-controller/actions/actions/view.ts | 16 + src/card-controller/actions/factory.ts | 133 +++ src/card-controller/actions/types.ts | 23 + .../actions/utils/action-state.ts | 25 + .../actions/utils/execution-request.ts | 38 + .../actions/utils/time-delta.ts | 10 + src/card-controller/automations-manager.ts | 53 +- src/card-controller/card-element-manager.ts | 31 +- src/card-controller/conditions-manager.ts | 21 +- .../{ => config}/config-manager.ts | 21 +- .../config/load-automations.ts | 8 + .../config/load-keyboard-shortcuts.ts | 130 +++ src/card-controller/controller.ts | 12 +- src/card-controller/keyboard-state-manager.ts | 59 ++ src/card-controller/query-string-manager.ts | 24 +- src/card-controller/types.ts | 56 +- src/card.ts | 1 + src/components-lib/key-assigner-controller.ts | 83 ++ src/components-lib/live/live-controller.ts | 1 - src/components-lib/menu-button-controller.ts | 167 ++-- src/components-lib/menu-controller.ts | 12 +- src/components-lib/ptz-controller.ts | 170 ---- src/components-lib/ptz/ptz-controller.ts | 145 +++ src/components-lib/ptz/types.ts | 21 + src/components-lib/zoom/types.ts | 30 +- src/components-lib/zoom/zoom-controller.ts | 134 ++- src/components-lib/zoom/zoom-view-context.ts | 39 +- src/components/image.ts | 100 +- src/components/key-assigner.ts | 93 ++ src/components/live/live.ts | 50 +- src/components/menu.ts | 15 +- src/components/ptz.ts | 90 +- src/components/viewer.ts | 24 +- src/components/views.ts | 2 +- src/components/zoomer.ts | 18 +- src/config/keyboard-shortcuts.ts | 47 + src/config/management.ts | 118 ++- src/config/ptz.ts | 12 + src/config/types.ts | 391 +++++--- src/const.ts | 20 +- src/declarations.d.ts | 4 + src/editor.ts | 74 +- src/localize/languages/ca.json | 21 +- src/localize/languages/en.json | 26 +- src/localize/languages/fr.json | 21 +- src/localize/languages/it.json | 21 +- src/localize/languages/pt-BR.json | 21 +- src/localize/languages/pt-PT.json | 21 +- src/scss/card.scss | 3 + src/scss/key-assigner.scss | 79 ++ src/types.ts | 9 +- src/utils/action.ts | 179 ++-- src/utils/basic.ts | 13 +- src/utils/ha/index.ts | 21 +- src/utils/ptz.ts | 86 +- src/utils/substream.ts | 4 +- src/view/view.ts | 12 +- tests/camera-manager/capabilities.test.ts | 4 +- tests/camera-manager/frigate/camera.test.ts | 19 +- .../frigate/engine-frigate.test.ts | 2 +- tests/camera-manager/manager.test.ts | 47 +- tests/camera-manager/utils/ptz.test.ts | 191 ++++ tests/card-controller/actions-manager.test.ts | 914 ------------------ .../actions/actions-manager.test.ts | 289 ++++++ .../actions/actions/base.test.ts | 19 + .../actions/actions/camera-select.test.ts | 163 ++++ .../actions/actions/camera-ui.test.ts | 18 + .../actions/actions/default.test.ts | 15 + .../actions/display-mode-select.test.ts | 16 + .../actions/actions/download.test.ts | 18 + .../actions/actions/expand.test.ts | 18 + .../actions/actions/fullscreen.test.ts | 18 + .../actions/actions/generic.test.ts | 46 + .../actions/actions/log.test.ts | 24 + .../actions/actions/media-player.test.ts | 104 ++ .../actions/actions/menu-toggle.test.ts | 18 + .../actions/actions/microphone-mute.test.ts | 18 + .../actions/actions/microphone-unmute.test.ts | 18 + .../actions/actions/mute.test.ts | 26 + .../actions/actions/pause.test.ts | 26 + .../actions/actions/play.test.ts | 26 + .../actions/actions/ptz-controls.test.ts | 21 + .../actions/actions/ptz-digital.test.ts | 479 +++++++++ .../actions/actions/ptz-multi.test.ts | 150 +++ .../actions/actions/ptz.test.ts | 513 ++++++++++ .../actions/actions/screenshot.test.ts | 18 + .../actions/actions/set.test.ts | 41 + .../actions/actions/sleep.test.ts | 29 + .../actions/actions/substream-off.test.ts | 18 + .../actions/actions/substream-on.test.ts | 18 + .../actions/actions/substream-select.test.ts | 19 + .../actions/actions/unmute.test.ts | 26 + .../actions/actions/view.test.ts | 36 + tests/card-controller/actions/factory.test.ts | 138 +++ .../actions/utils/time-delta.test.ts | 20 + .../automations-manager.test.ts | 120 +-- .../card-element-manager.test.ts | 8 +- .../conditions-manager.test.ts | 55 ++ .../{ => config}/config-manager.test.ts | 14 +- .../config/load-automations.test.ts | 38 + .../config/load-keyboard-shortcuts.test.ts | 143 +++ tests/card-controller/controller.test.ts | 18 +- .../keyboard-state-manager.test.ts | 86 ++ .../query-string-manager.test.ts | 32 +- .../key-assigner-controller.test.ts | 131 +++ .../menu-button-controller.test.ts | 136 +-- tests/components-lib/menu-controller.test.ts | 60 +- tests/components-lib/ptz-controller.test.ts | 316 ------ .../components-lib/ptz/ptz-controller.test.ts | 378 ++++++++ .../zoom/zoom-controller.test.ts | 119 ++- .../zoom/zoom-view-context.test.ts | 90 +- tests/config/management.test.ts | 774 +++++++++++++-- tests/config/types.test.ts | 213 +++- tests/test-utils.ts | 30 +- tests/utils/action.test.ts | 241 +++-- tests/utils/basic.test.ts | 48 + tests/utils/ptz.test.ts | 178 +++- tests/utils/substream.test.ts | 13 + tsconfig.json | 10 +- vite.config.ts | 5 +- yarn.lock | 20 +- 186 files changed, 9564 insertions(+), 3658 deletions(-) delete mode 100644 docs/configuration/actions.md create mode 100644 docs/configuration/actions/README.md create mode 100644 docs/configuration/actions/_sidebar.md create mode 100644 docs/configuration/actions/custom/README.md create mode 100644 docs/configuration/actions/custom/_sidebar.md create mode 100644 docs/configuration/actions/stock/README.md create mode 100644 docs/configuration/actions/stock/_sidebar.md create mode 100644 docs/usage/keyboard-shortcuts.md create mode 100644 src/camera-manager/utils/ptz.ts delete mode 100644 src/card-controller/actions-manager.ts create mode 100644 src/card-controller/actions/actions-manager.ts create mode 100644 src/card-controller/actions/actions/base.ts create mode 100644 src/card-controller/actions/actions/camera-select.ts create mode 100644 src/card-controller/actions/actions/camera-ui.ts create mode 100644 src/card-controller/actions/actions/default.ts create mode 100644 src/card-controller/actions/actions/display-mode-select.ts create mode 100644 src/card-controller/actions/actions/download.ts create mode 100644 src/card-controller/actions/actions/expand.ts create mode 100644 src/card-controller/actions/actions/fullscreen.ts create mode 100644 src/card-controller/actions/actions/generic.ts create mode 100644 src/card-controller/actions/actions/log.ts create mode 100644 src/card-controller/actions/actions/media-player.ts create mode 100644 src/card-controller/actions/actions/menu-toggle.ts create mode 100644 src/card-controller/actions/actions/microphone-mute.ts create mode 100644 src/card-controller/actions/actions/microphone-unmute.ts create mode 100644 src/card-controller/actions/actions/mute.ts create mode 100644 src/card-controller/actions/actions/pause.ts create mode 100644 src/card-controller/actions/actions/play.ts create mode 100644 src/card-controller/actions/actions/ptz-controls.ts create mode 100644 src/card-controller/actions/actions/ptz-digital.ts create mode 100644 src/card-controller/actions/actions/ptz-multi.ts create mode 100644 src/card-controller/actions/actions/ptz.ts create mode 100644 src/card-controller/actions/actions/screenshot.ts create mode 100644 src/card-controller/actions/actions/set.ts create mode 100644 src/card-controller/actions/actions/sleep.ts create mode 100644 src/card-controller/actions/actions/substream-off.ts create mode 100644 src/card-controller/actions/actions/substream-on.ts create mode 100644 src/card-controller/actions/actions/substream-select.ts create mode 100644 src/card-controller/actions/actions/unmute.ts create mode 100644 src/card-controller/actions/actions/view.ts create mode 100644 src/card-controller/actions/factory.ts create mode 100644 src/card-controller/actions/types.ts create mode 100644 src/card-controller/actions/utils/action-state.ts create mode 100644 src/card-controller/actions/utils/execution-request.ts create mode 100644 src/card-controller/actions/utils/time-delta.ts rename src/card-controller/{ => config}/config-manager.ts (85%) create mode 100644 src/card-controller/config/load-automations.ts create mode 100644 src/card-controller/config/load-keyboard-shortcuts.ts create mode 100644 src/card-controller/keyboard-state-manager.ts create mode 100644 src/components-lib/key-assigner-controller.ts delete mode 100644 src/components-lib/ptz-controller.ts create mode 100644 src/components-lib/ptz/ptz-controller.ts create mode 100644 src/components-lib/ptz/types.ts create mode 100644 src/components/key-assigner.ts create mode 100644 src/config/keyboard-shortcuts.ts create mode 100644 src/config/ptz.ts create mode 100644 src/scss/key-assigner.scss create mode 100644 tests/camera-manager/utils/ptz.test.ts delete mode 100644 tests/card-controller/actions-manager.test.ts create mode 100644 tests/card-controller/actions/actions-manager.test.ts create mode 100644 tests/card-controller/actions/actions/base.test.ts create mode 100644 tests/card-controller/actions/actions/camera-select.test.ts create mode 100644 tests/card-controller/actions/actions/camera-ui.test.ts create mode 100644 tests/card-controller/actions/actions/default.test.ts create mode 100644 tests/card-controller/actions/actions/display-mode-select.test.ts create mode 100644 tests/card-controller/actions/actions/download.test.ts create mode 100644 tests/card-controller/actions/actions/expand.test.ts create mode 100644 tests/card-controller/actions/actions/fullscreen.test.ts create mode 100644 tests/card-controller/actions/actions/generic.test.ts create mode 100644 tests/card-controller/actions/actions/log.test.ts create mode 100644 tests/card-controller/actions/actions/media-player.test.ts create mode 100644 tests/card-controller/actions/actions/menu-toggle.test.ts create mode 100644 tests/card-controller/actions/actions/microphone-mute.test.ts create mode 100644 tests/card-controller/actions/actions/microphone-unmute.test.ts create mode 100644 tests/card-controller/actions/actions/mute.test.ts create mode 100644 tests/card-controller/actions/actions/pause.test.ts create mode 100644 tests/card-controller/actions/actions/play.test.ts create mode 100644 tests/card-controller/actions/actions/ptz-controls.test.ts create mode 100644 tests/card-controller/actions/actions/ptz-digital.test.ts create mode 100644 tests/card-controller/actions/actions/ptz-multi.test.ts create mode 100644 tests/card-controller/actions/actions/ptz.test.ts create mode 100644 tests/card-controller/actions/actions/screenshot.test.ts create mode 100644 tests/card-controller/actions/actions/set.test.ts create mode 100644 tests/card-controller/actions/actions/sleep.test.ts create mode 100644 tests/card-controller/actions/actions/substream-off.test.ts create mode 100644 tests/card-controller/actions/actions/substream-on.test.ts create mode 100644 tests/card-controller/actions/actions/substream-select.test.ts create mode 100644 tests/card-controller/actions/actions/unmute.test.ts create mode 100644 tests/card-controller/actions/actions/view.test.ts create mode 100644 tests/card-controller/actions/factory.test.ts create mode 100644 tests/card-controller/actions/utils/time-delta.test.ts rename tests/card-controller/{ => config}/config-manager.test.ts (94%) create mode 100644 tests/card-controller/config/load-automations.test.ts create mode 100644 tests/card-controller/config/load-keyboard-shortcuts.test.ts create mode 100644 tests/card-controller/keyboard-state-manager.test.ts create mode 100644 tests/components-lib/key-assigner-controller.test.ts delete mode 100644 tests/components-lib/ptz-controller.test.ts create mode 100644 tests/components-lib/ptz/ptz-controller.test.ts diff --git a/docs/configuration/README.md b/docs/configuration/README.md index 7104216b..5cc275de 100644 --- a/docs/configuration/README.md +++ b/docs/configuration/README.md @@ -39,5 +39,5 @@ Only the `cameras` option is required, all other parameters are optional. | Option | Description | | - | - | -| [`actions`](actions.md) | Configure actions. | +| [`actions`](actions/README.md) | Configure actions. | | [`conditions`](conditions.md) | Configure conditions. | diff --git a/docs/configuration/_sidebar.md b/docs/configuration/_sidebar.md index c07b3bbb..8bbd9965 100644 --- a/docs/configuration/_sidebar.md +++ b/docs/configuration/_sidebar.md @@ -1,21 +1,21 @@ * [Getting Started](../README.md) * [Configuration](README.md) - * [`actions`](actions.md) - * [`automations`](automations.md) - * [`cameras`](cameras/README.md) - * [`conditions`](conditions.md) - * [`dimensions`](dimensions.md) - * [`elements`](elements.md) - * [`image`](image.md) - * [`live`](live.md) - * [`media_gallery`](media-gallery.md) - * [`media_viewer`](media-viewer.md) - * [`menu`](menu.md) - * [`overrides`](overrides.md) - * [`performance`](performance.md) - * [`profiles`](profiles.md) - * [`timeline`](timeline.md) - * [`view`](view.md) + * [`actions`](actions/README.md) + * [`automations`](automations.md) + * [`cameras`](cameras/README.md) + * [`conditions`](conditions.md) + * [`dimensions`](dimensions.md) + * [`elements`](elements.md) + * [`image`](image.md) + * [`live`](live.md) + * [`media_gallery`](media-gallery.md) + * [`media_viewer`](media-viewer.md) + * [`menu`](menu.md) + * [`overrides`](overrides.md) + * [`performance`](performance.md) + * [`profiles`](profiles.md) + * [`timeline`](timeline.md) + * [`view`](view.md) * [Examples](../examples.md) * [Screenshots](../screenshots.md) * [Troubleshooting](../troubleshooting.md) diff --git a/docs/configuration/actions.md b/docs/configuration/actions.md deleted file mode 100644 index b371f765..00000000 --- a/docs/configuration/actions.md +++ /dev/null @@ -1,553 +0,0 @@ -# `actions` - -## Introduction to actions - -`actions` is not a top-level configuration block, but can be used as part of -multiple other blocks. - -Actions are pre-configured activities that can be triggered in response to a -variety of circumstances (e.g. tapping on a menu icon, double tapping on an -[element](./elements.md) or holding the mouse/tap down on a particular -[view](./view.md?id=supported-views)). - -### Differences in actions between Frigate Card and Home Assistant - -Both the Home Assistant frontend and the Frigate card cooperate to provide -action functionality. In general, the Frigate Card functionality is a superset -of that offered by stock Home Assistant. - -Stock action functionality is used for Stock [Home Assistant picture -elements](https://www.home-assistant.io/lovelace/picture-elements/). Extended -Frigate card behavior covers all other interactions on the Frigate card (e.g. -menu icon elements, submenus and actions on the card or views). - -#### Custom action types: `start_tap` and `end_tap` - -The card has partial support for two special action types `start_tap` and -`end_tap` which occur when a tap is started (e.g. mouse is pressed down / -touch begins), and ended (e.g. mouse released / touch ends) respectively. This -might be useful for PTZ cameras cameras to start/stop movement on touch. Network -latency may introduce unavoidable imprecision between `end_tap` and action -actually occurring. - -#### Multiple actions - -Extended Frigate card behavior supports a list of actions instead of a single -action, all of which will be handled. See [an example of multiple -actions](../examples.md?id=multiple-actions). - -## Card and view actions - -Actions may be attached to the card itself, to trigger action when the card -experiences a `tap`, `double_tap`, `hold`, `start_tap` or `end_tap` event. -Alternatively they can be configured on a per group-of-views basis, e.g. only -when `live` view is tapped. - -| Configuration path | Views to which it refers | -| - | - | -| `image.actions` | `image` | -| `live.actions` | `live` | -| `media_gallery.actions` | `clips`, `snapshots`, `recordings` | -| `media_viewer.actions` | `clip`, `snapshot`, `recording` | -| `view.actions` | All | - -If an action is configured for both the whole card (`view.actions`) and a more -specific view (e.g. `live.actions`) then the actions are merged, with the more -specific overriding the less specific. - -!> The card itself relies on user interactions to function (e.g. `tap` on -the menu should activate that button). Card or View actions are prevented from -being activated through standard interaction with menu buttons, next/previous -controls, thumbnails, etc, but in some cases this prevention is not possible -(e.g. embedded WebRTC card controls) -- in these cases duplicate actions may -occur with certain configurations (e.g. `tap`). - -!> Card-wide actions are not supported on timelines nor when a info/error message -is being displayed. - -## `call-service` - -Call a service. See [Home Assistant actions documentation](https://www.home-assistant.io/dashboards/actions/). - -```yaml -action: call-service -[...] -``` - -## `custom:frigate-card-action` - -Execute a Frigate Card action. - -```yaml -action: custom:frigate-card-action -[...] -``` - -| Parameter | Description | -| - | - | -| `action` | Must be `custom:frigate-card-action`. | -| `frigate_card_action` | A supported Frigate Card action. See below. | - -### `camera_select` - -Select a given camera. - -```yaml -action: custom:frigate-card-action -frigate_card_action: camera_select -[...] -``` - -| Parameter | Description | -| - | - | -| `action` | Must be `custom:frigate-card-action`. | -| `frigate_card_action` | Must be `camera_select`. | -| `camera` | The [camera ID](cameras/README.md?id=cameras) of the camera to select. | -| `triggered` | If `true` instead of `camera` being specified then a triggered camera (if any) is selected instead. | - -This action will respect the value of the `view.camera_select` to choose the appropriate view on the new camera. See [`view` configuration](view.md). - -### `camera_ui` - -Download the displayed media. - -```yaml -action: custom:frigate-card-action -frigate_card_action: camera_ui -``` - -Open the UI for the selected camera engine (e.g. the Frigate UI). - -### `change_zoom` - -Zoom in and/or pan for a given camera. - -```yaml -action: custom:frigate-card-action -frigate_card_action: change_zoom -[...] -``` - -| Parameter | Description | -| - | - | -| `action` | Must be `custom:frigate-card-action`. | -| `frigate_card_action` | Must be `change_zoom`. | -| `target_id` | The [camera ID](cameras/README.md?id=cameras) or a media ID (e.g. `frigate` event ID) to change zoom/pam settings for. | -| `zoom` | Optional parameter that controls how much to zoom-in. See the [camera zoom parameter](cameras/README.md?id=layout-configuration). | -| `pan` | Optional parameter that controls how much to pan-x/y. See the [camera pan parameter](cameras/README.md?id=layout-configuration). | - -?> If neither `zoom` nor `pan` are specified the camera will return to its default zoom and pan settings. - -See [example of automatically zoom/panning based on state](../examples.md?id=automatically-zoom-based-on-state). - -### `clip`, `clips`, `image`, `live`, `recording`, `recordings`, `snapshot`, `snapshots` - -Change to the specified view. - -```yaml -action: custom:frigate-card-action -frigate_card_action: [view] -``` - -### `default` - -Change to the default view. - -```yaml -action: custom:frigate-card-action -frigate_card_action: default -``` - -### `download` - -Download the displayed media. - -```yaml -action: custom:frigate-card-action -frigate_card_action: download -``` - -### `expand` - -Expand the card into a dialog/popup. - -```yaml -action: custom:frigate-card-action -frigate_card_action: expand -``` - -### `fullscreen` - -Toggle fullscreen. - -```yaml -action: custom:frigate-card-action -frigate_card_action: fullscreen -``` - -### `live_substream_select` - -Select a substream. - -```yaml -action: custom:frigate-card-action -frigate_card_action: live_substream_select -[...] -``` - -| Parameter | Description | -| - | - | -| `action` | Must be `custom:frigate-card-action`. | -| `frigate_card_action` | Must be `live_substream_select`. | -| `camera` | The [camera ID](cameras/README.md?id=cameras) of the substream to select. | - -### `media_player` - -Perform a media player action. - -```yaml -action: custom:frigate-card-action -frigate_card_action: media_player -[...] -``` - -| Parameter | Description | -| - | - | -| `action` | Must be `custom:frigate-card-action`. | -| `frigate_card_action` | Must be `media_player`. | -| `media_player` | The entity ID of the media_player on which to perform the action. | -| `media_player_action` | Either `play` or `stop` to play or stop the media in question. | - -### `menu_toggle` - -Show/hide the menu (for the `hidden` mode style). - -```yaml -action: custom:frigate-card-action -frigate_card_action: menu_toggle -``` - -### `microphone_mute`, `microphone_unmute` - -Mute/Unmute the microphone during [2-way audio](../usage/2-way-audio.md). - -```yaml -action: custom:frigate-card-action -frigate_card_action: microphone_mute -``` - -```yaml -action: custom:frigate-card-action -frigate_card_action: microphone_unmute -``` - -### `mute`, `unmute` - -Mute/Unmute the selected media. - -```yaml -action: custom:frigate-card-action -frigate_card_action: mute -``` - -```yaml -action: custom:frigate-card-action -frigate_card_action: unmute -``` - -### `play`, `pause` - -Play/Pause the selected media. - -```yaml -action: custom:frigate-card-action -frigate_card_action: play -``` - -```yaml -action: custom:frigate-card-action -frigate_card_action: pause -``` - -### `ptz` - -Execute a native PTZ action (only for native out-of-the-box PTZ camera engines, e.g. Frigate). - - Takes a required `ptz_action` parameter that is one of . - -```yaml -action: custom:frigate-card-action -frigate_card_action: ptz -[...] -``` - -| Parameter | Description | -| - | - | -| `action` | Must be `custom:frigate-card-action`. | -| `frigate_card_action` | Must be `ptz`. | -| `ptz_action` | One of `left`, `right`, `up`, `down`, `zoom_in`, `zoom_out` or `preset`. | -| `ptz_phase` | Optional parameter that is one of `start` or `stop` to start or stop the movement separately. | -| `ptz_preset` | Optional preset to execute when the `ptz_action` is `preset`. | - -### `screenshot` - -Take a screenshot of the selected media (e.g. a still from a video). - -```yaml -action: custom:frigate-card-action -frigate_card_action: screenshot -``` - -### `show_ptz` - -Show or hide the PTZ controls. - -```yaml -action: custom:frigate-card-action -frigate_card_action: show_ptz -[...] -``` - -| Parameter | Description | -| - | - | -| `action` | Must be `custom:frigate-card-action`. | -| `frigate_card_action` | Must be `show_ptz`. | -| `show_ptz` | If `true` shows the PTZ controls, if `false` hides them. | - -## `more-info` - -Open the "more-info" dialog for an entity. See [Home Assistant actions documentation](https://www.home-assistant.io/dashboards/actions/). - -```yaml -action: more-info -[...] -``` - -## `navigate` - -Navigate to a particular dashboard path. See [Home Assistant actions documentation](https://www.home-assistant.io/dashboards/actions/). - -```yaml -action: navigate -[...] -``` - -## `toggle` - -Toggle an entity. See [Home Assistant actions documentation](https://www.home-assistant.io/dashboards/actions/). - -```yaml -action: toggle -[...] -``` - -## `url` - -Navigate to an arbitrary URL. See [Home Assistant actions documentation](https://www.home-assistant.io/dashboards/actions/). - -```yaml -action: url -[...] -``` - -## Fully expanded reference - -[](common/expanded-warning.md ':include') - -### Stock Home Assistant actions - -Reference: [Home Assistant Actions](https://www.home-assistant.io/dashboards/actions/). - -```yaml -elements: - - type: icon - icon: mdi:numeric-1-box - title: More info action - style: - left: 200px - top: 50px - entity: light.office_main_lights - tap_action: - action: more-info - - type: icon - icon: mdi:numeric-2-box - title: Toggle action - style: - left: 200px - top: 100px - entity: light.office_main_lights - tap_action: - action: toggle - - type: icon - icon: mdi:numeric-3-box - title: Call Service action - style: - left: 200px - top: 150px - tap_action: - action: call-service - service: homeassistant.toggle - service_data: - entity_id: light.office_main_lights - - type: icon - icon: mdi:numeric-4-box - title: Navigate action - style: - left: 200px - top: 200px - tap_action: - action: navigate - navigation_path: /lovelace/2 - - type: icon - icon: mdi:numeric-5-box - title: URL action - style: - left: 200px - top: 250px - tap_action: - action: url - url_path: https://www.home-assistant.io/ - - type: icon - icon: mdi:numeric-6-box - title: None action - style: - left: 200px - top: 300px - tap_action: - action: none - - type: icon - icon: mdi:numeric-7-box - title: Custom action - style: - left: 200px - top: 350px - tap_action: - action: fire-dom-event - key: value -``` - -### Frigate Card actions - -```yaml -elements: - - type: custom:frigate-card-menu-icon - icon: mdi:alpha-a-circle - title: Show default view - tap_action: - action: custom:frigate-card-action - frigate_card_action: default - - type: custom:frigate-card-menu-icon - icon: mdi:alpha-b-circle - title: Show most recent clip - tap_action: - action: custom:frigate-card-action - frigate_card_action: clip - - type: custom:frigate-card-menu-icon - icon: mdi:alpha-c-circle - title: Show clips - tap_action: - action: custom:frigate-card-action - frigate_card_action: clips - - type: custom:frigate-card-menu-icon - icon: mdi:alpha-d-circle - title: Show image view - tap_action: - action: custom:frigate-card-action - frigate_card_action: image - - type: custom:frigate-card-menu-icon - icon: mdi:alpha-e-circle - title: Show live view - tap_action: - action: custom:frigate-card-action - frigate_card_action: live - - type: custom:frigate-card-menu-icon - icon: mdi:alpha-f-circle - title: Show most recent snapshot - tap_action: - action: custom:frigate-card-action - frigate_card_action: snapshot - - type: custom:frigate-card-menu-icon - icon: mdi:alpha-g-circle - title: Show snapshots - tap_action: - action: custom:frigate-card-action - frigate_card_action: snapshots - - type: custom:frigate-card-menu-icon - icon: mdi:alpha-h-circle - title: Download media - tap_action: - action: custom:frigate-card-action - frigate_card_action: download - - type: custom:frigate-card-menu-icon - icon: mdi:alpha-i-circle - title: Open Frigate UI - tap_action: - action: custom:frigate-card-action - frigate_card_action: camera_ui - - type: custom:frigate-card-menu-icon - icon: mdi:alpha-j-circle - title: Change to fullscreen - tap_action: - action: custom:frigate-card-action - frigate_card_action: fullscreen - - type: custom:frigate-card-menu-icon - icon: mdi:alpha-k-circle - title: Toggle hidden menu - tap_action: - action: custom:frigate-card-action - frigate_card_action: menu_toggle - - type: custom:frigate-card-menu-icon - icon: mdi:alpha-l-circle - title: Select Front Door - tap_action: - action: custom:frigate-card-action - frigate_card_action: camera_select - camera: camera.front_door - - type: custom:frigate-card-menu-icon - icon: mdi:alpha-m-circle - title: Media player play - tap_action: - action: custom:frigate-card-action - frigate_card_action: media_player - media_player: media_player.nesthub50be - media_player_action: play - - type: custom:frigate-card-menu-icon - icon: mdi:alpha-n-circle - title: Media player stop - tap_action: - action: custom:frigate-card-action - frigate_card_action: media_player - media_player: media_player.nesthub - media_player_action: stop - - type: custom:frigate-card-menu-icon - icon: mdi:alpha-o-circle - title: Screenshot - tap_action: - action: custom:frigate-card-action - frigate_card_action: screenshot - - type: custom:frigate-card-menu-icon - icon: mdi:alpha-p-circle - title: Show PTZ - tap_action: - action: custom:frigate-card-action - frigate_card_action: show_ptz - show_ptz: true - - type: custom:frigate-card-menu-icon - icon: mdi:alpha-q-circle - title: Native PTZ Preset - tap_action: - action: custom:frigate-card-action - frigate_card_action: ptz - ptz_action: preset - ptz_preset: doorway - - type: custom:frigate-card-menu-icon - icon: mdi:alpha-r-circle - title: Change Zoom - tap_action: - action: custom:frigate-card-action - frigate_card_action: change_zoom - pan: - x: 50 - y: 50 - zoom: 1 -``` diff --git a/docs/configuration/actions/README.md b/docs/configuration/actions/README.md new file mode 100644 index 00000000..1853b004 --- /dev/null +++ b/docs/configuration/actions/README.md @@ -0,0 +1,73 @@ +# `actions` + +## Introduction to actions + +`actions` is not a top-level configuration block, but can be used as part of +multiple other blocks. + +Actions are pre-configured activities that can be triggered in response to a +variety of circumstances (e.g. tapping on a menu icon, double tapping on an +[element](../elements.md) or holding the mouse/tap down on a particular +[view](../view.md?id=supported-views)). + +### Differences in actions between Frigate Card and Home Assistant + +Both the Home Assistant frontend and the Frigate card cooperate to provide +action functionality. In general, the Frigate Card functionality is a superset +of that offered by stock Home Assistant. + +Stock action functionality is used for Stock [Home Assistant picture +elements](https://www.home-assistant.io/lovelace/picture-elements/). Extended +Frigate card behavior covers all other interactions on the Frigate card (e.g. +menu icon elements, submenus and actions on the card or views). + +#### Custom action types: `start_tap` and `end_tap` + +The card has partial support for two special action types `start_tap` and +`end_tap` which occur when a tap is started (e.g. mouse is pressed down / +touch begins), and ended (e.g. mouse released / touch ends) respectively. This +might be useful for PTZ cameras cameras to start/stop movement on touch. Network +latency may introduce unavoidable imprecision between `end_tap` and action +actually occurring. + +#### Multiple actions + +Extended Frigate card behavior supports a list of actions instead of a single +action, all of which will be handled. See [an example of multiple +actions](../../examples.md?id=multiple-actions). + +## Card and view actions + +Actions may be attached to the card itself, to trigger action when the card +experiences a `tap`, `double_tap`, `hold`, `start_tap` or `end_tap` event. +Alternatively they can be configured on a per group-of-views basis, e.g. only +when `live` view is tapped. + +| Configuration path | Views to which it refers | +| ----------------------- | ---------------------------------- | +| `image.actions` | `image` | +| `live.actions` | `live` | +| `media_gallery.actions` | `clips`, `snapshots`, `recordings` | +| `media_viewer.actions` | `clip`, `snapshot`, `recording` | +| `view.actions` | All except `timeline` and `diagnostic` | + +If an action is configured for both the whole card (`view.actions`) and a more +specific view (e.g. `live.actions`) then the actions are merged, with the more +specific overriding the less specific. + +!> The card itself relies on user interactions to function (e.g. `tap` on +the menu should activate that button). Card or View actions are prevented from +being activated through standard interaction with menu buttons, next/previous +controls, thumbnails, etc, but in some cases this prevention is not possible +(e.g. embedded WebRTC card controls) -- in these cases duplicate actions may +occur with certain configurations (e.g. `tap`). + +!> Card-wide actions are not supported on the `timeline` view, `diagnostics` +view nor when a info/error message is being displayed. + +## Actions + +| Option | Description | +| ------------------------------------ | ----------------------------------- | +| [Custom Actions](./custom/README.md) | Custom actions to control the card. | +| [Stock Actions](./stock/README.md) | Standard Home Assistant actions. | diff --git a/docs/configuration/actions/_sidebar.md b/docs/configuration/actions/_sidebar.md new file mode 100644 index 00000000..04802078 --- /dev/null +++ b/docs/configuration/actions/_sidebar.md @@ -0,0 +1,27 @@ +* [Getting Started](../../README.md) +* [Configuration](../README.md) + * [`actions`](README.md) + * [Custom Actions](./custom/README.md) + * [Stock Actions](./stock/README.md) + * [`automations`](../automations.md) + * [`cameras`](../cameras/README.md) + * [`conditions`](../conditions.md) + * [`dimensions`](../dimensions.md) + * [`elements`](../elements.md) + * [`image`](../image.md) + * [`live`](../live.md) + * [`media_gallery`](../media-gallery.md) + * [`media_viewer`](../media-viewer.md) + * [`menu`](../menu.md) + * [`overrides`](../overrides.md) + * [`performance`](../performance.md) + * [`profiles`](../profiles.md) + * [`timeline`](../timeline.md) + * [`view`](../view.md) +* [Screenshots](../../screenshots.md) +* [Troubleshooting](../../troubleshooting.md) +* [Usage](../../usage/README.md) + +--- + +* [Developing](../../developing.md) \ No newline at end of file diff --git a/docs/configuration/actions/custom/README.md b/docs/configuration/actions/custom/README.md new file mode 100644 index 00000000..66f53159 --- /dev/null +++ b/docs/configuration/actions/custom/README.md @@ -0,0 +1,737 @@ +# `custom:frigate-card-action` + +Execute a Frigate Card action. + +```yaml +action: custom:frigate-card-action +[...] +``` + +| Parameter | Description | +| - | - | +| `action` | Must be `custom:frigate-card-action`. | +| `frigate_card_action` | A supported Frigate Card action. See below. | + +## `camera_select` + +Select a given camera. + +```yaml +action: custom:frigate-card-action +frigate_card_action: camera_select +[...] +``` + +| Parameter | Description | +| - | - | +| `action` | Must be `custom:frigate-card-action`. | +| `frigate_card_action` | Must be `camera_select`. | +| `camera` | The [camera ID](../../cameras/README.md?id=cameras) of the camera to select. | +| `triggered` | If `true` instead of `camera` being specified then a triggered camera (if any) is selected instead. | + +This action will respect the value of the `view.camera_select` to choose the appropriate view on the new camera. See [`view` configuration](../../view.md). + +## `camera_ui` + +Download the displayed media. + +```yaml +action: custom:frigate-card-action +frigate_card_action: camera_ui +``` + +Open the UI for the selected camera engine (e.g. the Frigate UI). + +## `change_zoom` + +Zoom in and/or pan for a given camera. + +```yaml +action: custom:frigate-card-action +frigate_card_action: change_zoom +[...] +``` + +| Parameter | Description | +| - | - | +| `action` | Must be `custom:frigate-card-action`. | +| `frigate_card_action` | Must be `change_zoom`. | +| `target_id` | The [camera ID](../../cameras/README.md?id=cameras) or a media ID (e.g. `frigate` event ID) to change zoom/pam settings for. | +| `zoom` | Optional parameter that controls how much to zoom-in. See the [camera zoom parameter](../../cameras/README.md?id=layout-configuration). | +| `pan` | Optional parameter that controls how much to pan-x/y. See the [camera pan parameter](../../cameras/README.md?id=layout-configuration). | + +?> If neither `zoom` nor `pan` are specified the camera will return to its default zoom and pan settings. + +See [example of automatically zoom/panning based on state](../../../examples.md?id=automatically-zoom-based-on-state). + +## `clip` + +Change to the `clip` view. + +```yaml +action: custom:frigate-card-action +frigate_card_action: clip +``` + +## `clips` + +Change to the `clips` view. + +```yaml +action: custom:frigate-card-action +frigate_card_action: clips +``` + +## `default` + +Change to the default view. + +```yaml +action: custom:frigate-card-action +frigate_card_action: default +``` + +## `diagnostics` + +Show the card diagnostics. + +```yaml +action: custom:frigate-card-action +frigate_card_action: diagnostics +``` + +## `display_mode_select` + +Select a display mode (e.g. view a `single` camera or a `grid` of cameras). + +```yaml +action: custom:frigate-card-action +frigate_card_action: display_mode_select +[...] +``` + +| Parameter | Description | +| - | - | +| `action` | Must be `custom:frigate-card-action`. | +| `frigate_card_action` | Must be `display_mode_select`. | +| `display_mode` | `single` to show a single camera at a time in a carousel, or `grid` to show a grid of cameras. | + +## `download` + +Download the displayed media. + +```yaml +action: custom:frigate-card-action +frigate_card_action: download +``` + +## `expand` + +Expand the card into a dialog/popup. + +```yaml +action: custom:frigate-card-action +frigate_card_action: expand +``` + +## `fullscreen` + +Toggle fullscreen. + +```yaml +action: custom:frigate-card-action +frigate_card_action: fullscreen +``` + +## `image` + +Change to the `image` view. + +```yaml +action: custom:frigate-card-action +frigate_card_action: image +``` + +## `live` + +Change to the `live` view. + +```yaml +action: custom:frigate-card-action +frigate_card_action: live +``` + +## `live_substream_off` + +Turn off the substream (if on). + +```yaml +action: custom:frigate-card-action +frigate_card_action: live_substream_on +``` + +| Parameter | Description | +| - | - | +| `action` | Must be `custom:frigate-card-action`. | +| `frigate_card_action` | Must be `live_substream_on`. | + +## `live_substream_on` + +Turn on the first available substream. Use [Camera dependency configuration](../../cameras/README.md?id=dependencies) to configure substreams. + +```yaml +action: custom:frigate-card-action +frigate_card_action: live_substream_on +``` + +| Parameter | Description | +| - | - | +| `action` | Must be `custom:frigate-card-action`. | +| `frigate_card_action` | Must be `live_substream_on`. | + + + +## `live_substream_select` + +Select a substream. Use [Camera dependency configuration](../../cameras/README.md?id=dependencies) to configure substreams. + +```yaml +action: custom:frigate-card-action +frigate_card_action: live_substream_select +[...] +``` + +| Parameter | Description | +| - | - | +| `action` | Must be `custom:frigate-card-action`. | +| `frigate_card_action` | Must be `live_substream_select`. | +| `camera` | The [camera ID](../../cameras/README.md?id=cameras) of the substream to select. | + +## `log` + +Log a message to the Javascript console. + +```yaml +action: custom:frigate-card-action +frigate_card_action: log +[...] +``` + +| Parameter | Default | Description | +| - | - | - | +| `action` | | Must be `custom:frigate-card-action`. | +| `frigate_card_action` | | Must be `log`. | +| `message` | | The message to log. | +| `level` | `info` | The console logging level to use. One of `['debug', 'info', 'warn', 'error']`. | + +## `media_player` + +Perform a media player action. + +```yaml +action: custom:frigate-card-action +frigate_card_action: media_player +[...] +``` + +| Parameter | Description | +| - | - | +| `action` | Must be `custom:frigate-card-action`. | +| `frigate_card_action` | Must be `media_player`. | +| `media_player` | The entity ID of the media_player on which to perform the action. | +| `media_player_action` | Either `play` or `stop` to play or stop the media in question. | + +## `menu_toggle` + +Show/hide the menu (for the `hidden` mode style). + +```yaml +action: custom:frigate-card-action +frigate_card_action: menu_toggle +``` + +## `microphone_mute` + +Mute the microphone during [2-way audio](../../../usage/2-way-audio.md). + +```yaml +action: custom:frigate-card-action +frigate_card_action: microphone_mute +``` + +## `microphone_unmute` + +Unmute the microphone during [2-way audio](../../../usage/2-way-audio.md). + +```yaml +action: custom:frigate-card-action +frigate_card_action: microphone_unmute +``` + +## `mute` + +Mute the selected media. + +```yaml +action: custom:frigate-card-action +frigate_card_action: mute +``` + +## `pause` + +Pause the selected media. + +```yaml +action: custom:frigate-card-action +frigate_card_action: pause +``` + +## `play` + +Play the selected media. + +```yaml +action: custom:frigate-card-action +frigate_card_action: play +``` + +## `ptz` + +Execute a real PTZ action, whether configured manually (see [Camera PTZ configuration](../../cameras/README.md?id=ptz)) or auto-detected. + +```yaml +action: custom:frigate-card-action +frigate_card_action: ptz +[...] +``` + +| Parameter | | Description | +| - | - | - | +| `action` | | Must be `custom:frigate-card-action`. | +| `frigate_card_action` | | Must be `ptz`. | +| `camera` | Currently selected camera | An optional camera ID to execute the action on. | +| `ptz_action` | | Optional action that is of `left`, `right`, `up`, `down`, `zoom_in`, `zoom_out` or `preset`. | +| `ptz_phase` | | Optional parameter that is one of `start` or `stop` to start or stop the movement separately. | +| `ptz_preset` | | Optional preset to execute when the `ptz_action` is `preset`. | + +?> If no `ptz_action` is specified, the camera returns to its "home" position. For a real PTZ camera, the "home" position is the first available preset. If there are no presets, there is no home position. + +## `ptz_controls` + +Show or hide the PTZ controls. + +```yaml +action: custom:frigate-card-action +frigate_card_action: ptz_controls +[...] +``` + +| Parameter | Description | +| - | - | +| `action` | Must be `custom:frigate-card-action`. | +| `frigate_card_action` | Must be `ptz_controls`. | +| `show` | If `true` shows the PTZ controls, if `false` hides them. | + +## `ptz_digital` + +Execute a digital PTZ action. + +```yaml +action: custom:frigate-card-action +frigate_card_action: ptz-digital +[...] +``` + +| Parameter | Default | Description | +| - | - | - | +| `action` | | Must be `custom:frigate-card-action`. | +| `frigate_card_action` | | Must be `ptz-digital`. | +| `target_id` | The currently selected camera or media | The target (camera or media) to execute a digital PTZ action on. Can be a camera ID, or another media ID (e.g. for Frigate, can specify a media/event ID). | +| `ptz_action` | Optional action that is one of `left`, `right`, `up`, `down`, `zoom_in` or `zoom_out`. | +| `ptz_phase` | Optional parameter that is one of `start` or `stop` to start or stop the movement separately. | +| `absolute` | Optional parameter to specify exact absolute pan and zoom settings. See below. | + +?> If no `ptz_action` is specified and no `absolute` value is specified, the camera returns to its "home" position. See [Camera layout configuration](../../cameras/README.md?id=layout-configuration) to configure the default "home" position for digital PTZ. + +### `absolute` + +Set exact digital PTZ pan and zoom parameters. + +| Parameter | Description | +| - | - | +| `pan` | Control camera digital pan. See the `pan` parameter in [Camera layout configuration](../../cameras/README.md?id=layout-configuration). | +| `zoom` | Control camera digital zoom. See the `zoom` parameter in [Camera layout configuration](../../cameras/README.md?id=layout-configuration). | + +## `ptz_multi` + +Execute a PTZ action that intelligently chooses between a real and digital PTZ +action. If the media in question is a live camera with real PTZ support, a real +PTZ action will execute (equivalent to using the [`ptz`](README.md?id=ptz) +action), otherwise a digital PTZ action will be run (equivalent to using the +[`ptz_digital`](README.md?id=ptz_digital) action). + +?> If the camera supports _any_ real PTZ action, _all_ actions will attempt to make real PTZ calls. + +```yaml +action: custom:frigate-card-action +frigate_card_action: ptz-multi +[...] +``` + +| Parameter | Description | +| - | - | +| `action` | Must be `custom:frigate-card-action`. | +| `frigate_card_action` | Must be `ptz-digital`. | +| `ptz_action` | Optional action that is one of `left`, `right`, `up`, `down`, `zoom_in` or `zoom_out`. | +| `ptz_phase` | Optional parameter that is one of `start` or `stop` to start or stop the movement separately. | +| `ptz_preset` | Optional preset to execute when the `ptz_action` is `preset`. | + +?> If no `ptz_action` is specified, the camera returns to its "home" position. + +## `recording` + +Change to the `recording` view. + +```yaml +action: custom:frigate-card-action +frigate_card_action: recording +``` + +## `recordings` + +Change to the `recordings` view. + +```yaml +action: custom:frigate-card-action +frigate_card_action: recordings +``` + +## `screenshot` + +Take a screenshot of the selected media (e.g. a still from a video). + +```yaml +action: custom:frigate-card-action +frigate_card_action: screenshot +``` + +## `sleep` + +Take no action for a given duration. Useful to pause between multiple other actions. + +```yaml +action: custom:frigate-card-action +frigate_card_action: sleep +``` + +| Parameter | Description | +| - | - | +| `action` | Must be `custom:frigate-card-action`. | +| `frigate_card_action` | Must be `sleep`. | +| `duration` | A duration object. See below. | + +### `duration` + +The `duration` block configures how long the `sleep` should last. + +| Parameter | Description | +| - | - | +| `h` | Hours to sleep for. | +| `m` | Minutes to sleep for. | +| `s` | Seconds to sleep for. | +| `ms` | Milliseconds to sleep for. | + +?> Multiple values can be combined, e.g. `{ m: 2, s: 30}` will sleep for `2.5` minutes. + +## `snapshot` + +Change to the `snapshot` view. + +```yaml +action: custom:frigate-card-action +frigate_card_action: snapshot +``` + +## `snapshots` + +Change to the `snapshots` view. + +```yaml +action: custom:frigate-card-action +frigate_card_action: snapshots +``` + +## `timeline` + +Change to the `timeline` view. + +```yaml +action: custom:frigate-card-action +frigate_card_action: timeline +``` + +## `unmute` + +Unmute the selected media. + +```yaml +action: custom:frigate-card-action +frigate_card_action: unmute +``` + +## Fully expanded reference + +[](../../common/expanded-warning.md ':include') + +```yaml +elements: + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-a-circle + title: Select Front Door + tap_action: + action: custom:frigate-card-action + frigate_card_action: camera_select + camera: camera.front_door + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-b-circle + title: Open Camera UI + tap_action: + action: custom:frigate-card-action + frigate_card_action: camera_ui + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-c-circle + title: Change Zoom + tap_action: + action: custom:frigate-card-action + frigate_card_action: change_zoom + pan: + x: 50 + y: 50 + zoom: 1 + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-d-circle + title: Show most recent clip + tap_action: + action: custom:frigate-card-action + frigate_card_action: clip + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-e-circle + title: Show clips + tap_action: + action: custom:frigate-card-action + frigate_card_action: clips + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-f-circle + title: Show default view + tap_action: + action: custom:frigate-card-action + frigate_card_action: default + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-g-circle + title: Show diagnostics + tap_action: + action: custom:frigate-card-action + frigate_card_action: diagnostics + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-h-circle + title: Show grid + tap_action: + action: custom:frigate-card-action + frigate_card_action: display_mode_select + display_mode: grid + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-i-circle + title: Download media + tap_action: + action: custom:frigate-card-action + frigate_card_action: download + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-j-circle + title: Expand + tap_action: + action: custom:frigate-card-action + frigate_card_action: expand + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-k-circle + title: Fullscreen + tap_action: + action: custom:frigate-card-action + frigate_card_action: fullscreen + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-l-circle + title: Show image view + tap_action: + action: custom:frigate-card-action + frigate_card_action: image + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-m-circle + title: Show live view + tap_action: + action: custom:frigate-card-action + frigate_card_action: live + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-n-circle + title: Turn off substream + tap_action: + action: custom:frigate-card-action + frigate_card_action: live_substream_off + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-o-circle + title: Turn on substream + tap_action: + action: custom:frigate-card-action + frigate_card_action: live_substream_on + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-n-circle + title: Select HD substream + tap_action: + action: custom:frigate-card-action + frigate_card_action: live_substream_select + camera: camera.front_door_hd + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-o-circle + title: Log to console + tap_action: + action: custom:frigate-card-action + frigate_card_action: log + message: "Hello, world!" + level: debug + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-m-circle + title: Media player play + tap_action: + action: custom:frigate-card-action + frigate_card_action: media_player + media_player: media_player.nesthub50be + media_player_action: play + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-n-circle + title: Media player stop + tap_action: + action: custom:frigate-card-action + frigate_card_action: media_player + media_player: media_player.nesthub + media_player_action: stop + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-o-circle + title: Toggle hidden menu + tap_action: + action: custom:frigate-card-action + frigate_card_action: menu_toggle + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-p-circle + title: Microphone mute + tap_action: + action: custom:frigate-card-action + frigate_card_action: microphone_mute + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-q-circle + title: Microphone unmute + tap_action: + action: custom:frigate-card-action + frigate_card_action: microphone_unmute + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-r-circle + title: Mute + tap_action: + action: custom:frigate-card-action + frigate_card_action: mute + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-s-circle + title: Pause + tap_action: + action: custom:frigate-card-action + frigate_card_action: pause + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-t-circle + title: Play + tap_action: + action: custom:frigate-card-action + frigate_card_action: play + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-u-circle + title: Real PTZ Preset + tap_action: + action: custom:frigate-card-action + frigate_card_action: ptz + ptz_action: preset + ptz_preset: doorway + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-v-circle + title: Show PTZ Controls + tap_action: + action: custom:frigate-card-action + frigate_card_action: ptz_controls + enabled: true + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-w-circle + title: Go to precise digital location + tap_action: + action: custom:frigate-card-action + frigate_card_action: ptz_digital + absolute: + zoom: 5 + pan: + x: 58 + y: 14 + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-x-circle + title: Smart select between real and digital PTZ + tap_action: + action: custom:frigate-card-action + frigate_card_action: ptz_multi + ptz_action: left + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-y-circle + title: Show most recent recording + tap_action: + action: custom:frigate-card-action + frigate_card_action: recording + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-a-circle-outline + title: Show recordings + tap_action: + action: custom:frigate-card-action + frigate_card_action: recordings + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-b-circle-outline + title: Screenshot + tap_action: + action: custom:frigate-card-action + frigate_card_action: screenshot + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-c-circle-outline + title: Sleep + tap_action: + action: custom:frigate-card-action + frigate_card_action: sleep + duration: + h: 1 + m: 20 + s: 56 + ms: 422 + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-d-circle-outline + title: Show most recent snapshot + tap_action: + action: custom:frigate-card-action + frigate_card_action: snapshot + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-e-circle-outline + title: Show snapshots + tap_action: + action: custom:frigate-card-action + frigate_card_action: snapshots + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-f-circle-outline + title: Show timeline + tap_action: + action: custom:frigate-card-action + frigate_card_action: timeline + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-g-circle-outline + title: Unmute + tap_action: + action: custom:frigate-card-action + frigate_card_action: unmute +``` diff --git a/docs/configuration/actions/custom/_sidebar.md b/docs/configuration/actions/custom/_sidebar.md new file mode 100644 index 00000000..e2d1629d --- /dev/null +++ b/docs/configuration/actions/custom/_sidebar.md @@ -0,0 +1,27 @@ +* [Getting Started](../../../README.md) +* [Configuration](../../README.md) + * [`actions`](../README.md) + * [Custom Actions](README.md) + * [Stock Actions](../stock/README.md) + * [`automations`](../../automations.md) + * [`cameras`](../../cameras/README.md) + * [`conditions`](../../conditions.md) + * [`dimensions`](../../dimensions.md) + * [`elements`](../../elements.md) + * [`image`](../../image.md) + * [`live`](../../live.md) + * [`media_gallery`](../../media-gallery.md) + * [`media_viewer`](../../media-viewer.md) + * [`menu`](../../menu.md) + * [`overrides`](../../overrides.md) + * [`performance`](../../performance.md) + * [`profiles`](../../profiles.md) + * [`timeline`](../../timeline.md) + * [`view`](../../view.md) +* [Screenshots](../../../screenshots.md) +* [Troubleshooting](../../../troubleshooting.md) +* [Usage](../../../usage/README.md) + +--- + +* [Developing](../../../developing.md) \ No newline at end of file diff --git a/docs/configuration/actions/stock/README.md b/docs/configuration/actions/stock/README.md new file mode 100644 index 00000000..6fdcfd03 --- /dev/null +++ b/docs/configuration/actions/stock/README.md @@ -0,0 +1,120 @@ +# Stock Actions + +## `call-service` + +Call a service. See [Home Assistant actions documentation](https://www.home-assistant.io/dashboards/actions/). + +```yaml +action: call-service +[...] +``` + +## `more-info` + +Open the "more-info" dialog for an entity. See [Home Assistant actions documentation](https://www.home-assistant.io/dashboards/actions/). + +```yaml +action: more-info +[...] +``` + +## `navigate` + +Navigate to a particular dashboard path. See [Home Assistant actions documentation](https://www.home-assistant.io/dashboards/actions/). + +```yaml +action: navigate +[...] +``` + +## `toggle` + +Toggle an entity. See [Home Assistant actions documentation](https://www.home-assistant.io/dashboards/actions/). + +```yaml +action: toggle +[...] +``` + +## `url` + +Navigate to an arbitrary URL. See [Home Assistant actions documentation](https://www.home-assistant.io/dashboards/actions/). + +```yaml +action: url +[...] +``` + +## Fully expanded reference + +[](../../common/expanded-warning.md ':include') + +Reference: [Home Assistant Actions](https://www.home-assistant.io/dashboards/actions/). + +```yaml +elements: + - type: icon + icon: mdi:numeric-1-box + title: More info action + style: + left: 200px + top: 50px + entity: light.office_main_lights + tap_action: + action: more-info + - type: icon + icon: mdi:numeric-2-box + title: Toggle action + style: + left: 200px + top: 100px + entity: light.office_main_lights + tap_action: + action: toggle + - type: icon + icon: mdi:numeric-3-box + title: Call Service action + style: + left: 200px + top: 150px + tap_action: + action: call-service + service: homeassistant.toggle + data: + entity_id: light.office_main_lights + - type: icon + icon: mdi:numeric-4-box + title: Navigate action + style: + left: 200px + top: 200px + tap_action: + action: navigate + navigation_path: /lovelace/2 + - type: icon + icon: mdi:numeric-5-box + title: URL action + style: + left: 200px + top: 250px + tap_action: + action: url + url_path: https://www.home-assistant.io/ + - type: icon + icon: mdi:numeric-6-box + title: None action + style: + left: 200px + top: 300px + tap_action: + action: none + - type: icon + icon: mdi:numeric-7-box + title: Custom action + style: + left: 200px + top: 350px + tap_action: + action: fire-dom-event + key: value +``` diff --git a/docs/configuration/actions/stock/_sidebar.md b/docs/configuration/actions/stock/_sidebar.md new file mode 100644 index 00000000..3858987a --- /dev/null +++ b/docs/configuration/actions/stock/_sidebar.md @@ -0,0 +1,27 @@ +* [Getting Started](../../../README.md) +* [Configuration](../../README.md) + * [`actions`](../README.md) + * [Custom Actions](../custom/README.md) + * [Stock Actions](README.md) + * [`automations`](../../automations.md) + * [`cameras`](../../cameras/README.md) + * [`conditions`](../../conditions.md) + * [`dimensions`](../../dimensions.md) + * [`elements`](../../elements.md) + * [`image`](../../image.md) + * [`live`](../../live.md) + * [`media_gallery`](../../media-gallery.md) + * [`media_viewer`](../../media-viewer.md) + * [`menu`](../../menu.md) + * [`overrides`](../../overrides.md) + * [`performance`](../../performance.md) + * [`profiles`](../../profiles.md) + * [`timeline`](../../timeline.md) + * [`view`](../../view.md) +* [Screenshots](../../../screenshots.md) +* [Troubleshooting](../../../troubleshooting.md) +* [Usage](../../../usage/README.md) + +--- + +* [Developing](../../../developing.md) \ No newline at end of file diff --git a/docs/configuration/automations.md b/docs/configuration/automations.md index 19580ab1..37a1dc39 100644 --- a/docs/configuration/automations.md +++ b/docs/configuration/automations.md @@ -1,6 +1,6 @@ # `automations` -Automatically take [actions](actions.md) based on [conditions](conditions.md) being met. +Automatically take [actions](actions/README.md) based on [conditions](conditions.md) being met. ?> To change configuration conditionally use [overrides](overrides.md). @@ -17,8 +17,8 @@ automations: | Option | Default | Description | | - | - | - | | `conditions` | | A list of [conditions](conditions.md) that must evaluate to `true` in order to trigger the automation. | -| `actions` | | An optional list of [actions](actions.md) that will be run when the [conditions](conditions.md) evaluate `true`. | -| `actions_not` | | An optional list of [actions](actions.md) that will be run when the [conditions](conditions.md) evaluate `false`. | +| `actions` | | An optional list of [actions](actions/README.md) that will be run when the [conditions](conditions.md) evaluate `true`. | +| `actions_not` | | An optional list of [actions](actions/README.md) that will be run when the [conditions](conditions.md) evaluate `false`. | # Fully expanded reference diff --git a/docs/configuration/cameras/README.md b/docs/configuration/cameras/README.md index 31124162..12f8cd92 100644 --- a/docs/configuration/cameras/README.md +++ b/docs/configuration/cameras/README.md @@ -26,7 +26,7 @@ cameras_global: | `engine` | `auto` | The camera engine to use. If `auto` the card will attempt to choose the correct engine from the specified options. See [Engine](engine.md). | | `frigate` | | Options for Frigate cameras. See [Frigate camera engine configuration](engine.md?id=frigate). | | `icon` | Autodetected from `camera_entity` if that is specified. | The icon to use for this camera in the camera menu and in the next & previous controls when using the `icon` style. | -| `id` | `camera_entity`, `webrtc_card.entity` or `frigate.camera_name` if set (in that preference order). | An optional identifier to use throughout the card configuration to refer unambiguously to this camera. This `id` may be used in [conditions](../conditions.md), dependencies or custom [actions](../actions.md) to refer to a given camera unambiguously. | +| `id` | `camera_entity`, `webrtc_card.entity` or `frigate.camera_name` if set (in that preference order). | An optional identifier to use throughout the card configuration to refer unambiguously to this camera. This `id` may be used in [conditions](../conditions.md), dependencies or custom [actions](../actions/README.md) to refer to a given camera unambiguously. | | `live_provider` | `auto` | The choice of live stream provider. See [Live Provider](live-provider.md).| | `title` | Autodetected from `camera_entity` if that is specified. | A friendly name for this camera to use in the card. | | `triggers` | | Define what should cause this camera to update/trigger. See below. | @@ -176,6 +176,66 @@ See [media layout examples](../../examples.md?id=media-layout). ![](../../images/media_layout/pan-zoom.png "Panning and zooming :size=400") +## `ptz` + +Configure the PTZ actions taken for a camera (not to be confused with configuration of the PTZ _controls_, see [Live PTZ Controls](../live.md?id=ptz) or [Media Viewer PTZ Controls](../media-viewer.md?id=ptz)). Manually configured actions override any auto-detected actions. + +```yaml +cameras: + - camera_entity: camera.office + ptz: + [...] +``` + +### Movement types + +Generally PTZ cameras/integrations may support two kinds of PTZ actions: + +* `relative`: Single relative steps, e.g. "Pan to the left one step". +* `continuous`: Separate start and stop, e.g. "Start panning to the left", following by a later command "Stop panning". + +The card supports both, and with the help of the +`r2c_delay_between_calls_seconds` and `c2r_delay_between_calls_seconds` can +translate between them where necessary. See the [ONVIF +specification](https://www.onvif.org/specs/srv/ptz/ONVIF-PTZ-Service-Spec.pdf) +for more details on the distinction between `relative` and `continuous`. + +The card UI (e.g. PTZ controls) will always try to call the `continuous` variety +to allow for precise/smooth controls, and if unavailable will translate multiple +`relative` steps with optional delays between each step. Manually configured +[actions](../actions/README.md) may be configured to call either variety. + +?> Frigate auto-detected PTZ actions will always be `continuous` as this is what +the integration currently offers. + +### Parameters + +| Option | Default | Description | +| - | - | - | +| `actions_left`, `actions_right`, `actions_up`, `actions_down`, `actions_zoom_in`, `actions_zoom_out`, `actions_home` | Set by camera [engine](./engine.md) of the selected camera | The [call-service](../actions/stock/README.md?id=call-service) action that will be called for each PTZ action for relative movements. | +| `actions_left_start`, `actions_left_stop`, `actions_right_start`, `actions_right_stop`,`actions_up_start`, `actions_up_stop`,`actions_down_start`, `actions_down_stop`,`actions_zoom_in_start`, `actions_zoom_in_stop`,`actions_zoom_out_start`, `actions_zoom_out_stop` | Set by camera [engine](./engine.md) of the selected camera | The [call-service](../actions/stock/README.md?id=call-service) action that will be called for each PTZ action for continous movements. Both a `_start` and `_stop` variety must be provided for an action to be usable. | +| `c2r_delay_between_calls_seconds` | `0.2` | When the camera is configured with continuous actions only (e.g. `left_start` and `left_stop`, but not `left`), if something requests a relative action (e.g. a manually configured [action](../actions/README.md)), then `start` will be called, followed by a delay of this number of seconds and finally `stop` will be called. Cameras / integrations that are slower to respond to continuous steps may need to increase this value to avoid the continuous motion being too small. Cameras / integrations that are rapid to respond may need to decrease this value to avoid the "relative step" being too large. | +| `data_left`, `data_right`, `data_up`, `data_down`, `data_zoom_in`, `data_zoom_out`, `data_home` | | Shorthand for relative actions that call the service defined by the `service` parameter, with the data provided in this argument. Internally, this is just translated into the longer-form `actions_[action]`. If both `actions_X` and `data_X` are specified, `actions_X` takes priority. This is compatible with [AlexxIT's WebRTC Card PTZ configuration](https://github.com/AlexxIT/WebRTC/wiki/PTZ-Config-Examples). | +| `data_left_start`, `data_left_stop`, `data_right_start`, `data_right_stop`, `data_up_start`, `data_up_stop`, `data_down_start`, `data_down_stop`, `data_zoom_in_start`, `data_zoom_in_stop`, `data_zoom_out_start`, `data_zoom_out_stop` | | Shorthand for continuous actions that call the service defined by the `service` parameter, with the data provided in this argument. Internally, this is just translated into the longer-form `actions_[action]_start` and `actions_[action]_stop`. If both `actions_X_*` and `data_X_*` are specified, `actions_X_*` takes priority. This is compatible with [AlexxIT's WebRTC Card PTZ configuration](https://github.com/AlexxIT/WebRTC/wiki/PTZ-Config-Examples). Both a `_start` and `_stop` variety must be provided for an action to be usable. | +| `presets` | | PTZ preset actions. See below. | +| `r2c_delay_between_calls_seconds` | `0.5` | When the camera is configured with relative actions only (e.g. `left` but not `left_start` and `left_stop`), if something requests a continuous action (e.g. the card PTZ controls have a button held down), then a delay of this number of seconds will be inserted between each call of the relative action. Cameras / integrations that are slower to respond to relative steps may need to increase this value to avoid multiple simultaneous actions being sent. Cameras / integrations that are rapid to respond may need to decrease this value to increase the appearance of one single continuous motion. | +| `service` | | An optional Home Assistant service to call when the `data_` parameters are used. | + +### `presets` + +Configures named PTZ presets. If a preset of this name is auto-detected, these configured actions will take precedence. + +```yaml +cameras: + - camera_entity: camera.office + ptz: + presets: + [preset_name]: + [action]: +``` + +`[action]` is any [call-service](../actions/stock/README.md?id=call-service) action. + ## `triggers` The `triggers` block configures what triggers a camera. Triggering can be used @@ -314,6 +374,61 @@ cameras: pan: x: 50 y: 50 + - camera_entity: camera.manual-ptz + ptz: + c2r_delay_between_calls_seconds: 0.2 + r2c_delay_between_calls_seconds: 0.5 + # Relative action (only `left` shown) + actions_left: + action: call-service + service: service.of_your_choice + data: + device: '048123' + cmd: left + # Continuous action (only `right` shown) + actions_right_start: + action: call-service + service: service.of_your_choice + data: + device: '048123' + cmd: right + phase: start + actions_right_stop: + action: call-service + service: service.of_your_choice + data: + device: '048123' + phase: stop + # Equivalent relative short form (only `up` shown) + service: service.send_command + data_up: + device: '048123' + cmd: up + # Equivalent continuous short form (only `down` shown) + service: service.send_command + data_up_start: + device: '048123' + cmd: down + phase: start + data_up_stop: + device: '048123' + cmd: down + phase: stop + presets: + # Preset using long form. + armchair: + action: call-service + service: service.of_your_choice + data: + device: '048123' + cmd: preset + preset: armchair + # Preset using short form. + service: service.of_your_choice + window: + device: '048123' + cmd: preset + preset: window cameras_global: live_provider: ha ``` diff --git a/docs/configuration/cameras/_sidebar.md b/docs/configuration/cameras/_sidebar.md index 07620600..6ab6e00b 100644 --- a/docs/configuration/cameras/_sidebar.md +++ b/docs/configuration/cameras/_sidebar.md @@ -1,23 +1,23 @@ * [Getting Started](../../README.md) * [Configuration](../README.md) - * [`actions`](../actions.md) - * [`automations`](../automations.md) - * [`cameras`](README.md) - * [`live_provider`](live-provider.md) - * [`engine`](engine.md) - * [`conditions`](../conditions.md) - * [`dimensions`](../dimensions.md) - * [`elements`](../elements.md) - * [`image`](../image.md) - * [`live`](../live.md) - * [`media_gallery`](../media-gallery.md) - * [`media_viewer`](../media-viewer.md) - * [`menu`](../menu.md) - * [`overrides`](../overrides.md) - * [`performance`](../performance.md) - * [`profiles`](../profiles.md) - * [`timeline`](../timeline.md) - * [`view`](../view.md) + * [`actions`](../actions/README.md) + * [`automations`](../automations.md) + * [`cameras`](README.md) + * [`live_provider`](live-provider.md) + * [`engine`](engine.md) + * [`conditions`](../conditions.md) + * [`dimensions`](../dimensions.md) + * [`elements`](../elements.md) + * [`image`](../image.md) + * [`live`](../live.md) + * [`media_gallery`](../media-gallery.md) + * [`media_viewer`](../media-viewer.md) + * [`menu`](../menu.md) + * [`overrides`](../overrides.md) + * [`performance`](../performance.md) + * [`profiles`](../profiles.md) + * [`timeline`](../timeline.md) + * [`view`](../view.md) * [Screenshots](../../screenshots.md) * [Troubleshooting](../../troubleshooting.md) * [Usage](../../usage/README.md) diff --git a/docs/configuration/conditions.md b/docs/configuration/conditions.md index 40e59550..3a26792b 100644 --- a/docs/configuration/conditions.md +++ b/docs/configuration/conditions.md @@ -66,6 +66,24 @@ conditions: | `condition` | Must be `interaction`. | | `interaction` | If `true` the condition is satisfied if the card has had human interaction within `view.interaction_seconds` elapsed seconds. If `false` the condition is satisfied if the card has **NOT** had human interaction in that time. | +## `key` + +```yaml +conditions: + - condition: key + [...] +``` + +| Parameter | Default | Description | +| - | - | - | +| `condition` | - | Must be `key`. | +| `alt` | `false` | An optional value to match whether the `alt` key is being held. | +| `ctrl` | `false` | An optional value to match whether the `ctrl` key is being held. | +| `key` | | Any [keyboard key value](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values), e.g. `ArrowLeft`. | +| `meta` | `false` | An optional value to match whether the `meta` key is being held. | +| `shift` | `false` | An optional value to match whether the `shift` key is being held. | +| `state` | `down` | An optional value to match the state of the. Must be one of `down` or `up`. | + ## `media_loaded` ```yaml @@ -77,7 +95,7 @@ conditions: | Parameter | Description | | - | - | | `condition` | Must be `media_loaded`. | -| `media_loaded` | If `true` the condition is satisfied if there is media load**ED** (not load**ING**) in the card (e.g. a clip, snapshot or live view). This may be used to hide controls during media loading or when a message (not media) is being displayed. Note that if `true` this +| `media_loaded` | If `true` the condition is satisfied if there is media load**ED** (not load**ING**) in the card (e.g. a clip, snapshot or live view). This may be used to hide controls during media loading or when a message (not media) is being displayed. | ## `microphone` @@ -169,7 +187,7 @@ conditions: media is fetched. When including views as part of a [condition](conditions.md), you may need to refer to this special `media` view. -# Fully expanded reference +## Fully expanded reference [](common/expanded-warning.md ':include') @@ -184,6 +202,13 @@ conditions: fullscreen: true - condition: interaction interaction: true + - condition: key + alt: false + ctrl: false + key: F + meta: false + shift: false + state: down - condition: media_loaded media_loaded: true - condition: microphone diff --git a/docs/configuration/elements.md b/docs/configuration/elements.md index c98e48ef..992a89e7 100644 --- a/docs/configuration/elements.md +++ b/docs/configuration/elements.md @@ -10,8 +10,8 @@ elements: - [element_2] ``` -?> The Frigate Card allows either a single [action](actions.md) (as in stock Home -Assistant) or list of [actions](actions.md) to be defined for each class of user interaction +?> The Frigate Card allows either a single [action](actions/README.md) (as in stock Home +Assistant) or list of [actions](actions/README.md) to be defined for each class of user interaction (e.g. `tap`, `double_tap`, `hold`, etc). See [an example of multiple actions](../examples.md?id=multiple-actions). ## `conditional` @@ -81,7 +81,7 @@ Parameters for this element are identical to the parameters of the [stock Home A | `selected` | `false` | Whether or not to show this item as selected. | | `state_color` | `true` | Whether or not the title and icon should be stylized based on state. | | `style` | | Position and style the element using CSS. | -| `tap_action`, `double_tap_action`, `hold_action`, `start_tap`, `end_tap` | | The [actions](actions.md) to take when this item is interacted with. | +| `tap_action`, `double_tap_action`, `hold_action`, `start_tap`, `end_tap` | | The [actions](actions/README.md) to take when this item is interacted with. | | `title` | | An optional title to display. | ## `custom:frigate-card-menu-submenu-select` @@ -185,7 +185,7 @@ elements: ## Fully expanded reference -> [Actions](actions.md) are omitted for simplicity. +> [Actions](actions/README.md) are omitted for simplicity. [](common/expanded-warning.md ':include') diff --git a/docs/configuration/image.md b/docs/configuration/image.md index 2800033d..2f56f6fa 100644 --- a/docs/configuration/image.md +++ b/docs/configuration/image.md @@ -9,15 +9,14 @@ image: | Option | Default | Description | | - | - | - | -| `actions` | | [Actions](actions.md) to use for the `image` view.| +| `actions` | | [Actions](actions/README.md) to use for the `image` view.| | `mode` | `url` | Mode of the the `image` view. Value must be one of `url` (to fetch an arbitrary image URL), `camera` (to show a still of the currently selected camera using either `camera_entity` or `webrtc_card.entity` in that order of precedence), or `screensaver` (to show an [embedded stock Frigate card logo](https://github.com/dermotduffy/frigate-hass-card/blob/main/src/images/frigate-bird-in-sky.jpg)). In either `url` or `camera` mode, the `screensaver` content is used as a fallback if a URL is not specified or cannot be derived. | | `refresh_seconds` | 0 | The image will be refreshed at least every `refresh_seconds` (it may refresh more frequently, e.g. whenever Home Assistant updates its camera security token). `0` implies no refreshing. | | `url` | | A static image URL to be used when the `mode` is set to `url` or when a temporary image is required (e.g. may appear momentarily prior to load of a camera snapshot in the `camera` mode). Note that a `_t=[timestsamp]` query parameter will be automatically added to all URLs such that the image will not be cached by the browser. | -| `zoomable` | `true` | Whether or not the image can be zoomed and panned, via touch/pinch and mouse scroll wheel with `ctrl` held. | ?> When `mode` is set to `camera` this is effectively providing the same image as the `image` [live provider](cameras/live-provider.md) would show in the live camera carousel. -# Fully expanded reference +## Fully expanded reference [](common/expanded-warning.md ':include') @@ -25,7 +24,6 @@ image: image: mode: url refresh_seconds: 0 - zoomable: true actions: entity: light.office_main_lights tap_action: diff --git a/docs/configuration/live.md b/docs/configuration/live.md index acd7f509..51b144cc 100644 --- a/docs/configuration/live.md +++ b/docs/configuration/live.md @@ -9,7 +9,7 @@ live: | Option | Default | Description | | - | - | - | -| `actions` | | [Actions](actions.md) to use for the `live` view. | +| `actions` | | [Actions](actions/README.md) to use for the `live` view. | | `auto_mute` | `[unselected, hidden, microphone]` | A list of conditions in which live camera feeds are muted. `unselected` will automatically mute when a camera is unselected in the carousel, `hidden` will automatically mute when the browser/tab becomes hidden or `microphone` will automatically mute after the microphone is muted as long as the camera stays selected (see the `live.microphone.mute_after_microphone_mute_seconds` to control how long after). Use an empty list (`[]`) to never automatically mute. Note that if `auto_play` is enabled, the stream may mute itself automatically in order to honor the `auto_play` setting, as some browsers will not auto play media that is unmuted -- that is to say, where necessary, the `auto_play` parameter will take priority over the `auto_mute` parameter.| | `auto_pause` | `[]` | A list of conditions in which live camera feeds are automatically paused. `unselected` will automatically pause when a camera is unselected in the carousel and `hidden` will automatically pause when the browser/tab becomes hidden. Use an empty list (`[]`) to never automatically pause. **Caution**: Some live providers (e.g. `jsmpeg`) may not offer human-accessible means to resume play if it is paused, unless the `auto_play` option is used.| | `auto_play` | `[selected, visible]` | A list of conditions in which live camera feeds are automatically played.`selected` will automatically play when a camera is selected in the carousel and `visible` will automatically play when the browser/tab becomes visible. Use an empty list (`[]`) to never automatically play. Some live providers (e.g. `webrtc-card`, `jsmpeg`) do not support the prevention of automatic play on initial load, but should still respect the value of this flag on play-after-pause.| @@ -39,8 +39,8 @@ live: | - | - | - | | `builtin` | `true` | Whether to show the built in (browser) video controls on live video. | | `next_previous` | | Configures how the "Next & Previous" controls are shown on the `live` view. See below. | -| `thumbnails` | | | Configures how thumbnails are shown on the `live` view. See below. | -| `timeline` | | | Configures how the mini-timeline is shown on the `live` view. See below. | +| `thumbnails` | | Configures how thumbnails are shown on the `live` view. See below. | +| `timeline` | | Configures how the mini-timeline is shown on the `live` view. See below. | | `title` | | Configures how the camera title is shown on the `live` view. See below. | ### `next_previous` @@ -59,6 +59,29 @@ live: | `size` | `48` | The size of the next/previous controls in pixels. Must be >= `20`. | | `style` | `chevrons` | When viewing live cameras, what kind of controls to show to move to the previous/next camera. Acceptable values: `chevrons`, `icons`, `none` . | +### `ptz` + +Configures the PTZ (Pan Tilt Zoom) controls. + +```yaml +live: + controls: + ptz: + [...] +``` + +| Option | Default | Description | +| - | - | - | +| `hide_home` | `false` | When `true` the Home button of the control is hidden | +| `hide_pan_tilt` | `false` | When `true` the Pan & Tilt buttons of the control is hidden | +| `hide_zoom` | `false` | When `true` the Zoom button of the control is hidden | +| `mode` | `auto` | If `on` or `off` will always or never show PTZ controls respectively, if `auto` will show PTZ controls only if the camera supports real PTZ. | +| `orientation` | `horizontal` | Whether to show a `vertical` or `horizontal` PTZ control. | +| `position` | `bottom-right` | Whether to position the control on the `top-left`, `top-right`, `bottom-left` or `bottom-right`. This may be overridden by using the `style` parameter to precisely control placement. | +| `style` | | Optionally position and style the element using CSS. Similar to [Picture Element styling](https://www.home-assistant.io/dashboards/picture-elements/#how-to-use-the-style-object), except without any default, e.g. `left: 42%` | + +To configure the PTZ _actions_ taken for a particular camera, see [Camera PTZ Settings](./cameras/README.md?id=ptz). + ### `thumbnails` Configures how thumbnails are shown on the live view. @@ -154,29 +177,6 @@ live: See [Using 2-way audio](../usage/2-way-audio.md) for more information about the very particular requirements that must be followed for 2-way audio to work. -## `ptz` - -Controls a PTZ (Pan Tilt Zoom) overlay on the `live` view. - -```yaml -live: - ptz: - [...] -``` - -| Option | Default | Description | -| - | - | - | -| `actions_left`, `actions_right`, `actions_up`, `actions_down`, `actions_zoom_in`, `actions_zoom_out`, `actions_home` | Set by camera [engine](cameras/engine.md) of the selected camera | The [actions](actions.md) to call when this icon is interacted with. | -| `data_left`, `data_right`, `data_up`, `data_down`, `data_zoom_in`, `data_zoom_out`, `data_home` | | Shorthand for a `tap_action` that calls the `service` with the data provided in this argument. Internally, this is just translated into the longer-form `actions_[button]`. If both `actions_X` and `data_X` are specified, `actions_X` takes priority. This is compatible with [AlexxIT's WebRTC Card PTZ configuration](https://github.com/AlexxIT/WebRTC/wiki/PTZ-Config-Examples). | -| `hide_home` | `false` | When `true` the Home button of the control is hidden | -| `hide_pan_tilt` | `false` | When `true` the Pan & Tilt buttons of the control is hidden | -| `hide_zoom` | `false` | When `true` the Zoom button of the control is hidden | -| `mode` | `on` | When `on` will show a PTZ control if so configured (manually, or by the camera engine), if `off` will not show any control. | -| `orientation` | `horizontal` | Whether to show a `vertical` or `horizontal` PTZ control. | -| `position` | `bottom-right` | Whether to position the control on the `top-left`, `top-right`, `bottom-left` or `bottom-right`. This may be overridden by using the `style` parameter to precisely control placement. | -| `service` | | An optional Home Assistant service to call when the `data_` parameters are used. | -| `style` | | Optionally position and style the element using CSS. Similar to [Picture Element styling](https://www.home-assistant.io/dashboards/picture-elements/#how-to-use-the-style-object), except without any default, e.g. `left: 42%` | - ## Fully expanded reference [](common/expanded-warning.md ':include') @@ -203,6 +203,16 @@ live: next_previous: style: chevrons size: 48 + ptz: + mode: auto + position: bottom-right + orientation: horizontal + hide_pan_tilt: false + hide_zoom: false + hide_home: false + style: + # Optionally override the default style. + right: 5% thumbnails: media_type: events events_media_type: all @@ -227,29 +237,6 @@ live: always_connected: false disconnect_seconds: 90 mute_after_microphone_mute_seconds: 60 - ptz: - mode: on - position: bottom-right - orientation: horizontal - hide_pan_tilt: false - hide_zoom: false - hide_home: false - style: - # Optionally override the default style. - right: 5% - # Manually specifying actions. - actions_left: - tap_action: - action: call-service - service: sonoff.send_command - service_data: - device: '048123' - cmd: left - # Equivalent short form PTZ actions (only right button shown) - service: sonoff.send_command - data_right: - device: '048123' - cmd: right display: mode: single grid_selected_width_factor: 2 diff --git a/docs/configuration/media-gallery.md b/docs/configuration/media-gallery.md index 8a24347b..bd7a611b 100644 --- a/docs/configuration/media-gallery.md +++ b/docs/configuration/media-gallery.md @@ -9,7 +9,7 @@ media_gallery: | Option | Default | Description | | - | - | - | -| `actions` | | [Actions](actions.md) to use for all views that use the `media_gallery` (e.g. `clips`, `snapshots`, `recordings`). | +| `actions` | | [Actions](actions/README.md) to use for all views that use the `media_gallery` (e.g. `clips`, `snapshots`, `recordings`). | | `controls` | | Configuration for the Media viewer controls. See below. | ## `controls` diff --git a/docs/configuration/media-viewer.md b/docs/configuration/media-viewer.md index e17074a2..751efaba 100644 --- a/docs/configuration/media-viewer.md +++ b/docs/configuration/media-viewer.md @@ -9,7 +9,7 @@ media_viewer: | Option | Default | Description | | - | - | - | -| `actions` | | [Actions](actions.md) to use for all views that use the `media_viewer` (e.g. `clip`, `snapshot`). | +| `actions` | | [Actions](actions/README.md) to use for all views that use the `media_viewer` (e.g. `clip`, `snapshot`). | | `auto_mute` | `[unselected, hidden]` | A list of conditions in which media items are muted. `unselected` will automatically mute when a media item is unselected in the carousel and `hidden` will automatically mute when the browser/tab becomes hidden. Use an empty list (`[]`) to never automatically mute.| | `auto_pause` | `[unselected, hidden]` | A list of conditions in which media items are automatically paused. `unselected` will automatically pause when a media item is unselected in the carousel and `hidden` will automatically pause when the browser/tab becomes hidden. Use an empty list (`[]`) to never automatically pause.| | `auto_play` | `[selected, visible]` | A list of conditions in which media items are automatically played.`selected` will automatically play when a media item is selected in the carousel and `visible` will automatically play when the browser/tab becomes visible. Use an empty list (`[]`) to never automatically play.| @@ -55,6 +55,29 @@ media_viewer: | `size` | `48` | The size of the next/previous controls in pixels. Must be >= `20`.| | `style` | `thumbnails` | When viewing media, what kind of controls to show to move to the previous/next media item. Acceptable values: `thumbnails`, `chevrons`, `none` . | +### `ptz` + +Configures the PTZ (Pan Tilt Zoom) controls. As the media viewer is never +viewing live view, the PTZ controls in this context always refer to digital (vs +real) panning and zooming. + +```yaml +media_viewer: + controls: + ptz: + [...] +``` + +| Option | Default | Description | +| - | - | - | +| `hide_home` | `false` | When `true` the Home button of the control is hidden | +| `hide_pan_tilt` | `false` | When `true` the Pan & Tilt buttons of the control is hidden | +| `hide_zoom` | `false` | When `true` the Zoom button of the control is hidden | +| `mode` | `off` | If `on` or `off` will always or never show PTZ controls respectively. | +| `orientation` | `horizontal` | Whether to show a `vertical` or `horizontal` PTZ control. | +| `position` | `bottom-right` | Whether to position the control on the `top-left`, `top-right`, `bottom-left` or `bottom-right`. This may be overridden by using the `style` parameter to precisely control placement. | +| `style` | | Optionally position and style the element using CSS. Similar to [Picture Element styling](https://www.home-assistant.io/dashboards/picture-elements/#how-to-use-the-style-object), except without any default, e.g. `left: 42%` | + ### `thumbnails` Configures how thumbnails are shown on the media viewer. @@ -157,6 +180,16 @@ media_viewer: next_previous: size: 48 style: thumbnails + ptz: + mode: off + position: bottom-right + orientation: horizontal + hide_pan_tilt: false + hide_zoom: false + hide_home: false + style: + # Optionally override the default style. + right: 5% thumbnails: size: 100 mode: none diff --git a/docs/configuration/menu.md b/docs/configuration/menu.md index 093f8e11..7c956cb2 100644 --- a/docs/configuration/menu.md +++ b/docs/configuration/menu.md @@ -33,7 +33,6 @@ menu: | `camera_ui` | The `camera_ui` menu button: brings the user to a context-appropriate page on the UI of their camera engine (e.g. the Frigate camera homepage). Will only appear if the camera engine supports a camera UI (e.g. if `frigate.url` option is set for `frigate` engine users).| | `cameras` | The camera selection submenu. Will only appear if multiple cameras are configured. | | `clips` | The `clips` view menu button: brings the user to the `clips` view on tap and the most-recent `clip` view on hold. | -| `default_zoom` | The `default_zoom` button allows easily returning the camera to default pan/zoom. | | `display_mode` | The `display_mode` button allows changing between single and grid views. | | `download` | The `download` menu button: allow direct download of the media being displayed.| | `expand` | The `expand` menu button: expand the card into a popup/dialog. | @@ -41,9 +40,10 @@ menu: | `fullscreen` | The `fullscreen` menu button: expand the card to consume the fullscreen. | | `image` | The `image` view menu button: brings the user to the static `image` view. | | `live` | The `live` view menu button: brings the user to the `live` view. | -| `media_player` | The `media_player` menu button: sends the visible media to a remote media player. Supports Frigate clips, snapshots and live camera (only for cameras that specify a `camera_entity` and only using the default HA stream (equivalent to the `ha` live provider). `jsmpeg` or `webrtc-card` are not supported, although live can still be played as long as `camera_entity` is specified. In the player list, a `tap` will send the media to the player, a `hold` will stop the media on the player. | +| `media_player` | The `media_player` menu button: sends the visible media to a remote media player. Supports Frigate clips, snapshots and live camera (only for cameras that specify a `camera_entity` and only using the default HA stream (equivalent to the `ha` live provider)). `jsmpeg` or `webrtc-card` are not supported, although live can still be played as long as `camera_entity` is specified. In the player list, a `tap` will send the media to the player, a `hold` will stop the media on the player. | | `microphone` | The `microphone` button allows usage of 2-way audio in certain configurations. See [Using 2-way audio](../usage/2-way-audio.md). | -| `ptz` | The `show_ptz` button shows or hide the PTZ controls. | +| `ptz_controls` | The `ptz_controls` button shows or hides the PTZ controls. | +| `ptz_home` | The `ptz_home` button allows easily returning the camera to default home position. | | `recordings` | The `recordings` view menu button: brings the user to the `recordings` view on tap and the most-recent `recording` view on hold. | | `screenshot` | The `screenshot` menu button: take a screenshot of the loaded media (e.g. a still from a video). | | `snapshots` | The `snapshots` view menu button: brings the user to the `clips` view on tap and the most-recent `snapshot` view on hold. | @@ -54,7 +54,7 @@ menu: | Option | Default | Description | | - | - | - | | `alignment` | `matching` | Whether this button should have an alignment that is `matching` the menu alignment or `opposing` the menu. Can be used to create two separate groups of buttons on the menu. `priority` orders buttons within a given `alignment`. | -| `enabled` | `true` for `frigate`, `cameras`, `substreams`, `live`, `clips`, `snapshots`, `timeline`, `download`, `camera_ui`, `fullscreen`, `media_player`, `display_mode` and `default_zoom`. `false` for `image`, `expand`, `microphone`, `mute`, `play`, `recordings`, `screenshot`, `ptz` | Whether or not to show the button. | +| `enabled` | `true` for `frigate`, `cameras`, `substreams`, `live`, `clips`, `snapshots`, `timeline`, `download`, `camera_ui`, `fullscreen`, `media_player`, `display_mode` and `ptz_home`. `false` for `image`, `expand`, `microphone`, `mute`, `play`, `recordings`, `screenshot`, `ptz_controls` | Whether or not to show the button. | | `icon` | | An icon to overriding the default for that button, e.g. `mdi:camera-front`. | | `priority` | `50` | The button priority. Higher priority buttons are ordered closer to the start of the menu alignment (i.e. a button with priority `70` will order further to the left than a button with priority `60`, when the menu alignment is `left`). Minimum `0`, maximum `100`.| @@ -91,11 +91,6 @@ menu: enabled: true alignment: matching icon: mdi:video-switch - default_zoom: - priority: 50 - enabled: true - alignment: matching - icon: mdi:magnify-close substreams: priority: 50 enabled: true @@ -167,11 +162,16 @@ menu: enabled: false alignment: matching icon: mdi:play - show_ptz: + ptz_controls: priority: 50 enabled: false alignment: matching icon: mdi:pan + ptz_home: + priority: 50 + enabled: true + alignment: matching + icon: mdi:home button_size: 40 position: top style: hidden diff --git a/docs/configuration/view.md b/docs/configuration/view.md index cd3d2b1e..2315cc91 100644 --- a/docs/configuration/view.md +++ b/docs/configuration/view.md @@ -9,11 +9,12 @@ view: | Option | Default | Description | | - | - | - | -| `actions` | | [Actions](actions.md) to use for all views, individual actions may be overriden by view-specific actions. | +| `actions` | | [Actions](actions/README.md) to use for all views, individual actions may be overriden by view-specific actions. | | `camera_select` | `current` | The [view](view.md?id=supported-views) to show when a new camera is selected (e.g. in the camera menu). If `current` the view is unchanged when a new camera is selected. | | `dark_mode` | `off` | Whether or not to turn dark mode `on`, `off` or `auto` to automatically turn on if the card `interaction_seconds` has expired (i.e. card has been left unattended for that period of time) or if dark mode is enabled in the HA profile theme setting. Dark mode dims the brightness by `25%`.| | `default` | `live` | The view to show in the card by default. The default camera is the first one listed. See [Supported Views](view.md?id=supported-views) below. | | `interaction_seconds` | `300` | After a mouse/touch interaction with the card, it will be considered "interacted with" until this number of seconds elapses without further interaction. May be used as part of an [interaction condition](conditions.md?id=interaction) or with `reset_after_interaction` to reset the view after the interaction is complete. `0` means no interactions are reported / acted upon. | +| `keyboard_shortcuts` | See [usage](../usage/keyboard-shortcuts.md) for defaults. | Configure keyboard shortcuts. See below. | | `render_entities` | | **YAML only**: A list of entity ids that should cause the card to re-render 'in-place'. The view/camera is not changed. `update_*` flags do not pertain/relate to the behavior of this flag. This should **very** rarely be needed, but could be useful if the card is both setting and changing HA state of the same object as could be the case for some complex `card_mod` scenarios ([example](https://github.com/dermotduffy/frigate-hass-card/issues/343)). | | `reset_after_interaction` | `true` | If `true` the card will reset to the default configured view (i.e. 'screensaver' functionality) after `interaction_seconds` has elapsed after user interaction. | | `triggers` | | How to react when a camera is [triggered](cameras/README.md?id=triggers). | @@ -22,6 +23,25 @@ view: | `update_force` | `false` | Whether automated card updates should ignore user interaction. | | `update_seconds` | `0` | A number of seconds after which to automatically update/refresh the default view. If the default view occurs sooner (e.g. manually) the timer will start over. `0` disables this functionality.| +## `keyboard_shortcuts` + +Configure the key-bindings for the builtin keyboard shortcuts. See [usage](../usage/keyboard-shortcuts.md) information for defaults on keyboard shortcuts. + +| Option | Default | Description | +| - | - | - | +| `enabled` | `true` | If `true`, keyboard shortcuts are enabled. If `false`, they are disabled. | +| `ptz_left`, `ptz_right`, `ptz_up`, `ptz_down`, `ptz_zoom_in`, `ptz_zoom_out`, `ptz_home` | See [usage](../usage/keyboard-shortcuts.md) for defaults. | An object that configures the key binding for a given pre-configured action. See below. | + +### Keyboard Shortcut Configuration + +| Option | Default | Description | +| - | - | - | +| key | | Any [keyboard key value](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values), e.g. `ArrowLeft` | +| ctrl | `false` | If `true` requires the `ctrl` key to be held. | +| shift | `false` | If `true` requires the `shift` key to be held. | +| alt | `false` | If `true` requires the `alt` key to be held. | +| meta | `false` | If `true` requires the `meta` key to be held. | + ## `triggers` The `triggers` block controls how the card reacts when a camera is triggered (note that _what_ triggers the camera is controlled by the [`triggers`](cameras/README.md?id=triggers) block within the config for a given camera). This can be used for a variety of purposes, such as allowing the card to automatically change to `live` for a camera that triggers. @@ -102,6 +122,22 @@ view: interaction_mode: inactive trigger: default untrigger: none + keyboard_shortcuts: + enabled: true + ptz_left: + key: 'ArrowLeft' + ptz_right: + key: 'ArrowRight' + ptz_up: + key: 'ArrowUp' + ptz_down: + key: 'ArrowDown' + ptz_zoom_in: + key: '+' + ptz_zoom_out: + key: '-' + ptz_home: + key: 'h' actions: entity: light.office_main_lights tap_action: diff --git a/docs/examples.md b/docs/examples.md index 5c2aa999..bf623e6e 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -37,6 +37,8 @@ dimensions: ## Automation +### Responding to fullscreen + This example will automatically turn on the first configured substream when the card is put in fullscreen mode, and turn off the substream when exiting fullscreen mode. @@ -68,6 +70,49 @@ automations: frigate_card_action: live_substream_off ``` +### Responding to key input + +In addition to a handful of reconfigurable [built-in keyboard shortcuts](./usage/keyboard-shortcuts.md), `automations` can be used to take any action based on any keyboard input. These examples use [`key` conditions](./configuration/conditions.md?id=key) to assess keyboard state before taking action. + +#### Change to `live` temporarily + +In this example, the view will change to `live`, when `Alt+Z` is pressed, and change to the `clips` view `5` seconds later. + +```yaml +automations: + - conditions: + - condition: key + key: z + alt: true + actions: + - action: custom:frigate-card-action + frigate_card_action: live + - action: custom:frigate-card-action + frigate_card_action: sleep + duration: + s: 5 + - action: custom:frigate-card-action + frigate_card_action: clips +``` + +#### Change to `live` while key _held_ down + +In this example, the view will change to `live`, when `Alt+Z` is _held_ down, and immediately change to `clips` when _released_. + +```yaml +automations: + - conditions: + - condition: key + key: z + alt: true + actions: + - action: custom:frigate-card-action + frigate_card_action: live + actions_not: + - action: custom:frigate-card-action + frigate_card_action: clips +``` + ## `card-mod` This card allows the use of @@ -162,7 +207,7 @@ elements: tap_action: action: call-service service: amcrest.ptz_control - service_data: + data: entity_id: camera.kitchen movement: up ``` @@ -374,7 +419,7 @@ elements: ## Multiple actions -This example shows how to configure multiple actions for a single Frigate card user interaction, in this case both selecting a different camera and changing the view on `tap`. Note that multiple actions are not supported on stock Picture Elements, see [actions](configuration/actions.md) for more information. +This example shows how to configure multiple actions for a single Frigate card user interaction, in this case both selecting a different camera and changing the view on `tap`. Note that multiple actions are not supported on stock Picture Elements, see [actions](configuration/actions/README.md) for more information. ```yaml type: custom:frigate-card diff --git a/docs/usage/_sidebar.md b/docs/usage/_sidebar.md index 9b69f6a3..d129cda7 100644 --- a/docs/usage/_sidebar.md +++ b/docs/usage/_sidebar.md @@ -4,9 +4,10 @@ * [Screenshots](../screenshots.md) * [Troubleshooting](../troubleshooting.md) * [Usage](README.md) - * [2-way audio](2-way-audio.md) - * [Casting](casting.md) - * [URL Actions](url-actions.md) + * [2-way audio](2-way-audio.md) + * [Casting](casting.md) + * [Keyboard Shortcuts](keyboard-shortcuts.md) + * [URL Actions](url-actions.md) --- diff --git a/docs/usage/keyboard-shortcuts.md b/docs/usage/keyboard-shortcuts.md new file mode 100644 index 00000000..c7c07bd7 --- /dev/null +++ b/docs/usage/keyboard-shortcuts.md @@ -0,0 +1,18 @@ +# Keyboard Shortcuts + +There are two ways to have the card respond to key input: + +* As a convenience, the card supports a small number of built in shortcuts with pre-defined default bindings. See below for these built in shortcuts. Use the [`keyboard_shortcuts`](../configuration/view.md?id=keyboard_shortcuts) configuration to change their bindings. +* More generally, _any_ [action](../configuration/actions/README.md) can be configured to run in response to keyboard input as part of an [automation](../configuration/automations.md), even if that action does not have a pre-defined shortcut. See [keyboard automation example](../examples.md?id=responding-to-key-input) to show how to execute any arbitrary action(s) in response to keyboard activity. + +## Built-in shortcuts + +| Name | Default key binding | Action | Description | +| - | - | - | - | +| `ptz_down` | `ArrowDown` | [`ptz_multi`](../configuration/actions/custom/README.md?id=ptz_multi) | PTZ move down. | +| `ptz_home` | `h` | [`ptz_multi`](../configuration/actions/custom/README.md?id=ptz_multi) | PTZ home / default. | +| `ptz_left` | `ArrowLeft` | [`ptz_multi`](../configuration/actions/custom/README.md?id=ptz_multi) | PTZ move left. | +| `ptz_right` | `ArrowRight` | [`ptz_multi`](../configuration/actions/custom/README.md?id=ptz_multi) | PTZ move right. | +| `ptz_up` | `ArrowUp` | [`ptz_multi`](../configuration/actions/custom/README.md?id=ptz_multi) | PTZ move up. | +| `ptz_zoom_in` | `+` | [`ptz_multi`](../configuration/actions/custom/README.md?id=ptz_multi) | PTZ zoom in. | +| `ptz_zoom_out` | `-` | [`ptz_multi`](../configuration/actions/custom/README.md?id=ptz_multi) | PTZ zoom out. | diff --git a/docs/usage/url-actions.md b/docs/usage/url-actions.md index 0dbbcbd8..fa026bcd 100644 --- a/docs/usage/url-actions.md +++ b/docs/usage/url-actions.md @@ -1,7 +1,7 @@ # URL Actions It is possible to pass the Frigate card one or more -[actions](../configuration/actions.md) from the URL (e.g. select a particular +[actions](../configuration/actions/README.md) from the URL (e.g. select a particular camera, open the live view in expanded mode, etc). ### When actions are executed @@ -44,7 +44,7 @@ unless the action is targeted with a `CARD_ID` as shown above. ## Supported Actions -Only a subset of all [actions](../configuration/actions.md) are supported in URL form. +Only a subset of all [actions](../configuration/actions/README.md) are supported in URL form. | Action | Supported in URL | Explanation | | - | - | - | @@ -68,7 +68,7 @@ Only a subset of all [actions](../configuration/actions.md) are supported in URL | `recording` | :white_check_mark: | | | `recordings` | :white_check_mark: | | | `screenshot`| :heavy_multiplication_x: | Latest media information is not available on initial render. | -| `show_ptz` | :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/frigate-hass-card/issues) if you need this. | +| `ptz_controls` | :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/frigate-hass-card/issues) if you need this. | | `snapshot` | :white_check_mark: | | | `snapshots` | :white_check_mark: | | diff --git a/package.json b/package.json index a188473e..fb25a6bf 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "@types/masonry-layout": "^4.2.5", "@typescript-eslint/eslint-plugin": "^7.12.0", "@typescript-eslint/parser": "^7.12.0", - "@vitest/coverage-istanbul": "^1.5.0", + "@vitest/coverage-istanbul": "^1.6.0", "docsify-cli": "^4.4.4", "eslint": "^8.57.0", "eslint-config-airbnb-base": "^15.0.0", @@ -82,6 +82,7 @@ "sass": "^1.54.9", "ts-prune": "^0.10.3", "typescript": "^5.4.5", + "type-fest": "^4.18.0", "vitest": "^1.5.0", "vitest-mock-extended": "^1.3.1" }, diff --git a/src/action-handler-directive.ts b/src/action-handler-directive.ts index 5a7a4448..387654c2 100644 --- a/src/action-handler-directive.ts +++ b/src/action-handler-directive.ts @@ -92,6 +92,16 @@ class ActionHandler extends HTMLElement implements ActionHandlerInterface { } }; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const endTap = (_ev: Event): void => { + this.holdTimer.stop(); + + if (this.started) { + this.started = false; + fireEvent(element, 'action', { action: 'end_tap' }); + } + }; + const end = (ev: Event): void => { const options = element.actionHandlerOptions; if (!options?.allowPropagation) { @@ -109,10 +119,7 @@ class ActionHandler extends HTMLElement implements ActionHandlerInterface { return; } - this.holdTimer.stop(); - - this.started = false; - fireEvent(element, 'action', { action: 'end_tap' }); + endTap(ev); if (options?.hasHold && this.held) { fireEvent(element, 'action', { action: 'hold' }); @@ -147,6 +154,9 @@ class ActionHandler extends HTMLElement implements ActionHandlerInterface { element.addEventListener('click', end); element.addEventListener('keyup', handleEnter); + + // If the mouse leaves the element, this is considered the end of the interaction. + element.addEventListener('mouseleave', endTap); } } diff --git a/src/camera-manager/browse-media/engine-browse-media.ts b/src/camera-manager/browse-media/engine-browse-media.ts index d12d94f0..6f7e236d 100644 --- a/src/camera-manager/browse-media/engine-browse-media.ts +++ b/src/camera-manager/browse-media/engine-browse-media.ts @@ -31,6 +31,7 @@ import { import { BrowseMediaCamera } from './camera'; import { BrowseMediaViewMediaFactory } from './media'; import { BrowseMediaMetadata } from './types'; +import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; /** * A utility method to determine if a browse media object matches against a @@ -154,6 +155,7 @@ export class BrowseMediaCameraManagerEngine seek: false, snapshots: true, substream: true, + ptz: getPTZCapabilitiesFromCameraConfig(cameraConfig) ?? undefined, }, { disable: cameraConfig.capabilities?.disable, diff --git a/src/camera-manager/camera.ts b/src/camera-manager/camera.ts index c1bf6b14..c987b9f3 100644 --- a/src/camera-manager/camera.ts +++ b/src/camera-manager/camera.ts @@ -8,10 +8,10 @@ import { subscribeToTrigger, } from '../utils/ha'; import { EntityRegistryManager } from '../utils/ha/entity-registry'; +import { Capabilities } from './capabilities'; import { CameraManagerEngine } from './engine'; import { CameraNoIDError } from './error'; import { CameraEventCallback } from './types'; -import { Capabilities } from './capabilities'; type DestroyCallback = () => Promise; diff --git a/src/camera-manager/capabilities.ts b/src/camera-manager/capabilities.ts index 29c79456..cb3e07be 100644 --- a/src/camera-manager/capabilities.ts +++ b/src/camera-manager/capabilities.ts @@ -54,6 +54,18 @@ export class Capabilities { return this._capabilities.ptz ?? null; } + public hasPTZCapability(): boolean { + return !!( + this._capabilities.ptz?.down?.length || + this._capabilities.ptz?.up?.length || + this._capabilities.ptz?.left?.length || + this._capabilities.ptz?.right?.length || + this._capabilities.ptz?.zoomIn?.length || + this._capabilities.ptz?.zoomOut?.length || + this._capabilities.ptz?.presets?.length + ); + } + public getRawCapabilities(): CapabilitiesRaw { return this._capabilities; } diff --git a/src/camera-manager/engine.ts b/src/camera-manager/engine.ts index d9d09971..a17c0110 100644 --- a/src/camera-manager/engine.ts +++ b/src/camera-manager/engine.ts @@ -1,5 +1,5 @@ import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; -import { CameraConfig, PTZAction, PTZPhase } from '../config/types'; +import { CameraConfig, ActionPhase } from '../config/types'; import { ExtendedHomeAssistant } from '../types'; import { EntityRegistryManager } from '../utils/ha/entity-registry'; import { ViewMedia } from '../view/media'; @@ -27,6 +27,7 @@ import { RecordingSegmentsQuery, RecordingSegmentsQueryResultsMap, } from './types'; +import { PTZAction } from '../config/ptz'; export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; @@ -139,7 +140,7 @@ export interface CameraManagerEngine { cameraConfig: CameraConfig, action: PTZAction, options?: { - phase?: PTZPhase; + phase?: ActionPhase; preset?: string; }, ): Promise; diff --git a/src/camera-manager/frigate/camera.ts b/src/camera-manager/frigate/camera.ts index fdef3229..f00d1117 100644 --- a/src/camera-manager/frigate/camera.ts +++ b/src/camera-manager/frigate/camera.ts @@ -3,7 +3,10 @@ import uniq from 'lodash-es/uniq'; import { CameraConfig } from '../../config/types'; import { localize } from '../../localize/localize'; import { PTZCapabilities, PTZMovementType } from '../../types'; -import { errorToConsole } from '../../utils/basic'; +import { + errorToConsole, + recursivelyMergeObjectsConcatenatingArraysUniquely, +} from '../../utils/basic'; import { subscribeToTrigger } from '../../utils/ha'; import { EntityRegistryManager } from '../../utils/ha/entity-registry'; import { Entity } from '../../utils/ha/entity-registry/types'; @@ -13,6 +16,7 @@ import { CameraInitializationError } from '../error'; import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config'; import { getPTZInfo } from './requests'; import { PTZInfo, frigateEventChangeTriggerResponseSchema } from './types'; +import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; const CAMERA_BIRDSEYE = 'birdseye' as const; @@ -97,7 +101,18 @@ export class FrigateCamera extends Camera { protected async _initializeCapabilities(hass: HomeAssistant): Promise { const config = this.getConfig(); - const ptz = await this._getPTZCapabilities(hass, config); + + const configPTZCapabilities = getPTZCapabilitiesFromCameraConfig(this.getConfig()); + const frigatePTZCapabilities = await this._getPTZCapabilities(hass, config); + const combinedPTZCapabilities = + configPTZCapabilities || frigatePTZCapabilities + ? recursivelyMergeObjectsConcatenatingArraysUniquely( + {}, + configPTZCapabilities, + frigatePTZCapabilities, + ) + : null; + const birdseye = isBirdseye(config); this._capabilities = new Capabilities( { @@ -110,7 +125,7 @@ export class FrigateCamera extends Camera { live: true, menu: true, substream: true, - ...(ptz && { ptz: ptz }), + ...(combinedPTZCapabilities && { ptz: combinedPTZCapabilities }), }, { disable: config.capabilities?.disable, @@ -153,20 +168,25 @@ export class FrigateCamera extends Camera { return null; } + // Note: The Frigate integration only supports continuous PTZ movements + // (regardless of the actual underlying camera capability). const panTilt: PTZMovementType[] = [ ...(ptzInfo.features?.includes('pt') ? ['continuous' as const] : []), - ...(ptzInfo.features?.includes('pt-r') ? ['relative' as const] : []), ]; const zoom: PTZMovementType[] = [ ...(ptzInfo.features?.includes('zoom') ? ['continuous' as const] : []), - ...(ptzInfo.features?.includes('zoom-r') ? ['relative' as const] : []), ]; const presets = ptzInfo.presets; if (panTilt.length || zoom.length || presets?.length) { return { - ...(panTilt && { panTilt: panTilt }), - ...(zoom && { zoom: zoom }), + ...(panTilt && { + left: panTilt, + right: panTilt, + up: panTilt, + down: panTilt, + }), + ...(zoom && { zoomIn: zoom, zoomOut: zoom }), ...(presets && { presets: presets }), }; } diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index de6ef537..0f01bae3 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -4,7 +4,8 @@ import isEqual from 'lodash-es/isEqual'; import orderBy from 'lodash-es/orderBy'; import throttle from 'lodash-es/throttle'; import uniqWith from 'lodash-es/uniqWith'; -import { CameraConfig, PTZAction, PTZPhase } from '../../config/types'; +import { PTZAction } from '../../config/ptz'; +import { ActionPhase, CameraConfig } from '../../config/types'; import { ExtendedHomeAssistant } from '../../types'; import { allPromises, @@ -19,8 +20,8 @@ import { ViewMediaClassifier } from '../../view/media-classifier'; import { RecordingSegmentsCache, RequestCache } from '../cache'; import { Camera } from '../camera'; import { - CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, CameraManagerEngine, + CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, } from '../engine'; import { GenericCameraManagerEngine } from '../generic/engine-generic'; import { DateRange } from '../range'; @@ -61,12 +62,12 @@ import { FrigateCamera, isBirdseye } from './camera'; import { FrigateViewMediaFactory } from './media'; import { FrigateViewMediaClassifier } from './media-classifier'; import { - NativeFrigateEventQuery, - NativeFrigateRecordingSegmentsQuery, - getEventSummary, getEvents, + getEventSummary, getRecordingSegments, getRecordingsSummary, + NativeFrigateEventQuery, + NativeFrigateRecordingSegmentsQuery, retainEvent, } from './requests'; import { @@ -1015,7 +1016,7 @@ export class FrigateCameraManagerEngine cameraConfig: CameraConfig, action: PTZAction, options?: { - phase?: PTZPhase; + phase?: ActionPhase; preset?: string; }, ): Promise { diff --git a/src/camera-manager/generic/engine-generic.ts b/src/camera-manager/generic/engine-generic.ts index 971aec8b..dc69f35e 100644 --- a/src/camera-manager/generic/engine-generic.ts +++ b/src/camera-manager/generic/engine-generic.ts @@ -1,8 +1,9 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; -import { CameraConfig, PTZAction, PTZPhase } from '../../config/types'; -import { ExtendedHomeAssistant } from '../../types'; +import { PTZAction, PTZ_PAN_TILT_ACTIONS, PTZ_ZOOM_ACTIONS } from '../../config/ptz'; +import { ActionPhase, CameraConfig } from '../../config/types'; +import { ExtendedHomeAssistant, PTZCapabilities, PTZMovementType } from '../../types'; import { getEntityIcon, getEntityTitle } from '../../utils/ha'; import { EntityRegistryManager } from '../../utils/ha/entity-registry'; import { ViewMedia } from '../../view/media'; @@ -35,6 +36,7 @@ import { } from '../types'; import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config'; import { getDefaultGo2RTCEndpoint } from '../utils/go2rtc-endpoint'; +import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; export class GenericCameraManagerEngine implements CameraManagerEngine { protected _eventCallback?: CameraEventCallback; @@ -64,6 +66,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { seek: false, snapshots: false, substream: true, + ptz: getPTZCapabilitiesFromCameraConfig(cameraConfig) ?? undefined, }, { disable: cameraConfig.capabilities?.disable, @@ -222,7 +225,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { _cameraConfig: CameraConfig, _action: PTZAction, _options?: { - phase?: PTZPhase; + phase?: ActionPhase; preset?: string; }, ): Promise { diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index e73776de..9bae0252 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -3,7 +3,8 @@ import cloneDeep from 'lodash-es/cloneDeep'; import sum from 'lodash-es/sum'; import PQueue from 'p-queue'; import { CardCameraAPI } from '../card-controller/types.js'; -import { CameraConfig, CamerasConfig, PTZAction, PTZPhase } from '../config/types.js'; +import { PTZAction } from '../config/ptz.js'; +import { ActionPhase, CameraConfig, CamerasConfig } from '../config/types.js'; import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js'; import { localize } from '../localize/localize.js'; import { @@ -15,6 +16,7 @@ import { } from '../utils/basic.js'; import { getCameraID } from '../utils/camera.js'; import { log } from '../utils/debug.js'; +import { getConfiguredPTZAction } from './utils/ptz.js'; import { ViewMedia } from '../view/media.js'; import { Capabilities } from './capabilities.js'; import { CameraManagerEngineFactory } from './engine-factory.js'; @@ -771,18 +773,26 @@ export class CameraManager { public async executePTZAction( cameraID: string, action: PTZAction, - options: { - phase?: PTZPhase; + options?: { + phase?: ActionPhase; preset?: string; }, ): Promise { - const hass = this._api.getHASSManager().getHASS(); - const engine = this._store.getEngineForCameraID(cameraID); const cameraConfig = this._store.getCameraConfig(cameraID); - - if (!engine || !cameraConfig || !hass) { + if (!cameraConfig) { return; } - return engine.executePTZAction(hass, cameraConfig, action, options); + const configuredAction = getConfiguredPTZAction(cameraConfig, action, options); + if (configuredAction) { + return await this._api.getActionsManager().executeActions(configuredAction); + } + + const hass = this._api.getHASSManager().getHASS(); + const engine = this._store.getEngineForCameraID(cameraID); + + if (!engine || !hass) { + return; + } + return await engine.executePTZAction(hass, cameraConfig, action, options); } } diff --git a/src/camera-manager/utils/ptz.ts b/src/camera-manager/utils/ptz.ts new file mode 100644 index 00000000..78a6ccd0 --- /dev/null +++ b/src/camera-manager/utils/ptz.ts @@ -0,0 +1,82 @@ +import { PTZAction, PTZBaseAction } from '../../config/ptz'; +import { ActionPhase, ActionType, CameraConfig } from '../../config/types'; +import { PTZCapabilities, PTZMovementType } from '../../types'; + +export const getConfiguredPTZAction = ( + cameraConfig: CameraConfig, + action: PTZAction, + options?: { + phase?: ActionPhase; + preset?: string; + }, +): ActionType | ActionType[] | null => { + if (action === 'preset') { + return (options?.preset ? cameraConfig.ptz.presets?.[options.preset] : null) ?? null; + } + + if (options?.phase) { + return cameraConfig.ptz[`actions_${action}_${options.phase}`] ?? null; + } + + return cameraConfig.ptz[`actions_${action}`] ?? null; +}; + +const hasConfiguredPTZAction = ( + cameraConfig: CameraConfig, + action: PTZBaseAction, + options?: { + phase?: ActionPhase; + preset?: string; + }, +): boolean => { + return !!getConfiguredPTZAction(cameraConfig, action, options); +}; + +export const getConfiguredPTZMovementType = ( + cameraConfig: CameraConfig, + action: PTZBaseAction, +): PTZMovementType[] | null => { + const continuous = + hasConfiguredPTZAction(cameraConfig, action, { phase: 'start' }) && + hasConfiguredPTZAction(cameraConfig, action, { phase: 'stop' }); + const relative = hasConfiguredPTZAction(cameraConfig, action); + + return continuous || relative + ? [ + ...(continuous ? ['continuous' as const] : []), + ...(relative ? ['relative' as const] : []), + ] + : null; +}; + +export const getPTZCapabilitiesFromCameraConfig = ( + cameraConfig: CameraConfig, +): PTZCapabilities | null => { + const left = getConfiguredPTZMovementType(cameraConfig, 'left'); + const right = getConfiguredPTZMovementType(cameraConfig, 'right'); + const up = getConfiguredPTZMovementType(cameraConfig, 'up'); + const down = getConfiguredPTZMovementType(cameraConfig, 'down'); + const zoomIn = getConfiguredPTZMovementType(cameraConfig, 'zoom_in'); + const zoomOut = getConfiguredPTZMovementType(cameraConfig, 'zoom_out'); + const presets = cameraConfig.ptz.presets + ? Object.keys(cameraConfig.ptz.presets) + : undefined; + + return left?.length || + right?.length || + up?.length || + down?.length || + zoomIn?.length || + zoomOut?.length || + presets?.length + ? { + left: left ?? undefined, + right: right ?? undefined, + up: up ?? undefined, + down: down ?? undefined, + zoomIn: zoomIn ?? undefined, + zoomOut: zoomOut ?? undefined, + presets: presets, + } + : null; +}; diff --git a/src/card-controller/actions-manager.ts b/src/card-controller/actions-manager.ts deleted file mode 100644 index 08eff63c..00000000 --- a/src/card-controller/actions-manager.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { z } from 'zod'; -import { - Actions, - ActionsConfig, - ActionType, - FrigateCardCustomAction, -} from '../config/types.js'; -import { - convertActionToFrigateCardCustomAction, - frigateCardHandleAction, - frigateCardHandleActionConfig, - getActionConfigGivenAction, -} from '../utils/action.js'; -import { getStreamCameraID } from '../utils/substream.js'; -import { generateViewContextForZoomChange } from '../components-lib/zoom/zoom-view-context.js'; -import { CardActionsManagerAPI } from './types.js'; - -const interactionSchema = z.object({ - action: z.enum(['tap', 'double_tap', 'hold', 'start_tap', 'end_tap']), -}); -export type Interaction = z.infer; - -const interactionEventSchema = z.object({ - detail: interactionSchema, -}); - -export class ActionsManager { - protected _api: CardActionsManagerAPI; - - constructor(api: CardActionsManagerAPI) { - this._api = api; - } - - /** - * Merge card-wide and view-specific actions. - * @returns A combined set of action. - */ - public getMergedActions(): ActionsConfig { - const view = this._api.getViewManager().getView(); - if (this._api.getMessageManager().hasMessage()) { - return {}; - } - - const config = this._api.getConfigManager().getConfig(); - let specificActions: Actions | undefined = undefined; - if (view?.is('live')) { - specificActions = config?.live.actions; - } else if (view?.isGalleryView()) { - specificActions = config?.media_gallery?.actions; - } else if (view?.isViewerView()) { - specificActions = config?.media_viewer.actions; - } else if (view?.is('image')) { - specificActions = config?.image?.actions; - } else { - return {}; - } - return { ...config?.view.actions, ...specificActions }; - } - - /** - * Handle an human interaction called on an element (e.g. 'tap'). - */ - public handleInteractionEvent = (ev: Event): void => { - const result = interactionEventSchema.safeParse(ev); - if (!result.success) { - return; - } - const interaction = result.data.detail.action; - const hass = this._api.getHASSManager().getHASS(); - const config = this.getMergedActions(); - const actionConfig = getActionConfigGivenAction(interaction, config); - if ( - hass && - config && - interaction && - // Don't call frigateCardHandleActionConfig() unless there is explicitly an - // action defined (as it uses a default that is unhelpful for views that - // have default tap/click actions). - actionConfig - ) { - frigateCardHandleActionConfig( - this._api.getCardElementManager().getElement(), - hass, - config, - interaction, - actionConfig, - ); - } - }; - - public handleActionEvent = (ev: Event): void => { - if (!('detail' in ev)) { - // The event may not be a CustomEvent object, see: - // https://github.com/custom-cards/custom-card-helpers/blob/master/src/fire-event.ts#L70 - return; - } - - const frigateCardAction = convertActionToFrigateCardCustomAction(ev.detail); - if (frigateCardAction) { - this.executeFrigateAction(frigateCardAction); - } - }; - - /** - * Small convenience method to call frigateCardHandleAction without the caller - * needing hass or the element. - */ - public executeActions(actions: ActionType | ActionType[]): void { - const hass = this._api.getHASSManager().getHASS(); - if (!hass) { - return; - } - - frigateCardHandleAction( - this._api.getCardElementManager().getElement(), - hass, - {}, - actions, - ); - } - - /** - * Execute a card action. - * @param frigateCardAction - * @returns `true` if an action is executed. - */ - public async executeFrigateAction( - frigateCardAction: FrigateCardCustomAction, - ): Promise { - const config = this._api.getConfigManager().getConfig(); - const mediaLoadedInfoManager = this._api.getMediaLoadedInfoManager(); - - if ( - // Command not intended for this card (e.g. query string command). - frigateCardAction.card_id && - config?.card_id !== frigateCardAction.card_id - ) { - return; - } - - // Note: This function needs to process (view-related) commands even when - // _view has not yet been initialized (since it may be used to set a view - // via the querystring). - const view = this._api.getViewManager().getView(); - - const action = frigateCardAction.frigate_card_action; - - switch (action) { - case 'default': - this._api.getViewManager().setViewDefault(); - break; - case 'clip': - case 'clips': - case 'image': - case 'live': - case 'recording': - case 'recordings': - case 'snapshot': - case 'snapshots': - case 'timeline': - this._api.getViewManager().setViewByParameters({ - viewName: action, - cameraID: view?.camera, - }); - break; - case 'download': - await this._api.getDownloadManager().downloadViewerMedia(); - break; - case 'camera_ui': - this._api.getCameraURLManager().openURL(); - break; - case 'expand': - this._api.getExpandManager().toggleExpanded(); - break; - case 'fullscreen': - this._api.getFullscreenManager().toggleFullscreen(); - break; - case 'menu_toggle': - // This is a rare code path: this would only be used if someone has a - // menu toggle action configured outside of the menu itself (e.g. - // picture elements). - this._api.getCardElementManager().toggleMenu(); - break; - case 'camera_select': - const selectCameraID = - frigateCardAction.camera ?? - (frigateCardAction.triggered - ? this._api.getTriggersManager().getMostRecentlyTriggeredCameraID() - : null); - if (selectCameraID && view) { - const viewOnCameraSelect = config?.view.camera_select ?? 'current'; - const targetViewName = - viewOnCameraSelect === 'current' ? view.view : viewOnCameraSelect; - this._api.getViewManager().setViewByParameters({ - viewName: targetViewName, - cameraID: selectCameraID, - failSafe: true, - }); - } - break; - case 'live_substream_select': { - this._api.getViewManager().setViewWithSubstream(frigateCardAction.camera); - break; - } - case 'live_substream_off': { - this._api.getViewManager().setViewWithoutSubstream(); - break; - } - case 'live_substream_on': { - this._api.getViewManager().setViewWithSubstream(); - break; - } - case 'media_player': - const mediaPlayer = frigateCardAction.media_player; - const mediaPlayerController = this._api.getMediaPlayerManager(); - const media = view?.queryResults?.getSelectedResult() ?? null; - - if (frigateCardAction.media_player_action === 'stop') { - await mediaPlayerController.stop(mediaPlayer); - } else if (view?.is('live')) { - await mediaPlayerController.playLive(mediaPlayer, getStreamCameraID(view)); - } else if (view?.isViewerView() && media) { - await mediaPlayerController.playMedia(mediaPlayer, media); - } - break; - case 'diagnostics': - this._api.getViewManager().setViewByParameters({ viewName: 'diagnostics' }); - break; - case 'microphone_mute': - this._api.getMicrophoneManager().mute(); - break; - case 'microphone_unmute': - await this._api.getMicrophoneManager().unmute(); - break; - case 'mute': - await mediaLoadedInfoManager.get()?.player?.mute(); - break; - case 'unmute': - await mediaLoadedInfoManager.get()?.player?.unmute(); - break; - case 'play': - await mediaLoadedInfoManager.get()?.player?.play(); - break; - case 'pause': - await mediaLoadedInfoManager.get()?.player?.pause(); - break; - case 'screenshot': - await this._api.getDownloadManager().downloadScreenshot(); - break; - case 'display_mode_select': - this._api - .getViewManager() - .setViewWithNewDisplayMode(frigateCardAction.display_mode); - break; - case 'ptz': - const cameraID = this._api.getViewManager().getView()?.camera; - if (cameraID) { - this._api - .getCameraManager() - .executePTZAction(cameraID, frigateCardAction.ptz_action, { - phase: frigateCardAction.ptz_phase, - preset: frigateCardAction.ptz_preset, - }); - } - break; - case 'show_ptz': - this._api.getViewManager().setViewWithMergedContext({ - live: { ptzVisible: frigateCardAction.show_ptz }, - }); - break; - case 'change_zoom': - this._api.getViewManager().setViewWithMergedContext( - generateViewContextForZoomChange(frigateCardAction.target_id, { - zoom: { - pan: frigateCardAction.pan, - zoom: frigateCardAction.zoom, - }, - }), - ); - break; - default: - console.warn(`Frigate card received unknown card action: ${action}`); - } - } -} diff --git a/src/card-controller/actions/actions-manager.ts b/src/card-controller/actions/actions-manager.ts new file mode 100644 index 00000000..79bea074 --- /dev/null +++ b/src/card-controller/actions/actions-manager.ts @@ -0,0 +1,121 @@ +import { z } from 'zod'; +import { Actions, ActionsConfig, ActionType } from '../../config/types.js'; +import { getActionConfigGivenAction } from '../../utils/action.js'; +import { ActionSet } from './actions/set.js'; +import { CardActionsManagerAPI } from '../types.js'; +import { ActionExecutionRequest, AuxillaryActionConfig } from './types.js'; +import { ActionContext } from 'action'; + +const INTERACTIONS = ['tap', 'double_tap', 'hold', 'start_tap', 'end_tap'] as const; +export type InteractionName = (typeof INTERACTIONS)[number]; + +const interactionSchema = z.object({ + action: z.enum(INTERACTIONS), +}); +export type Interaction = z.infer; + +const interactionEventSchema = z.object({ + detail: interactionSchema, +}); + +export class ActionsManager { + protected _api: CardActionsManagerAPI; + protected _actionsInFlight: ActionSet[] = []; + protected _actionContext: ActionContext = {}; + + constructor(api: CardActionsManagerAPI) { + this._api = api; + } + + /** + * Merge card-wide and view-specific actions. + * @returns A combined set of action. + */ + public getMergedActions(): ActionsConfig { + const view = this._api.getViewManager().getView(); + if (this._api.getMessageManager().hasMessage()) { + return {}; + } + + const config = this._api.getConfigManager().getConfig(); + let specificActions: Actions | undefined = undefined; + if (view?.is('live')) { + specificActions = config?.live.actions; + } else if (view?.isGalleryView()) { + specificActions = config?.media_gallery?.actions; + } else if (view?.isViewerView()) { + specificActions = config?.media_viewer.actions; + } else if (view?.is('image')) { + specificActions = config?.image?.actions; + } else { + return {}; + } + return { ...config?.view.actions, ...specificActions }; + } + + /** + * Handle an human interaction called on an element (e.g. 'tap'). + */ + public handleInteractionEvent = (ev: Event): void => { + const result = interactionEventSchema.safeParse(ev); + if (!result.success) { + return; + } + const interaction = result.data.detail.action; + const config = this.getMergedActions(); + const actionConfig = getActionConfigGivenAction(interaction, config); + if ( + config && + interaction && + // Don't call frigateCardHandleActionConfig() unless there is explicitly an + // action defined (as it uses a default that is unhelpful for views that + // have default tap/click actions). + actionConfig + ) { + this.executeActions(actionConfig, config); + } + }; + + /** + * This method is called when an ll-custom event is fired. This is used by + * cards to fire custom actions. This card itself should not call this, but + * embedded elements may. + */ + public handleCustomActionEvent = (ev: Event): void => { + if (!('detail' in ev)) { + // The event may not be a CustomEvent object, see: + // https://github.com/custom-cards/custom-card-helpers/blob/master/src/fire-event.ts#L70 + return; + } + this.executeActions(ev.detail as ActionType); + }; + + /** + * This method handles actions requested by components of the Frigate card + * itself (e.g. menu, PTZ controller). + */ + public handleActionExecutionRequestEvent = async ( + ev: CustomEvent, + ): Promise => { + await this.executeActions(ev.detail.action, ev.detail.config); + }; + + public uninitialize(): void { + // If there are any long-running actions, ensure they are stopped. + this._actionsInFlight.forEach((actionSet) => actionSet.stop()); + } + + public async executeActions( + action: ActionType | ActionType[], + config?: AuxillaryActionConfig, + ): Promise { + const actionSet = new ActionSet(this._actionContext, action, { + config: config, + cardID: this._api.getConfigManager().getConfig()?.card_id, + }); + + this._actionsInFlight.push(actionSet); + await actionSet.execute(this._api); + this._actionsInFlight = this._actionsInFlight.filter((a) => a !== actionSet); + } +} diff --git a/src/card-controller/actions/actions/base.ts b/src/card-controller/actions/actions/base.ts new file mode 100644 index 00000000..12533296 --- /dev/null +++ b/src/card-controller/actions/actions/base.ts @@ -0,0 +1,29 @@ +import { ActionContext } from 'action'; +import { FrigateCardCustomAction } from '../../../config/types'; +import { CardActionsAPI } from '../../types'; +import { Action, AuxillaryActionConfig } from '../types'; + +export class BaseAction implements Action { + protected _context: ActionContext; + protected _action: T; + protected _config?: AuxillaryActionConfig; + + constructor(context: ActionContext, action: T, config?: AuxillaryActionConfig) { + this._context = context; + this._action = action; + this._config = config; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async execute(_api: CardActionsAPI): Promise { + // Pass. + } + + public async stop(): Promise { + // Pass. + } +} + +export class FrigateCardAction< + T extends FrigateCardCustomAction, +> extends BaseAction {} diff --git a/src/card-controller/actions/actions/camera-select.ts b/src/card-controller/actions/actions/camera-select.ts new file mode 100644 index 00000000..c5f82ce2 --- /dev/null +++ b/src/card-controller/actions/actions/camera-select.ts @@ -0,0 +1,26 @@ +import { CameraSelectActionConfig } from '../../../config/types'; +import { CardActionsAPI } from '../../types'; +import { FrigateCardAction } from './base'; + +export class CameraSelectAction extends FrigateCardAction { + public async execute(api: CardActionsAPI): Promise { + const selectCameraID = + this._action.camera ?? + (this._action.triggered + ? api.getTriggersManager().getMostRecentlyTriggeredCameraID() + : null); + const view = api.getViewManager().getView(); + const config = api.getConfigManager().getConfig(); + + if (selectCameraID && view) { + const viewOnCameraSelect = config?.view.camera_select ?? 'current'; + const targetViewName = + viewOnCameraSelect === 'current' ? view.view : viewOnCameraSelect; + api.getViewManager().setViewByParameters({ + viewName: targetViewName, + cameraID: selectCameraID, + failSafe: true, + }); + } + } +} diff --git a/src/card-controller/actions/actions/camera-ui.ts b/src/card-controller/actions/actions/camera-ui.ts new file mode 100644 index 00000000..148788c9 --- /dev/null +++ b/src/card-controller/actions/actions/camera-ui.ts @@ -0,0 +1,9 @@ +import { GeneralActionConfig } from '../../../config/types'; +import { CardActionsAPI } from '../../types'; +import { FrigateCardAction } from './base'; + +export class CameraUIAction extends FrigateCardAction { + public async execute(api: CardActionsAPI): Promise { + api.getCameraURLManager().openURL(); + } +} diff --git a/src/card-controller/actions/actions/default.ts b/src/card-controller/actions/actions/default.ts new file mode 100644 index 00000000..5fa12f40 --- /dev/null +++ b/src/card-controller/actions/actions/default.ts @@ -0,0 +1,9 @@ +import { GeneralActionConfig } from '../../../config/types'; +import { CardActionsAPI } from '../../types'; +import { FrigateCardAction } from './base'; + +export class DefaultAction extends FrigateCardAction { + public async execute(api: CardActionsAPI): Promise { + api.getViewManager().setViewDefault(); + } +} diff --git a/src/card-controller/actions/actions/display-mode-select.ts b/src/card-controller/actions/actions/display-mode-select.ts new file mode 100644 index 00000000..adacf29d --- /dev/null +++ b/src/card-controller/actions/actions/display-mode-select.ts @@ -0,0 +1,9 @@ +import { DisplayModeActionConfig } from '../../../config/types'; +import { CardActionsAPI } from '../../types'; +import { FrigateCardAction } from './base'; + +export class DisplayModeSelectAction extends FrigateCardAction { + public async execute(api: CardActionsAPI): Promise { + await api.getViewManager().setViewWithNewDisplayMode(this._action.display_mode); + } +} diff --git a/src/card-controller/actions/actions/download.ts b/src/card-controller/actions/actions/download.ts new file mode 100644 index 00000000..5103202e --- /dev/null +++ b/src/card-controller/actions/actions/download.ts @@ -0,0 +1,9 @@ +import { GeneralActionConfig } from '../../../config/types'; +import { CardActionsAPI } from '../../types'; +import { FrigateCardAction } from './base'; + +export class DownloadAction extends FrigateCardAction { + public async execute(api: CardActionsAPI): Promise { + await api.getDownloadManager().downloadViewerMedia(); + } +} diff --git a/src/card-controller/actions/actions/expand.ts b/src/card-controller/actions/actions/expand.ts new file mode 100644 index 00000000..94d80d48 --- /dev/null +++ b/src/card-controller/actions/actions/expand.ts @@ -0,0 +1,9 @@ +import { GeneralActionConfig } from '../../../config/types'; +import { CardActionsAPI } from '../../types'; +import { FrigateCardAction } from './base'; + +export class ExpandAction extends FrigateCardAction { + public async execute(api: CardActionsAPI): Promise { + api.getExpandManager().toggleExpanded(); + } +} diff --git a/src/card-controller/actions/actions/fullscreen.ts b/src/card-controller/actions/actions/fullscreen.ts new file mode 100644 index 00000000..411b08d2 --- /dev/null +++ b/src/card-controller/actions/actions/fullscreen.ts @@ -0,0 +1,9 @@ +import { GeneralActionConfig } from '../../../config/types'; +import { CardActionsAPI } from '../../types'; +import { FrigateCardAction } from './base'; + +export class FullscreenAction extends FrigateCardAction { + public async execute(api: CardActionsAPI): Promise { + api.getFullscreenManager().toggleFullscreen(); + } +} diff --git a/src/card-controller/actions/actions/generic.ts b/src/card-controller/actions/actions/generic.ts new file mode 100644 index 00000000..9b7fda95 --- /dev/null +++ b/src/card-controller/actions/actions/generic.ts @@ -0,0 +1,20 @@ +import { ActionConfig, handleActionConfig } from '@dermotduffy/custom-card-helpers'; +import { CardActionsAPI } from '../../types'; +import { BaseAction } from './base'; + +/** + * Handles generic HA (non-Frigate) actions (e.g. 'more-info') + */ +export class GenericAction extends BaseAction { + public async execute(api: CardActionsAPI): Promise { + const hass = api.getHASSManager().getHASS(); + if (hass) { + handleActionConfig( + api.getCardElementManager().getElement(), + hass, + this._config ?? {}, + this._action, + ); + } + } +} diff --git a/src/card-controller/actions/actions/log.ts b/src/card-controller/actions/actions/log.ts new file mode 100644 index 00000000..50302f37 --- /dev/null +++ b/src/card-controller/actions/actions/log.ts @@ -0,0 +1,10 @@ +import { LogActionConfig } from '../../../config/types'; +import { CardActionsAPI } from '../../types'; +import { FrigateCardAction } from './base'; + +export class LogAction extends FrigateCardAction { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async execute(_api: CardActionsAPI): Promise { + console[this._action.level](this._action.message); + } +} diff --git a/src/card-controller/actions/actions/media-player.ts b/src/card-controller/actions/actions/media-player.ts new file mode 100644 index 00000000..8ecfc574 --- /dev/null +++ b/src/card-controller/actions/actions/media-player.ts @@ -0,0 +1,21 @@ +import { MediaPlayerActionConfig } from '../../../config/types'; +import { getStreamCameraID } from '../../../utils/substream'; +import { CardActionsAPI } from '../../types'; +import { FrigateCardAction } from './base'; + +export class MediaPlayerAction extends FrigateCardAction { + public async execute(api: CardActionsAPI): Promise { + const mediaPlayer = this._action.media_player; + const mediaPlayerController = api.getMediaPlayerManager(); + const view = api.getViewManager().getView(); + const media = view?.queryResults?.getSelectedResult() ?? null; + + if (this._action.media_player_action === 'stop') { + await mediaPlayerController.stop(mediaPlayer); + } else if (view?.is('live')) { + await mediaPlayerController.playLive(mediaPlayer, getStreamCameraID(view)); + } else if (view?.isViewerView() && media) { + await mediaPlayerController.playMedia(mediaPlayer, media); + } + } +} diff --git a/src/card-controller/actions/actions/menu-toggle.ts b/src/card-controller/actions/actions/menu-toggle.ts new file mode 100644 index 00000000..1cbac07d --- /dev/null +++ b/src/card-controller/actions/actions/menu-toggle.ts @@ -0,0 +1,9 @@ +import { GeneralActionConfig } from '../../../config/types'; +import { CardActionsAPI } from '../../types'; +import { FrigateCardAction } from './base'; + +export class MenuToggleAction extends FrigateCardAction { + public async execute(api: CardActionsAPI): Promise { + api.getCardElementManager().toggleMenu(); + } +} diff --git a/src/card-controller/actions/actions/microphone-mute.ts b/src/card-controller/actions/actions/microphone-mute.ts new file mode 100644 index 00000000..c7a6c1bb --- /dev/null +++ b/src/card-controller/actions/actions/microphone-mute.ts @@ -0,0 +1,9 @@ +import { GeneralActionConfig } from '../../../config/types'; +import { CardActionsAPI } from '../../types'; +import { FrigateCardAction } from './base'; + +export class MicrophoneMuteAction extends FrigateCardAction { + public async execute(api: CardActionsAPI): Promise { + api.getMicrophoneManager().mute(); + } +} diff --git a/src/card-controller/actions/actions/microphone-unmute.ts b/src/card-controller/actions/actions/microphone-unmute.ts new file mode 100644 index 00000000..8757b2a8 --- /dev/null +++ b/src/card-controller/actions/actions/microphone-unmute.ts @@ -0,0 +1,9 @@ +import { GeneralActionConfig } from '../../../config/types'; +import { CardActionsAPI } from '../../types'; +import { FrigateCardAction } from './base'; + +export class MicrophoneUnmuteAction extends FrigateCardAction { + public async execute(api: CardActionsAPI): Promise { + await api.getMicrophoneManager().unmute(); + } +} diff --git a/src/card-controller/actions/actions/mute.ts b/src/card-controller/actions/actions/mute.ts new file mode 100644 index 00000000..3c43b8d0 --- /dev/null +++ b/src/card-controller/actions/actions/mute.ts @@ -0,0 +1,9 @@ +import { GeneralActionConfig } from '../../../config/types'; +import { CardActionsAPI } from '../../types'; +import { FrigateCardAction } from './base'; + +export class MuteAction extends FrigateCardAction { + public async execute(api: CardActionsAPI): Promise { + await api.getMediaLoadedInfoManager().get()?.player?.mute(); + } +} diff --git a/src/card-controller/actions/actions/pause.ts b/src/card-controller/actions/actions/pause.ts new file mode 100644 index 00000000..f77d657b --- /dev/null +++ b/src/card-controller/actions/actions/pause.ts @@ -0,0 +1,9 @@ +import { GeneralActionConfig } from '../../../config/types'; +import { CardActionsAPI } from '../../types'; +import { FrigateCardAction } from './base'; + +export class PauseAction extends FrigateCardAction { + public async execute(api: CardActionsAPI): Promise { + await api.getMediaLoadedInfoManager().get()?.player?.pause(); + } +} diff --git a/src/card-controller/actions/actions/play.ts b/src/card-controller/actions/actions/play.ts new file mode 100644 index 00000000..1d0707fe --- /dev/null +++ b/src/card-controller/actions/actions/play.ts @@ -0,0 +1,9 @@ +import { GeneralActionConfig } from '../../../config/types'; +import { CardActionsAPI } from '../../types'; +import { FrigateCardAction } from './base'; + +export class PlayAction extends FrigateCardAction { + public async execute(api: CardActionsAPI): Promise { + await api.getMediaLoadedInfoManager().get()?.player?.play(); + } +} diff --git a/src/card-controller/actions/actions/ptz-controls.ts b/src/card-controller/actions/actions/ptz-controls.ts new file mode 100644 index 00000000..6bf0e976 --- /dev/null +++ b/src/card-controller/actions/actions/ptz-controls.ts @@ -0,0 +1,11 @@ +import { PTZControlsActionConfig } from '../../../config/types'; +import { CardActionsAPI } from '../../types'; +import { FrigateCardAction } from './base'; + +export class PTZControlsAction extends FrigateCardAction { + public async execute(api: CardActionsAPI): Promise { + api.getViewManager().setViewWithMergedContext({ + ptzControls: { enabled: this._action.enabled }, + }); + } +} diff --git a/src/card-controller/actions/actions/ptz-digital.ts b/src/card-controller/actions/actions/ptz-digital.ts new file mode 100644 index 00000000..1f8bec54 --- /dev/null +++ b/src/card-controller/actions/actions/ptz-digital.ts @@ -0,0 +1,126 @@ +import clamp from 'lodash-es/clamp'; +import { + PartialZoomSettings, + ZOOM_DEFAULT_PAN_X, + ZOOM_DEFAULT_PAN_Y, + ZOOM_DEFAULT_SCALE, +} from '../../../components-lib/zoom/types'; +import { generateViewContextForZoom } from '../../../components-lib/zoom/zoom-view-context'; +import { PTZDigitialActionConfig, ZOOM_MAX, ZOOM_MIN } from '../../../config/types'; +import { getPTZTarget } from '../../../utils/ptz'; +import { Timer } from '../../../utils/timer'; +import { CardActionsAPI } from '../../types'; +import { FrigateCardAction } from './base'; +import { TargetedActionContext } from '../types'; +import { + setInProgressForThisTarget, + stopInProgressForThisTarget, +} from '../utils/action-state'; + +const STEP_DELAY_SECONDS = 0.1; +const STEP_ZOOM = 0.1; +const STEP_PAN = 5; + +declare module 'action' { + interface ActionContext { + ptzDigital?: TargetedActionContext; + } +} + +export class PTZDigitalAction extends FrigateCardAction { + protected _timer = new Timer(); + + protected async _stepChange(api: CardActionsAPI, targetID: string): Promise { + api.getViewManager().setViewWithMergedContext( + generateViewContextForZoom(targetID, { + requested: this._convertActionToZoomSettings( + api.getViewManager().getView()?.context?.zoom?.[targetID]?.observed, + ), + }), + ); + } + + public async stop(): Promise { + this._timer.stop(); + } + + public async execute(api: CardActionsAPI): Promise { + const view = api.getViewManager().getView(); + if (!view) { + return; + } + + const targetID = + this._action.target_id ?? + getPTZTarget(view, { type: 'digital', cameraManager: api.getCameraManager() }) + ?.targetID; + if (!targetID) { + return; + } + + if (!!this._action.absolute || !this._action.ptz_phase) { + return await this._stepChange(api, targetID); + } + + /* istanbul ignore else: the else path cannot be reached -- @preserve */ + if (this._action.ptz_phase === 'start') { + stopInProgressForThisTarget(targetID, this._context.ptzDigital); + setInProgressForThisTarget(targetID, this._context, 'ptzDigital', this); + + await this._stepChange(api, targetID); + this._timer.startRepeated(STEP_DELAY_SECONDS, () => + this._stepChange(api, targetID), + ); + } else if (this._action.ptz_phase === 'stop') { + stopInProgressForThisTarget(targetID, this._context.ptzDigital); + delete this._context.ptzDigital?.[targetID]; + } + } + + protected _convertActionToZoomSettings( + base?: PartialZoomSettings, + ): PartialZoomSettings { + if (!this._action.absolute && !this._action.ptz_action) { + // If neither an absolute position nor an action are specified, the request + // is assumed to be to return to default. + return {}; + } + + if (this._action.absolute) { + return this._action.absolute; + } + + const zoom = base?.zoom ?? ZOOM_DEFAULT_SCALE; + const pan = { + x: base?.pan?.x ?? ZOOM_DEFAULT_PAN_X, + y: base?.pan?.y ?? ZOOM_DEFAULT_PAN_Y, + }; + + const zoomDelta = + this._action.ptz_action === 'zoom_in' + ? STEP_ZOOM + : this._action.ptz_action === 'zoom_out' + ? -STEP_ZOOM + : 0; + const xDelta = + this._action.ptz_action === 'left' + ? -STEP_PAN + : this._action.ptz_action === 'right' + ? STEP_PAN + : 0; + const yDelta = + this._action.ptz_action === 'up' + ? -STEP_PAN + : this._action.ptz_action === 'down' + ? STEP_PAN + : 0; + + return { + zoom: clamp(zoom + zoomDelta, ZOOM_MIN, ZOOM_MAX), + pan: { + x: clamp(pan.x + xDelta, 0, 100), + y: clamp(pan.y + yDelta, 0, 100), + }, + }; + } +} diff --git a/src/card-controller/actions/actions/ptz-multi.ts b/src/card-controller/actions/actions/ptz-multi.ts new file mode 100644 index 00000000..1207a50c --- /dev/null +++ b/src/card-controller/actions/actions/ptz-multi.ts @@ -0,0 +1,60 @@ +import { PTZMultiActionConfig } from '../../../config/types'; +import { createPTZAction, createPTZDigitalAction } from '../../../utils/action'; +import { PTZType, getPTZTarget, hasCameraTruePTZ } from '../../../utils/ptz'; +import { CardActionsAPI } from '../../types'; +import { FrigateCardAction } from './base'; +import { PTZAction } from './ptz'; +import { PTZDigitalAction } from './ptz-digital'; + +export class PTZMultiAction extends FrigateCardAction { + public async execute(api: CardActionsAPI): Promise { + const view = api.getViewManager().getView(); + let targetID: string | null = null; + let type: PTZType | null = null; + + if (this._action.target_id) { + targetID = this._action.target_id; + type = hasCameraTruePTZ(api.getCameraManager(), targetID) ? 'ptz' : 'digital'; + } else if (view) { + const multiTarget = getPTZTarget(view, { cameraManager: api.getCameraManager() }); + targetID = multiTarget?.targetID ?? null; + type = multiTarget?.type ?? null; + } + + if (!targetID || type === null) { + return; + } + + (type === 'ptz' + ? this._toPTZAction(targetID) + : this._toPTZDigitalAction(targetID) + ).execute(api); + } + + protected _toPTZAction(targetID: string): PTZAction { + return new PTZAction( + this._context, + createPTZAction({ + cardID: this._action.card_id, + cameraID: targetID, + ptzAction: this._action.ptz_action, + ptzPhase: this._action.ptz_phase, + ptzPreset: this._action.ptz_preset, + }), + this._config, + ); + } + + protected _toPTZDigitalAction(targetID: string): PTZDigitalAction { + return new PTZDigitalAction( + this._context, + createPTZDigitalAction({ + cardID: this._action.card_id, + ptzPhase: this._action.ptz_phase, + ptzAction: this._action.ptz_action, + targetID: targetID, + }), + this._config, + ); + } +} diff --git a/src/card-controller/actions/actions/ptz.ts b/src/card-controller/actions/actions/ptz.ts new file mode 100644 index 00000000..17cf7a0b --- /dev/null +++ b/src/card-controller/actions/actions/ptz.ts @@ -0,0 +1,121 @@ +import { PTZActionConfig } from '../../../config/types'; +import { getPTZTarget, ptzActionToCapabilityKey } from '../../../utils/ptz'; +import { Timer } from '../../../utils/timer'; +import { CardActionsAPI } from '../../types'; +import { + setInProgressForThisTarget, + stopInProgressForThisTarget, +} from '../utils/action-state'; +import { FrigateCardAction } from './base'; + +interface PTZContext { + [cameraID: string]: { + inProgressAction?: PTZAction; + }; +} + +declare module 'action' { + interface ActionContext { + ptz?: PTZContext; + } +} + +export class PTZAction extends FrigateCardAction { + protected _timer = new Timer(); + + public async stop(): Promise { + this._timer.stop(); + } + + public async execute(api: CardActionsAPI): Promise { + const view = api.getViewManager().getView(); + if (!view) { + return; + } + + const ptzCameraID = + this._action.camera ?? + getPTZTarget(view, { type: 'ptz', cameraManager: api.getCameraManager() }) + ?.targetID ?? + null; + const ptzCapabilities = ptzCameraID + ? api + .getCameraManager() + .getCameraCapabilities(ptzCameraID) + ?.getPTZCapabilities() + : null; + const ptzConfiguration = ptzCameraID + ? api.getCameraManager().getStore().getCameraConfig(ptzCameraID)?.ptz + : null; + if (!ptzCameraID || !ptzCapabilities || !ptzConfiguration) { + return; + } + + if (!this._action.ptz_action) { + if (ptzCapabilities.presets && ptzCapabilities.presets.length >= 1) { + await api.getCameraManager().executePTZAction(ptzCameraID, 'preset', { + phase: this._action.ptz_phase, + preset: ptzCapabilities.presets[0], + }); + } + return; + } + + const capabilityKey = ptzActionToCapabilityKey(this._action.ptz_action); + if ( + (capabilityKey && + ptzCapabilities[capabilityKey]?.includes( + this._action.ptz_phase ? 'continuous' : 'relative', + )) || + this._action.ptz_action === 'preset' + ) { + // Scenario: Camera natively supports requested move type. + return await api + .getCameraManager() + .executePTZAction(ptzCameraID, this._action.ptz_action, { + phase: this._action.ptz_phase, + preset: this._action.ptz_preset, + }); + } + + if (this._action.ptz_phase === 'start') { + // Scenario: Asked to start a continuous move, camera only supports relative moves natively. + stopInProgressForThisTarget(ptzCameraID, this._context.ptz); + setInProgressForThisTarget(ptzCameraID, this._context, 'ptz', this); + + const singleStep = async (): Promise => { + this._action.ptz_action && + (await api + .getCameraManager() + .executePTZAction(ptzCameraID, this._action.ptz_action, { + preset: this._action.ptz_preset, + })); + // Only start the timer for the next step after this step returns. + this._timer.start(ptzConfiguration.r2c_delay_between_calls_seconds, singleStep); + }; + + await singleStep(); + } else if (this._action.ptz_phase === 'stop') { + // Scenario: Asked to stop continuous move, camera only supports relative moves natively. + stopInProgressForThisTarget(ptzCameraID, this._context.ptz); + } else { + // Relative move (but camera only supports continuous). + await api + .getCameraManager() + .executePTZAction(ptzCameraID, this._action.ptz_action, { + preset: this._action.ptz_preset, + phase: 'start', + }); + + this._timer.start(ptzConfiguration.c2r_delay_between_calls_seconds, async () => { + this._action.ptz_action && + (await api + .getCameraManager() + .executePTZAction(ptzCameraID, this._action.ptz_action, { + preset: this._action.ptz_preset, + phase: 'stop', + })); + }); + } + } +} diff --git a/src/card-controller/actions/actions/screenshot.ts b/src/card-controller/actions/actions/screenshot.ts new file mode 100644 index 00000000..573b69a6 --- /dev/null +++ b/src/card-controller/actions/actions/screenshot.ts @@ -0,0 +1,9 @@ +import { GeneralActionConfig } from '../../../config/types'; +import { CardActionsAPI } from '../../types'; +import { FrigateCardAction } from './base'; + +export class ScreenshotAction extends FrigateCardAction { + public async execute(api: CardActionsAPI): Promise { + await api.getDownloadManager().downloadScreenshot(); + } +} diff --git a/src/card-controller/actions/actions/set.ts b/src/card-controller/actions/actions/set.ts new file mode 100644 index 00000000..2e87c4c2 --- /dev/null +++ b/src/card-controller/actions/actions/set.ts @@ -0,0 +1,44 @@ +import { ActionContext } from 'action'; +import { ActionType } from '../../../config/types'; +import { arrayify } from '../../../utils/basic'; +import { CardActionsAPI } from '../../types'; +import { ActionFactory } from '../factory'; +import { Action, AuxillaryActionConfig } from '../types'; + +export class ActionSet implements Action { + protected _context: ActionContext; + protected _actions: Action[] = []; + protected _factory = new ActionFactory(); + protected _stopped = false; + + constructor( + context: ActionContext, + actions: ActionType | ActionType[], + options?: { + config?: AuxillaryActionConfig; + cardID?: string; + }, + ) { + this._context = context; + for (const actionObj of arrayify(actions)) { + const action = this._factory.createAction(context, actionObj, options); + if (action) { + this._actions.push(action); + } + } + } + + public async execute(api: CardActionsAPI): Promise { + for (const action of this._actions) { + if (this._stopped) { + break; + } + + await action.execute(api); + } + } + + public async stop(): Promise { + this._stopped = true; + } +} diff --git a/src/card-controller/actions/actions/sleep.ts b/src/card-controller/actions/actions/sleep.ts new file mode 100644 index 00000000..3bf10c68 --- /dev/null +++ b/src/card-controller/actions/actions/sleep.ts @@ -0,0 +1,12 @@ +import { SleepActionConfig } from '../../../config/types'; +import { sleep } from '../../../utils/basic'; +import { CardActionsAPI } from '../../types'; +import { timeDeltaToSeconds } from '../utils/time-delta'; +import { FrigateCardAction } from './base'; + +export class SleepAction extends FrigateCardAction { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async execute(_api: CardActionsAPI): Promise { + await sleep(timeDeltaToSeconds(this._action.duration)); + } +} diff --git a/src/card-controller/actions/actions/substream-off.ts b/src/card-controller/actions/actions/substream-off.ts new file mode 100644 index 00000000..6e84e04a --- /dev/null +++ b/src/card-controller/actions/actions/substream-off.ts @@ -0,0 +1,9 @@ +import { GeneralActionConfig } from '../../../config/types'; +import { CardActionsAPI } from '../../types'; +import { FrigateCardAction } from './base'; + +export class SubstreamOffAction extends FrigateCardAction { + public async execute(api: CardActionsAPI): Promise { + api.getViewManager().setViewWithoutSubstream(); + } +} diff --git a/src/card-controller/actions/actions/substream-on.ts b/src/card-controller/actions/actions/substream-on.ts new file mode 100644 index 00000000..0fd13310 --- /dev/null +++ b/src/card-controller/actions/actions/substream-on.ts @@ -0,0 +1,9 @@ +import { GeneralActionConfig } from '../../../config/types'; +import { CardActionsAPI } from '../../types'; +import { FrigateCardAction } from './base'; + +export class SubstreamOnAction extends FrigateCardAction { + public async execute(api: CardActionsAPI): Promise { + api.getViewManager().setViewWithSubstream(); + } +} diff --git a/src/card-controller/actions/actions/substream-select.ts b/src/card-controller/actions/actions/substream-select.ts new file mode 100644 index 00000000..7935ca5d --- /dev/null +++ b/src/card-controller/actions/actions/substream-select.ts @@ -0,0 +1,9 @@ +import { SubstreamSelectActionConfig } from '../../../config/types'; +import { CardActionsAPI } from '../../types'; +import { FrigateCardAction } from './base'; + +export class SubstreamSelectAction extends FrigateCardAction { + public async execute(api: CardActionsAPI): Promise { + api.getViewManager().setViewWithSubstream(this._action.camera); + } +} diff --git a/src/card-controller/actions/actions/unmute.ts b/src/card-controller/actions/actions/unmute.ts new file mode 100644 index 00000000..81043271 --- /dev/null +++ b/src/card-controller/actions/actions/unmute.ts @@ -0,0 +1,9 @@ +import { GeneralActionConfig } from '../../../config/types'; +import { CardActionsAPI } from '../../types'; +import { FrigateCardAction } from './base'; + +export class UnmuteAction extends FrigateCardAction { + public async execute(api: CardActionsAPI): Promise { + await api.getMediaLoadedInfoManager().get()?.player?.unmute(); + } +} diff --git a/src/card-controller/actions/actions/view.ts b/src/card-controller/actions/actions/view.ts new file mode 100644 index 00000000..045b65de --- /dev/null +++ b/src/card-controller/actions/actions/view.ts @@ -0,0 +1,16 @@ +import { ViewActionConfig } from '../../../config/types'; +import { CardActionsAPI } from '../../types'; +import { FrigateCardAction } from './base'; + +export class ViewAction extends FrigateCardAction { + public async execute(api: CardActionsAPI): Promise { + api.getViewManager().setViewByParameters({ + viewName: this._action.frigate_card_action, + + // Note: This function needs to process (view-related) commands even when + // _view has not yet been initialized (since it may be used to set a view + // via the querystring). + cameraID: api.getViewManager().getView()?.camera, + }); + } +} diff --git a/src/card-controller/actions/factory.ts b/src/card-controller/actions/factory.ts new file mode 100644 index 00000000..0548ab0c --- /dev/null +++ b/src/card-controller/actions/factory.ts @@ -0,0 +1,133 @@ +import { ActionConfig } from '@dermotduffy/custom-card-helpers'; +import { ActionContext } from 'action'; +import { ActionType } from '../../config/types'; +import { convertActionToCardCustomAction } from '../../utils/action'; +import { CameraSelectAction } from './actions/camera-select'; +import { CameraUIAction } from './actions/camera-ui'; +import { DefaultAction } from './actions/default'; +import { DisplayModeSelectAction } from './actions/display-mode-select'; +import { DownloadAction } from './actions/download'; +import { ExpandAction } from './actions/expand'; +import { FullscreenAction } from './actions/fullscreen'; +import { GenericAction } from './actions/generic'; +import { LogAction } from './actions/log'; +import { MediaPlayerAction } from './actions/media-player'; +import { MenuToggleAction } from './actions/menu-toggle'; +import { MicrophoneMuteAction } from './actions/microphone-mute'; +import { MicrophoneUnmuteAction } from './actions/microphone-unmute'; +import { MuteAction } from './actions/mute'; +import { PauseAction } from './actions/pause'; +import { PlayAction } from './actions/play'; +import { PTZAction } from './actions/ptz'; +import { PTZDigitalAction } from './actions/ptz-digital'; +import { PTZMultiAction } from './actions/ptz-multi'; +import { ScreenshotAction } from './actions/screenshot'; +import { PTZControlsAction } from './actions/ptz-controls'; +import { SleepAction } from './actions/sleep'; +import { SubstreamOffAction } from './actions/substream-off'; +import { SubstreamOnAction } from './actions/substream-on'; +import { SubstreamSelectAction } from './actions/substream-select'; +import { UnmuteAction } from './actions/unmute'; +import { ViewAction } from './actions/view'; +import { Action, AuxillaryActionConfig } from './types'; + +export class ActionFactory { + public createAction( + context: ActionContext, + action: ActionType, + options?: { + config?: AuxillaryActionConfig; + cardID?: string; + }, + ): Action | null { + const frigateCardAction = convertActionToCardCustomAction(action); + if (action.action !== 'fire-dom-event' || !frigateCardAction) { + // * There is a slight typing (but not functional) difference between + // ActionType in this card and ActionConfig in `custom-card-helpers`. See + // `ExtendedConfirmationRestrictionConfig` in `types.ts` for the source and + // reason behind this difference. + return new GenericAction(context, action as ActionConfig, options?.config); + } + + if ( + // Command not intended for this card (e.g. query string command). + frigateCardAction.card_id && + frigateCardAction.card_id !== options?.cardID + ) { + return null; + } + + switch (frigateCardAction.frigate_card_action) { + case 'default': + return new DefaultAction(context, frigateCardAction, options?.config); + case 'clip': + case 'clips': + case 'image': + case 'live': + case 'recording': + case 'recordings': + case 'snapshot': + case 'snapshots': + case 'timeline': + case 'diagnostics': + return new ViewAction(context, frigateCardAction, options?.config); + case 'sleep': + return new SleepAction(context, frigateCardAction, options?.config); + case 'download': + return new DownloadAction(context, frigateCardAction, options?.config); + case 'camera_ui': + return new CameraUIAction(context, frigateCardAction, options?.config); + case 'expand': + return new ExpandAction(context, frigateCardAction, options?.config); + case 'fullscreen': + return new FullscreenAction(context, frigateCardAction, options?.config); + case 'menu_toggle': + // This is a rare code path: this would only be used if someone has a + // menu toggle action configured outside of the menu itself. + return new MenuToggleAction(context, frigateCardAction, options?.config); + case 'camera_select': + return new CameraSelectAction(context, frigateCardAction, options?.config); + case 'live_substream_select': + return new SubstreamSelectAction(context, frigateCardAction, options?.config); + case 'live_substream_off': + return new SubstreamOffAction(context, frigateCardAction, options?.config); + case 'live_substream_on': + return new SubstreamOnAction(context, frigateCardAction, options?.config); + case 'media_player': + return new MediaPlayerAction(context, frigateCardAction, options?.config); + case 'microphone_mute': + return new MicrophoneMuteAction(context, frigateCardAction, options?.config); + case 'microphone_unmute': + return new MicrophoneUnmuteAction(context, frigateCardAction, options?.config); + case 'mute': + return new MuteAction(context, frigateCardAction, options?.config); + case 'unmute': + return new UnmuteAction(context, frigateCardAction, options?.config); + case 'play': + return new PlayAction(context, frigateCardAction, options?.config); + case 'pause': + return new PauseAction(context, frigateCardAction, options?.config); + case 'screenshot': + return new ScreenshotAction(context, frigateCardAction, options?.config); + case 'display_mode_select': + return new DisplayModeSelectAction(context, frigateCardAction, options?.config); + case 'ptz': + return new PTZAction(context, frigateCardAction, options?.config); + case 'ptz_digital': + return new PTZDigitalAction(context, frigateCardAction, options?.config); + case 'ptz_multi': + return new PTZMultiAction(context, frigateCardAction, options?.config); + case 'ptz_controls': + return new PTZControlsAction(context, frigateCardAction, options?.config); + case 'log': + return new LogAction(context, frigateCardAction, options?.config); + } + + /* istanbul ignore next: this path cannot be reached -- @preserve */ + console.warn( + `Frigate card received unknown card action: ${frigateCardAction['frigate_card_action']}`, + ); + /* istanbul ignore next: this path cannot be reached -- @preserve */ + return null; + } +} diff --git a/src/card-controller/actions/types.ts b/src/card-controller/actions/types.ts new file mode 100644 index 00000000..5eeb4f0f --- /dev/null +++ b/src/card-controller/actions/types.ts @@ -0,0 +1,23 @@ +import { ActionType } from "../../config/types"; +import { CardActionsAPI } from "../types"; + +export interface AuxillaryActionConfig { + camera_image?: string; + entity?: string; +} + +export interface Action { + execute(api: CardActionsAPI): Promise; + stop(): Promise; +} + +export interface ActionExecutionRequest { + action: ActionType[] | ActionType; + config?: AuxillaryActionConfig; +} + +export interface TargetedActionContext { + [targetID: string]: { + inProgressAction?: Action; + }; +} diff --git a/src/card-controller/actions/utils/action-state.ts b/src/card-controller/actions/utils/action-state.ts new file mode 100644 index 00000000..3884800f --- /dev/null +++ b/src/card-controller/actions/utils/action-state.ts @@ -0,0 +1,25 @@ +import merge from 'lodash-es/merge'; +import { Action, TargetedActionContext } from '../types'; +import { ActionContext } from 'action'; + +export const stopInProgressForThisTarget = ( + targetID: string, + context?: TargetedActionContext, +): void => { + context?.[targetID]?.inProgressAction?.stop(); +}; + +export const setInProgressForThisTarget = ( + targetID: string, + context: ActionContext, + contextKey: keyof ActionContext, + action: Action, +) => { + merge(context, { + [contextKey]: { + [targetID]: { + inProgressAction: action, + }, + }, + }); +}; diff --git a/src/card-controller/actions/utils/execution-request.ts b/src/card-controller/actions/utils/execution-request.ts new file mode 100644 index 00000000..39ee47bc --- /dev/null +++ b/src/card-controller/actions/utils/execution-request.ts @@ -0,0 +1,38 @@ +import { dispatchFrigateCardEvent } from '../../../utils/basic'; +import { ActionExecutionRequest } from '../types'; + +export const dispatchActionExecutionRequest = ( + element: HTMLElement, + request: ActionExecutionRequest, +) => { + dispatchFrigateCardEvent(element, 'action:execution-request', request); +}; + +export interface ActionExecutionRequestEventTarget extends EventTarget { + addEventListener( + event: 'frigate-card:action:execution-request', + listener: ( + this: ActionExecutionRequestEventTarget, + ev: CustomEvent, + ) => void, + options?: AddEventListenerOptions | boolean, + ): void; + addEventListener( + type: string, + callback: EventListenerOrEventListenerObject, + options?: AddEventListenerOptions | boolean, + ): void; + removeEventListener( + event: 'frigate-card:action:execution-request', + listener: ( + this: ActionExecutionRequestEventTarget, + ev: CustomEvent, + ) => void, + options?: boolean | EventListenerOptions, + ): void; + removeEventListener( + type: string, + callback: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ): void; +} diff --git a/src/card-controller/actions/utils/time-delta.ts b/src/card-controller/actions/utils/time-delta.ts new file mode 100644 index 00000000..ac064acd --- /dev/null +++ b/src/card-controller/actions/utils/time-delta.ts @@ -0,0 +1,10 @@ +import { TimeDelta } from '../../../config/types'; + +export const timeDeltaToSeconds = (timeDelta: TimeDelta): number => { + return ( + (timeDelta.h ?? 0) * 3600 + + (timeDelta.m ?? 0) * 60 + + (timeDelta.s ?? 0) + + (timeDelta.ms ?? 0) / 1000 + ); +}; diff --git a/src/card-controller/automations-manager.ts b/src/card-controller/automations-manager.ts index 1da364d8..dbc38abb 100644 --- a/src/card-controller/automations-manager.ts +++ b/src/card-controller/automations-manager.ts @@ -1,14 +1,13 @@ -import { Automation, AutomationActions, Automations } from '../config/types.js'; +import { Automation, AutomationActions } from '../config/types.js'; import { localize } from '../localize/localize.js'; -import { frigateCardHandleAction } from '../utils/action.js'; -import { CardAutomationsAPI } from './types.js'; +import { CardAutomationsAPI, TaggedAutomations } from './types.js'; const MAX_NESTED_AUTOMATION_EXECUTIONS = 10; export class AutomationsManager { protected _api: CardAutomationsAPI; - protected _automations: Automations; + protected _automations: TaggedAutomations = []; protected _priorEvaluations: Map = new Map(); // A counter to avoid infinite loops, increases every time actions are run, @@ -19,10 +18,12 @@ export class AutomationsManager { this._api = api; } - public setAutomationsFromConfig() { - this._automations = this._api - .getConfigManager() - .getNonOverriddenConfig()?.automations; + public deleteAutomations(tag?: unknown) { + this._automations = this._automations.filter((automation) => automation.tag !== tag); + } + + public addAutomations(automations: TaggedAutomations): void { + this._automations.push(...automations); } public execute(): void { @@ -34,8 +35,8 @@ export class AutomationsManager { return; } - const actionsToRun: AutomationActions[] = []; - for (const automation of this._automations ?? []) { + const actionsToRun: AutomationActions = []; + for (const automation of this._automations) { const shouldExecute = this._api .getConditionsManager() .evaluateConditions(automation.conditions); @@ -43,27 +44,27 @@ export class AutomationsManager { const priorEvaluation = this._priorEvaluations.get(automation); this._priorEvaluations.set(automation, shouldExecute); if (shouldExecute !== priorEvaluation && actions) { - actionsToRun.push(actions); + actionsToRun.push(...actions); } } - ++this._nestedAutomationExecutions; - if (this._nestedAutomationExecutions > MAX_NESTED_AUTOMATION_EXECUTIONS) { - this._api.getMessageManager().setMessageIfHigherPriority({ - type: 'error', - message: localize('error.too_many_automations'), - }); + if (!actionsToRun.length) { return; } - actionsToRun.forEach((actions) => { - frigateCardHandleAction( - this._api.getCardElementManager().getElement(), - hass, - {}, - actions, - ); - }); - --this._nestedAutomationExecutions; + const runActions = async (actions: AutomationActions): Promise => { + ++this._nestedAutomationExecutions; + if (this._nestedAutomationExecutions > MAX_NESTED_AUTOMATION_EXECUTIONS) { + this._api.getMessageManager().setMessageIfHigherPriority({ + type: 'error', + message: localize('error.too_many_automations'), + }); + return; + } + + await this._api.getActionsManager().executeActions(actions); + --this._nestedAutomationExecutions; + }; + runActions(actionsToRun); } } diff --git a/src/card-controller/card-element-manager.ts b/src/card-controller/card-element-manager.ts index c586173e..6cc3a462 100644 --- a/src/card-controller/card-element-manager.ts +++ b/src/card-controller/card-element-manager.ts @@ -4,11 +4,12 @@ import { setOrRemoveAttribute } from '../utils/basic'; import { isCardInPanel } from '../utils/ha'; import { InitializationAspect } from './initialization-manager'; import { CardElementAPI } from './types'; +import { ActionExecutionRequestEventTarget } from './actions/utils/execution-request'; export type ScrollCallback = () => void; export type MenuToggleCallback = () => void; -export type CardHTMLElement = LitElement & ReactiveControllerHost & ActionEventTarget; +export type CardHTMLElement = LitElement & ReactiveControllerHost & ActionEventTarget & ActionExecutionRequestEventTarget; export class CardElementManager { protected _api: CardElementAPI; @@ -62,19 +63,21 @@ export class CardElementManager { this._api.getExpandManager().initialize(); this._api.getMediaLoadedInfoManager().initialize(); this._api.getMicrophoneManager().initialize(); + this._api.getKeyboardStateManager().initialize(); // Whether or not the card is in panel mode on the dashboard. setOrRemoveAttribute(this._element, isCardInPanel(this._element), 'panel'); + setOrRemoveAttribute(this._element, true, 'tabindex', '0'); this._api.getFullscreenManager().connect(); - + this._element.addEventListener( 'mousemove', this._api.getInteractionManager().reportInteraction, ); this._element.addEventListener( 'll-custom', - this._api.getActionsManager().handleActionEvent, + this._api.getActionsManager().handleCustomActionEvent, ); this._element.addEventListener( 'action', @@ -84,6 +87,10 @@ export class CardElementManager { 'action', this._api.getInteractionManager().reportInteraction, ); + this._element.addEventListener( + 'frigate-card:action:execution-request', + this._api.getActionsManager().handleActionExecutionRequestEvent, + ); // Listen for HA `navigate` actions. // See: https://github.com/home-assistant/frontend/blob/273992c8e9c3062c6e49481b6d7d688a07067232/src/common/navigate.ts#L43 @@ -106,23 +113,25 @@ export class CardElementManager { public elementDisconnected(): void { setOrRemoveAttribute(this._element, false, 'panel'); + setOrRemoveAttribute(this._element, false, 'tabindex'); // When the dashboard 'tab' is changed, the media is effectively unloaded. this._api.getMediaLoadedInfoManager().clear(); this._api.getFullscreenManager().disconnect(); + this._api.getKeyboardStateManager().uninitialize(); + this._api.getActionsManager().uninitialize(); // Uninitialize cameras to cause them to reinitialize on // reconnection, to ensure the state subscription/unsubscription works // correctly for triggers. this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS), - - this._element.removeEventListener( - 'mousemove', - this._api.getInteractionManager().reportInteraction, - ); + this._element.removeEventListener( + 'mousemove', + this._api.getInteractionManager().reportInteraction, + ); this._element.removeEventListener( 'll-custom', - this._api.getActionsManager().handleActionEvent, + this._api.getActionsManager().handleCustomActionEvent, ); this._element.removeEventListener( 'action', @@ -132,6 +141,10 @@ export class CardElementManager { 'action', this._api.getInteractionManager().reportInteraction, ); + this._element.removeEventListener( + 'frigate-card:action:execution-request', + this._api.getActionsManager().handleActionExecutionRequestEvent, + ); window.removeEventListener( 'location-changed', diff --git a/src/card-controller/conditions-manager.ts b/src/card-controller/conditions-manager.ts index 4f8b243e..641a8e71 100644 --- a/src/card-controller/conditions-manager.ts +++ b/src/card-controller/conditions-manager.ts @@ -16,7 +16,7 @@ import { Overrides, } from '../config/types'; import { desparsifyArrays } from '../utils/basic'; -import { CardConditionAPI } from './types'; +import { CardConditionAPI, KeysState } from './types'; interface MicrophoneConditionState { connected?: boolean; @@ -35,6 +35,7 @@ interface ConditionState { interaction?: boolean; microphone?: MicrophoneConditionState; user?: CurrentUser; + keys?: KeysState; } export class ConditionsEvaluateRequestEvent extends Event { @@ -190,7 +191,9 @@ export class ConditionsManager { const config = this._api.getConfigManager().getConfig(); const conditions: FrigateCardCondition[] = []; config?.overrides?.forEach((override) => conditions.push(...override.conditions)); - config?.automations?.forEach((automation) => conditions.push(...automation.conditions)); + config?.automations?.forEach((automation) => + conditions.push(...automation.conditions), + ); // Element conditions can be arbitrarily nested underneath conditionals and // custom elements that this card may not known. Here we recursively parse @@ -323,6 +326,20 @@ export class ConditionsManager { (conditionObj.muted === undefined || state.microphone?.muted === conditionObj.muted) ); + case 'key': + return ( + !!state.keys && + conditionObj.key in state.keys && + (conditionObj.state ?? 'down') === state.keys[conditionObj.key].state && + (conditionObj.ctrl === undefined || + conditionObj.ctrl === !!state.keys[conditionObj.key].ctrl) && + (conditionObj.alt === undefined || + conditionObj.alt === !!state.keys[conditionObj.key].alt) && + (conditionObj.meta === undefined || + conditionObj.meta === !!state.keys[conditionObj.key].meta) && + (conditionObj.shift === undefined || + conditionObj.shift === !!state.keys[conditionObj.key].shift) + ); } } diff --git a/src/card-controller/config-manager.ts b/src/card-controller/config/config-manager.ts similarity index 85% rename from src/card-controller/config-manager.ts rename to src/card-controller/config/config-manager.ts index 947b530b..a2b3fa5e 100644 --- a/src/card-controller/config-manager.ts +++ b/src/card-controller/config/config-manager.ts @@ -1,17 +1,19 @@ import isEqual from 'lodash-es/isEqual'; -import { isConfigUpgradeable } from '../config/management'; +import { isConfigUpgradeable } from '../../config/management.js'; import { CardWideConfig, FrigateCardConfig, frigateCardConfigSchema, RawFrigateCardConfig, -} from '../config/types'; -import { localize } from '../localize/localize'; -import { setProfiles } from '../config/profiles'; -import { getParseErrorPaths } from '../utils/zod.js'; -import { getOverriddenConfig } from './conditions-manager'; -import { InitializationAspect } from './initialization-manager'; -import { CardConfigAPI } from './types'; +} from '../../config/types.js'; +import { localize } from '../../localize/localize.js'; +import { setProfiles } from '../../config/profiles/index.js'; +import { getParseErrorPaths } from '../../utils/zod.js'; +import { getOverriddenConfig } from '../conditions-manager.js'; +import { InitializationAspect } from '../initialization-manager.js'; +import { CardConfigAPI } from '../types.js'; +import { setAutomationsFromConfig } from './load-automations.js'; +import { setKeyboardShortcutsFromConfig } from './load-keyboard-shortcuts.js'; export class ConfigManager { protected _api: CardConfigAPI; @@ -92,9 +94,10 @@ export class ConfigManager { this._api.getMediaLoadedInfoManager().clear(); this._api.getViewManager().reset(); this._api.getMessageManager().reset(); - this._api.getAutomationsManager().setAutomationsFromConfig(); this._api.getStyleManager().setPerformance(); this._api.getCardElementManager().update(); + setKeyboardShortcutsFromConfig(this._api, this); + setAutomationsFromConfig(this._api); this.computeOverrideConfig(); } diff --git a/src/card-controller/config/load-automations.ts b/src/card-controller/config/load-automations.ts new file mode 100644 index 00000000..8c6b7ffa --- /dev/null +++ b/src/card-controller/config/load-automations.ts @@ -0,0 +1,8 @@ +import { CardConfigLoaderAPI } from '../types'; + +export const setAutomationsFromConfig = (api: CardConfigLoaderAPI) => { + api.getAutomationsManager().deleteAutomations(); + api + .getAutomationsManager() + .addAutomations(api.getConfigManager().getNonOverriddenConfig()?.automations ?? []); +}; diff --git a/src/card-controller/config/load-keyboard-shortcuts.ts b/src/card-controller/config/load-keyboard-shortcuts.ts new file mode 100644 index 00000000..e8f8f895 --- /dev/null +++ b/src/card-controller/config/load-keyboard-shortcuts.ts @@ -0,0 +1,130 @@ +import { + KeyboardShortcuts, + PTZKeyboardShortcutName, +} from '../../config/keyboard-shortcuts'; +import { PTZAction } from '../../config/ptz'; +import { CardConfigLoaderAPI, TaggedAutomations } from '../types'; +import { createPTZMultiAction } from '../../utils/action'; + +export const setKeyboardShortcutsFromConfig = ( + api: CardConfigLoaderAPI, + tag: unknown, +) => { + api.getAutomationsManager().deleteAutomations(tag); + + const shortcuts = api.getConfigManager().getConfig()?.view.keyboard_shortcuts; + if (!shortcuts) { + return; + } + + const automations = convertKeyboardShortcutsToAutomations(tag, shortcuts); + if (automations.length) { + api.getAutomationsManager().addAutomations(automations); + } +}; + +const ptzKeyboardShortcutToPTZAction = ( + ptzKbs: PTZKeyboardShortcutName, +): PTZAction | null => { + switch (ptzKbs) { + case 'ptz_left': + return 'left'; + case 'ptz_right': + return 'right'; + case 'ptz_up': + return 'up'; + case 'ptz_down': + return 'down'; + case 'ptz_zoom_in': + return 'zoom_in'; + case 'ptz_zoom_out': + return 'zoom_out'; + } + /* istanbul ignore next: No (current) way to reach this code -- @preserve */ + return null; +}; + +const convertKeyboardShortcutsToAutomations = ( + tag: unknown, + shortcuts: KeyboardShortcuts, +): TaggedAutomations => { + if (!shortcuts.enabled) { + return []; + } + + const automations: TaggedAutomations = []; + + for (const name of [ + 'ptz_down', + 'ptz_left', + 'ptz_right', + 'ptz_up', + 'ptz_zoom_in', + 'ptz_zoom_out', + ] as const) { + const shortcut = shortcuts[name]; + const ptzAction = ptzKeyboardShortcutToPTZAction(name); + if (!shortcut || !ptzAction) { + continue; + } + + automations.push({ + conditions: [ + { + condition: 'key' as const, + key: shortcut.key, + state: 'down', + shift: shortcut.shift, + ctrl: shortcut.ctrl, + alt: shortcut.alt, + meta: shortcut.meta, + }, + ], + actions: [ + createPTZMultiAction({ + ptzAction: ptzAction, + ptzPhase: 'start', + }), + ], + tag: tag, + }); + + automations.push({ + conditions: [ + { + condition: 'key' as const, + key: shortcut.key, + state: 'up', + }, + ], + actions: [ + createPTZMultiAction({ + ptzAction: ptzAction, + ptzPhase: 'stop', + }), + ], + tag: tag, + }); + } + + const homeShortcut = shortcuts.ptz_home; + if (homeShortcut) { + automations.push({ + conditions: [ + { + condition: 'key' as const, + key: homeShortcut.key, + state: 'down', + shift: homeShortcut.shift, + ctrl: homeShortcut.ctrl, + alt: homeShortcut.alt, + meta: homeShortcut.meta, + }, + ], + actions: [createPTZMultiAction()], + tag: tag, + }); + } + + return automations; +}; diff --git a/src/card-controller/controller.ts b/src/card-controller/controller.ts index e44e6d8a..ca69fff8 100644 --- a/src/card-controller/controller.ts +++ b/src/card-controller/controller.ts @@ -5,7 +5,7 @@ import { FrigateCardConfig } from '../config/types'; import { EntityRegistryManager } from '../utils/ha/entity-registry'; import { EntityCache } from '../utils/ha/entity-registry/cache'; import { ResolvedMediaCache } from '../utils/ha/resolved-media'; -import { ActionsManager } from './actions-manager'; +import { ActionsManager } from './actions/actions-manager'; import { AutoUpdateManager } from './auto-update-manager'; import { AutomationsManager } from './automations-manager'; import { CameraURLManager } from './camera-url-manager'; @@ -16,7 +16,7 @@ import { ScrollCallback, } from './card-element-manager'; import { ConditionsManager, ConditionsManagerListener } from './conditions-manager'; -import { ConfigManager } from './config-manager'; +import { ConfigManager } from './config/config-manager'; import { DownloadManager } from './download-manager'; import { ExpandManager } from './expand-manager'; import { FullscreenManager } from './fullscreen-manager'; @@ -45,6 +45,7 @@ import { CardHASSAPI, CardInitializerAPI, CardInteractionAPI, + CardKeyboardStateAPI, CardMediaLoadedAPI, CardMediaPlayerAPI, CardMessageAPI, @@ -55,6 +56,7 @@ import { CardViewAPI, } from './types'; import { ViewManager } from './view-manager'; +import { KeyboardStateManager } from './keyboard-state-manager'; export class CardController implements @@ -72,6 +74,7 @@ export class CardController CardHASSAPI, CardInitializerAPI, CardInteractionAPI, + CardKeyboardStateAPI, CardMediaLoadedAPI, CardMediaPlayerAPI, CardMessageAPI, @@ -101,6 +104,7 @@ export class CardController protected _hassManager = new HASSManager(this); protected _initializationManager = new InitializationManager(this); protected _interactionManager = new InteractionManager(this); + protected _keyboardStateManager = new KeyboardStateManager(this); protected _mediaLoadedInfoManager = new MediaLoadedInfoManager(this); protected _mediaPlayerManager = new MediaPlayerManager(this); protected _messageManager = new MessageManager(this); @@ -195,6 +199,10 @@ export class CardController return this._interactionManager; } + public getKeyboardStateManager(): KeyboardStateManager { + return this._keyboardStateManager; + } + public getMediaLoadedInfoManager(): MediaLoadedInfoManager { return this._mediaLoadedInfoManager; } diff --git a/src/card-controller/keyboard-state-manager.ts b/src/card-controller/keyboard-state-manager.ts new file mode 100644 index 00000000..53a24a6c --- /dev/null +++ b/src/card-controller/keyboard-state-manager.ts @@ -0,0 +1,59 @@ +import { CardKeyboardStateAPI, KeysState } from './types'; +import isEqual from 'lodash/isEqual'; + +export class KeyboardStateManager { + protected _api: CardKeyboardStateAPI; + protected _state: KeysState = {}; + + constructor(api: CardKeyboardStateAPI) { + this._api = api; + } + + public initialize(): void { + const element = this._api.getCardElementManager().getElement(); + element.addEventListener('keydown', this._handleKeydown); + element.addEventListener('keyup', this._handleKeyup); + element.addEventListener('blur', this._handleBlur); + } + + public uninitialize(): void { + const element = this._api.getCardElementManager().getElement(); + element.removeEventListener('keydown', this._handleKeydown); + element.removeEventListener('keyup', this._handleKeyup); + element.removeEventListener('blur', this._handleBlur); + } + + protected _handleKeydown = (ev: KeyboardEvent): void => { + const keyObj = { + state: 'down' as const, + ctrl: ev.ctrlKey, + alt: ev.altKey, + meta: ev.metaKey, + shift: ev.shiftKey, + }; + + if (!isEqual(this._state[ev.key], keyObj)) { + this._state[ev.key] = keyObj; + this._processStateChange(); + } + }; + + protected _handleKeyup = (ev: KeyboardEvent): void => { + if (ev.key in this._state && this._state[ev.key].state === 'down') { + this._state[ev.key].state = 'up'; + this._processStateChange(); + } + }; + + protected _handleBlur = (): void => { + if (Object.keys(this._state).length) { + // State is emptied if the element loses focus. + this._state = {}; + this._processStateChange(); + } + }; + + protected _processStateChange(): void { + this._api.getConditionsManager().setState({ keys: this._state }); + } +} diff --git a/src/card-controller/query-string-manager.ts b/src/card-controller/query-string-manager.ts index f118f3be..15d3a1a6 100644 --- a/src/card-controller/query-string-manager.ts +++ b/src/card-controller/query-string-manager.ts @@ -1,8 +1,5 @@ -import { FrigateCardCustomAction, FrigateCardViewAction } from '../config/types'; -import { - createFrigateCardCameraAction, - createFrigateCardSimpleAction -} from '../utils/action.js'; +import { FrigateCardCustomAction, ViewActionConfig } from '../config/types'; +import { createCameraAction, createGeneralAction } from '../utils/action.js'; import { CardQueryStringAPI } from './types'; import { ViewManagerSetViewParameters } from './view-manager'; @@ -56,14 +53,15 @@ export class QueryStringManager { } protected _executeNonViewRelated(intent: QueryStringViewIntent): void { - // Only execute non-view actions when the card has rendered at least once. - if (!this._api.getCardElementManager().hasUpdated()) { + if ( + // Only execute non-view actions when the card has rendered at least once. + !this._api.getCardElementManager().hasUpdated() || + !intent.other?.length + ) { return; } - intent.other?.forEach((action) => - this._api.getActionsManager().executeFrigateAction(action), - ); + this._api.getActionsManager().executeActions(intent.other); } protected _calculateIntent(): QueryStringViewIntent { @@ -105,7 +103,7 @@ export class QueryStringManager { case 'camera_select': case 'live_substream_select': if (value) { - customAction = createFrigateCardCameraAction(action, value, { + customAction = createCameraAction(action, value, { cardID: cardID, }); } @@ -125,7 +123,7 @@ export class QueryStringManager { case 'snapshot': case 'snapshots': case 'timeline': - customAction = createFrigateCardSimpleAction(action, { + customAction = createGeneralAction(action, { cardID: cardID, }); break; @@ -143,7 +141,7 @@ export class QueryStringManager { protected _isViewAction = ( action: FrigateCardCustomAction, - ): action is FrigateCardViewAction => { + ): action is ViewActionConfig => { switch (action.frigate_card_action) { case 'clip': case 'clips': diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index 33c4df07..d289f9c5 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -2,12 +2,12 @@ import type { CameraManager } from '../camera-manager/manager'; import type { ConditionsManager } from './conditions-manager'; import type { EntityRegistryManager } from '../utils/ha/entity-registry'; import type { ResolvedMediaCache } from '../utils/ha/resolved-media'; -import type { ActionsManager } from './actions-manager'; +import type { ActionsManager } from './actions/actions-manager'; import type { AutoUpdateManager } from './auto-update-manager'; import type { AutomationsManager } from './automations-manager'; import type { CameraURLManager } from './camera-url-manager'; import type { CardElementManager } from './card-element-manager'; -import type { ConfigManager } from './config-manager'; +import type { ConfigManager } from './config/config-manager'; import type { DownloadManager } from './download-manager'; import type { ExpandManager } from './expand-manager'; import type { FullscreenManager } from './fullscreen-manager'; @@ -22,17 +22,22 @@ import type { StyleManager } from './style-manager'; import type { TriggersManager } from './triggers-manager'; import type { ViewManager } from './view-manager'; import type { QueryStringManager } from './query-string-manager'; +import { KeyboardStateManager } from './keyboard-state-manager'; +import { Automation } from '../config/types'; -/** - * This defines a series of limited APIs that various manager helpers use to - * control the card. Explicitly specifying them helps make coupling intentional - * and avoids cyclic importing. - */ +// ************************************************************************* +// Manager APIs +// This defines a series of limited APIs that various managers use to control +// the card. Explicitly specifying them helps make coupling intentional and +// reduce cyclic dependencies. +// ************************************************************************* -export interface CardActionsManagerAPI { +export interface CardActionsAPI { + getActionsManager(): ActionsManager; getCameraManager(): CameraManager; getCameraURLManager(): CameraURLManager; getCardElementManager(): CardElementManager; + getConditionsManager(): ConditionsManager; getConfigManager(): ConfigManager; getDownloadManager(): DownloadManager; getExpandManager(): ExpandManager; @@ -45,11 +50,12 @@ export interface CardActionsManagerAPI { getTriggersManager(): TriggersManager; getViewManager(): ViewManager; } +export type CardActionsManagerAPI = CardActionsAPI; export interface CardAutomationsAPI { + getActionsManager(): ActionsManager; getCardElementManager(): CardElementManager; getConditionsManager(): ConditionsManager; - getConfigManager(): ConfigManager; getHASSManager(): HASSManager; getMessageManager(): MessageManager; } @@ -62,6 +68,7 @@ export interface CardAutoRefreshAPI { } export interface CardCameraAPI { + getActionsManager(): ActionsManager; getConfigManager(): ConfigManager; getEntityRegistryManager(): EntityRegistryManager; getHASSManager(): HASSManager; @@ -84,6 +91,7 @@ export interface CardConfigAPI { getAutomationsManager(): AutomationsManager; getCardElementManager(): CardElementManager; getConditionsManager(): ConditionsManager; + getConfigManager(): ConfigManager; getInitializationManager(): InitializationManager; getMediaLoadedInfoManager(): MediaLoadedInfoManager; getMessageManager(): MessageManager; @@ -91,6 +99,11 @@ export interface CardConfigAPI { getViewManager(): ViewManager; } +export interface CardConfigLoaderAPI { + getConfigManager(): ConfigManager; + getAutomationsManager(): AutomationsManager; +} + export interface CardDownloadAPI { getCameraManager(): CameraManager; getHASSManager(): HASSManager; @@ -106,6 +119,7 @@ export interface CardElementAPI { getFullscreenManager(): FullscreenManager; getInitializationManager(): InitializationManager; getInteractionManager(): InteractionManager; + getKeyboardStateManager(): KeyboardStateManager; getMediaLoadedInfoManager(): MediaLoadedInfoManager; getMicrophoneManager(): MicrophoneManager; getQueryStringManager(): QueryStringManager; @@ -159,6 +173,12 @@ export interface CardInteractionAPI { getViewManager(): ViewManager; } +export interface CardKeyboardStateAPI { + getCardElementManager(): CardElementManager; + getConditionsManager(): ConditionsManager; + getConfigManager(): ConfigManager; +} + export interface CardMediaLoadedAPI { getCardElementManager(): CardElementManager; getConditionsManager(): ConditionsManager; @@ -224,3 +244,21 @@ export interface CardViewAPI { getStyleManager(): StyleManager; getTriggersManager(): TriggersManager; } + +// ************************************************************************* +// Common Types +// ************************************************************************* + +export interface KeysState { + [key: string]: { + state: 'down' | 'up'; + ctrl: boolean; + shift: boolean; + alt: boolean; + meta: boolean; + } +} +interface TaggedAutomation extends Automation { + tag?: unknown; +} +export type TaggedAutomations = TaggedAutomation[]; diff --git a/src/card.ts b/src/card.ts index c6b49571..350fde22 100644 --- a/src/card.ts +++ b/src/card.ts @@ -284,6 +284,7 @@ class FrigateCard extends LitElement { @frigate-card:media:pause=${ () => this.requestUpdate() /* Refresh play/pause menu button */ } + @frigate-card:focus=${() => this.focus()} > ${renderMenuAbove ? this._renderMenu() : ''}
diff --git a/src/components-lib/key-assigner-controller.ts b/src/components-lib/key-assigner-controller.ts new file mode 100644 index 00000000..c9e628cb --- /dev/null +++ b/src/components-lib/key-assigner-controller.ts @@ -0,0 +1,83 @@ +import { LitElement, ReactiveController } from 'lit'; +import isEqual from 'lodash-es/isEqual'; +import { KeyboardShortcut } from '../config/keyboard-shortcuts'; +import { setOrRemoveAttribute } from '../utils/basic'; + +export class KeyAssignerController implements ReactiveController { + protected _host: LitElement; + protected _assigning = false; + protected _value: KeyboardShortcut | null = null; + + constructor(host: LitElement) { + this._host = host; + this._host.addController(this); + } + + public setValue(value: KeyboardShortcut | null): void { + if (!isEqual(value, this._value)) { + this._value = value; + this._host.requestUpdate(); + + this._host.dispatchEvent( + new CustomEvent('value-changed', { + detail: { + value: this._value, + }, + }), + ); + } + } + public getValue(): KeyboardShortcut | null { + return this._value; + } + public hasValue(): boolean { + return !!this._value; + } + + public isAssigning(): boolean { + return this._assigning; + } + public toggleAssigning(): void { + this._setAssigning(!this._assigning); + } + protected _setAssigning(assigning: boolean): void { + this._assigning = assigning; + setOrRemoveAttribute(this._host, this._assigning, 'assigning'); + + if (this._assigning) { + this._host.addEventListener('keydown', this._keydownEventHandler); + } else { + this._host.removeEventListener('keydown', this._keydownEventHandler); + } + + this._host.requestUpdate(); + } + + protected _blurEventHandler = (): void => { + this._setAssigning(false); + }; + + protected _keydownEventHandler = (ev: KeyboardEvent): void => { + // Don't allow _only_ a modifier. + if (!ev.key || ['Control', 'Alt', 'Shift', 'Meta'].includes(ev.key)) { + return; + } + + this.setValue({ + key: ev.key, + ctrl: ev.ctrlKey, + alt: ev.altKey, + shift: ev.shiftKey, + meta: ev.metaKey, + }); + this._setAssigning(false); + }; + + public hostConnected(): void { + this._host.addEventListener('blur', this._blurEventHandler); + } + + public hostDisconnected(): void { + this._host.removeEventListener('blur', this._blurEventHandler); + } +} diff --git a/src/components-lib/live/live-controller.ts b/src/components-lib/live/live-controller.ts index b7cd577f..7a63055a 100644 --- a/src/components-lib/live/live-controller.ts +++ b/src/components-lib/live/live-controller.ts @@ -20,7 +20,6 @@ interface LiveViewContext { // camera to be live rather than the camera selected in the view). overrides?: Map; - ptzVisible?: boolean; fetchThumbnails?: boolean; } diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index 5fa25a9a..0c4983c0 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -14,17 +14,17 @@ import { FRIGATE_BUTTON_MENU_ICON } from '../const'; import { localize } from '../localize/localize.js'; import { MediaLoadedInfo } from '../types'; import { - createFrigateCardCameraAction, - createFrigateCardChangeZoomAction, - createFrigateCardDisplayModeAction, - createFrigateCardMediaPlayerAction, - createFrigateCardShowPTZAction, - createFrigateCardSimpleAction, + createCameraAction, + createPTZMultiAction, + createDisplayModeAction, + createMediaPlayerAction, + createPTZControlsAction, + createGeneralAction, } from '../utils/action'; import { isTruthy } from '../utils/basic'; import { getEntityIcon, getEntityTitle } from '../utils/ha'; -import { hasUsablePTZ } from '../utils/ptz'; -import { hasSubstream } from '../utils/substream'; +import { getPTZTarget } from '../utils/ptz'; +import { getStreamCameraID, hasSubstream } from '../utils/substream'; import { View } from '../view/view'; import { getCameraIDsForViewName } from '../view/view-to-cameras'; @@ -95,8 +95,8 @@ export class MenuButtonController { this._getMuteUnmuteButton(config, options?.currentMediaLoadedInfo), this._getScreenshotButton(config, options?.currentMediaLoadedInfo), this._getDisplayModeButton(config, cameraManager, view), - this._getPTZButton(config, cameraManager, view), - this._getDefaultZoomButton(config, view), + this._getPTZControlsButton(config, cameraManager, view), + this._getPTZHomeButton(config, cameraManager, view), ...this._dynamicMenuButtons.map((button) => ({ style: this._getStyleFromActions(config, view, button, options), @@ -115,11 +115,9 @@ export class MenuButtonController { title: localize('config.menu.buttons.frigate'), tap_action: config.menu?.style === 'hidden' - ? (createFrigateCardSimpleAction('menu_toggle') as FrigateCardCustomAction) - : (createFrigateCardSimpleAction('default') as FrigateCardCustomAction), - hold_action: createFrigateCardSimpleAction( - 'diagnostics', - ) as FrigateCardCustomAction, + ? (createGeneralAction('menu_toggle') as FrigateCardCustomAction) + : (createGeneralAction('default') as FrigateCardCustomAction), + hold_action: createGeneralAction('diagnostics') as FrigateCardCustomAction, }; } @@ -135,7 +133,7 @@ export class MenuButtonController { const menuItems = Array.from( cameraManager.getStore().getCameraConfigEntries(menuCameraIDs), ([cameraID, config]) => { - const action = createFrigateCardCameraAction('camera_select', cameraID); + const action = createCameraAction('camera_select', cameraID); const metadata = cameraManager.getCameraMetadata(cameraID); return { @@ -175,7 +173,7 @@ export class MenuButtonController { (cameraID) => cameraID !== view.camera, ); const streams = [view.camera, ...substreams]; - const substreamAwareCameraID = this._getSubstreamAwareCameraID(view); + const substreamAwareCameraID = getStreamCameraID(view); if (streams.length === 2) { // If there are only two dependencies (the main camera, and 1 other) @@ -187,16 +185,13 @@ export class MenuButtonController { title: localize('config.menu.buttons.substreams'), ...config.menu.buttons.substreams, type: 'custom:frigate-card-menu-icon', - tap_action: createFrigateCardSimpleAction( + tap_action: createGeneralAction( hasSubstream(view) ? 'live_substream_off' : 'live_substream_on', ) as FrigateCardCustomAction, }; } else if (streams.length > 2) { const menuItems = Array.from(streams, (streamID) => { - const action = createFrigateCardCameraAction( - 'live_substream_select', - streamID, - ); + const action = createCameraAction('live_substream_select', streamID); const metadata = cameraManager.getCameraMetadata(streamID) ?? undefined; const cameraConfig = cameraManager.getStore().getCameraConfig(streamID); return { @@ -236,7 +231,7 @@ export class MenuButtonController { type: 'custom:frigate-card-menu-icon', title: localize('config.view.views.live'), style: view.is('live') ? this._getEmphasizedStyle() : {}, - tap_action: createFrigateCardSimpleAction('live') as FrigateCardCustomAction, + tap_action: createGeneralAction('live') as FrigateCardCustomAction, } : null; } @@ -253,8 +248,8 @@ export class MenuButtonController { type: 'custom:frigate-card-menu-icon', title: localize('config.view.views.clips'), style: view?.is('clips') ? this._getEmphasizedStyle() : {}, - tap_action: createFrigateCardSimpleAction('clips') as FrigateCardCustomAction, - hold_action: createFrigateCardSimpleAction('clip') as FrigateCardCustomAction, + tap_action: createGeneralAction('clips') as FrigateCardCustomAction, + hold_action: createGeneralAction('clip') as FrigateCardCustomAction, } : null; } @@ -271,12 +266,8 @@ export class MenuButtonController { type: 'custom:frigate-card-menu-icon', title: localize('config.view.views.snapshots'), style: view?.is('snapshots') ? this._getEmphasizedStyle() : {}, - tap_action: createFrigateCardSimpleAction( - 'snapshots', - ) as FrigateCardCustomAction, - hold_action: createFrigateCardSimpleAction( - 'snapshot', - ) as FrigateCardCustomAction, + tap_action: createGeneralAction('snapshots') as FrigateCardCustomAction, + hold_action: createGeneralAction('snapshot') as FrigateCardCustomAction, } : null; } @@ -293,12 +284,8 @@ export class MenuButtonController { type: 'custom:frigate-card-menu-icon', title: localize('config.view.views.recordings'), style: view.is('recordings') ? this._getEmphasizedStyle() : {}, - tap_action: createFrigateCardSimpleAction( - 'recordings', - ) as FrigateCardCustomAction, - hold_action: createFrigateCardSimpleAction( - 'recording', - ) as FrigateCardCustomAction, + tap_action: createGeneralAction('recordings') as FrigateCardCustomAction, + hold_action: createGeneralAction('recording') as FrigateCardCustomAction, } : null; } @@ -315,7 +302,7 @@ export class MenuButtonController { type: 'custom:frigate-card-menu-icon', title: localize('config.view.views.image'), style: view?.is('image') ? this._getEmphasizedStyle() : {}, - tap_action: createFrigateCardSimpleAction('image') as FrigateCardCustomAction, + tap_action: createGeneralAction('image') as FrigateCardCustomAction, } : null; } @@ -332,9 +319,7 @@ export class MenuButtonController { type: 'custom:frigate-card-menu-icon', title: localize('config.view.views.timeline'), style: view.is('timeline') ? this._getEmphasizedStyle() : {}, - tap_action: createFrigateCardSimpleAction( - 'timeline', - ) as FrigateCardCustomAction, + tap_action: createGeneralAction('timeline') as FrigateCardCustomAction, } : null; } @@ -358,7 +343,7 @@ export class MenuButtonController { ...config.menu.buttons.download, type: 'custom:frigate-card-menu-icon', title: localize('config.menu.buttons.download'), - tap_action: createFrigateCardSimpleAction('download') as FrigateCardCustomAction, + tap_action: createGeneralAction('download') as FrigateCardCustomAction, }; } return null; @@ -374,9 +359,7 @@ export class MenuButtonController { ...config.menu.buttons.camera_ui, type: 'custom:frigate-card-menu-icon', title: localize('config.menu.buttons.camera_ui'), - tap_action: createFrigateCardSimpleAction( - 'camera_ui', - ) as FrigateCardCustomAction, + tap_action: createGeneralAction('camera_ui') as FrigateCardCustomAction, } : null; } @@ -402,16 +385,16 @@ export class MenuButtonController { style: forbidden || muted ? {} : this._getEmphasizedStyle(true), ...(!forbidden && buttonType === 'momentary' && { - start_tap_action: createFrigateCardSimpleAction( + start_tap_action: createGeneralAction( 'microphone_unmute', ) as FrigateCardCustomAction, - end_tap_action: createFrigateCardSimpleAction( + end_tap_action: createGeneralAction( 'microphone_mute', ) as FrigateCardCustomAction, }), ...(!forbidden && buttonType === 'toggle' && { - tap_action: createFrigateCardSimpleAction( + tap_action: createGeneralAction( muted ? 'microphone_unmute' : 'microphone_mute', ) as FrigateCardCustomAction, }), @@ -429,7 +412,7 @@ export class MenuButtonController { ...config.menu.buttons.expand, type: 'custom:frigate-card-menu-icon', title: localize('config.menu.buttons.expand'), - tap_action: createFrigateCardSimpleAction('expand') as FrigateCardCustomAction, + tap_action: createGeneralAction('expand') as FrigateCardCustomAction, style: inExpandedMode ? this._getEmphasizedStyle() : {}, }; } @@ -444,9 +427,7 @@ export class MenuButtonController { ...config.menu.buttons.fullscreen, type: 'custom:frigate-card-menu-icon', title: localize('config.menu.buttons.fullscreen'), - tap_action: createFrigateCardSimpleAction( - 'fullscreen', - ) as FrigateCardCustomAction, + tap_action: createGeneralAction('fullscreen') as FrigateCardCustomAction, style: inFullscreenMode ? this._getEmphasizedStyle() : {}, } : null; @@ -469,8 +450,8 @@ export class MenuButtonController { .map((playerEntityID) => { const title = getEntityTitle(hass, playerEntityID) || playerEntityID; const state = hass.states[playerEntityID]; - const playAction = createFrigateCardMediaPlayerAction(playerEntityID, 'play'); - const stopAction = createFrigateCardMediaPlayerAction(playerEntityID, 'stop'); + const playAction = createMediaPlayerAction(playerEntityID, 'play'); + const stopAction = createMediaPlayerAction(playerEntityID, 'stop'); const disabled = !state || state.state === 'unavailable'; return { @@ -512,7 +493,7 @@ export class MenuButtonController { ...config.menu.buttons.play, type: 'custom:frigate-card-menu-icon', title: localize('config.menu.buttons.play'), - tap_action: createFrigateCardSimpleAction( + tap_action: createGeneralAction( paused ? 'play' : 'pause', ) as FrigateCardCustomAction, }; @@ -535,7 +516,7 @@ export class MenuButtonController { ...config.menu.buttons.mute, type: 'custom:frigate-card-menu-icon', title: localize('config.menu.buttons.mute'), - tap_action: createFrigateCardSimpleAction( + tap_action: createGeneralAction( muted ? 'unmute' : 'mute', ) as FrigateCardCustomAction, }; @@ -553,9 +534,7 @@ export class MenuButtonController { ...config.menu.buttons.screenshot, type: 'custom:frigate-card-menu-icon', title: localize('config.menu.buttons.screenshot'), - tap_action: createFrigateCardSimpleAction( - 'screenshot', - ) as FrigateCardCustomAction, + tap_action: createGeneralAction('screenshot') as FrigateCardCustomAction, }; } return null; @@ -577,65 +556,75 @@ export class MenuButtonController { title: isGrid ? localize('display_modes.single') : localize('display_modes.grid'), - tap_action: createFrigateCardDisplayModeAction(isGrid ? 'single' : 'grid'), + tap_action: createDisplayModeAction(isGrid ? 'single' : 'grid'), }; } return null; } - protected _getSubstreamAwareCameraID(view: View): string { - return view.is('live') - ? view.context?.live?.overrides?.get(view.camera) ?? view.camera - : view.camera; - } - - protected _getPTZButton( + protected _getPTZControlsButton( config: FrigateCardConfig, cameraManager: CameraManager, view: View, ): MenuItem | null { - const substreamAwareCameraCapabilities = cameraManager.getCameraCapabilities( - this._getSubstreamAwareCameraID(view), - ); + const ptzConfig = view.is('live') + ? config.live.controls.ptz + : view.isViewerView() + ? config.media_viewer.controls.ptz + : null; - if ( - view.is('live') && - hasUsablePTZ(substreamAwareCameraCapabilities, config.live.controls.ptz) - ) { + if (!ptzConfig || ptzConfig.mode === 'off') { + return null; + } + + const ptzTarget = getPTZTarget(view, { + cameraManager: cameraManager, + ...(ptzConfig.mode === 'auto' && { type: 'ptz' }), + }); + + if (ptzTarget) { const isOn = - view.context?.live?.ptzVisible === false - ? false - : config.live.controls.ptz.mode === 'on'; + view.context?.ptzControls?.enabled !== false && + (ptzConfig.mode === 'on' || + (ptzConfig.mode === 'auto' && ptzTarget.type === 'ptz')); return { icon: 'mdi:pan', - ...config.menu.buttons.ptz, + ...config.menu.buttons.ptz_controls, style: isOn ? this._getEmphasizedStyle() : {}, type: 'custom:frigate-card-menu-icon', - title: localize('config.menu.buttons.ptz'), - tap_action: createFrigateCardShowPTZAction(!isOn), + title: localize('config.menu.buttons.ptz_controls'), + tap_action: createPTZControlsAction(!isOn), }; } return null; } - protected _getDefaultZoomButton( + protected _getPTZHomeButton( config: FrigateCardConfig, + cameraManager: CameraManager, view: View, ): MenuItem | null { - const targetID = view.isViewerView() - ? view.queryResults?.getSelectedResult()?.getID() ?? null - : this._getSubstreamAwareCameraID(view); + const target = getPTZTarget(view, { + cameraManager: cameraManager, + }); - if (!targetID || (view.context?.zoom?.[targetID]?.isDefault ?? true)) { + if ( + !target || + ((target.type === 'digital' && + view.context?.zoom?.[target.targetID]?.observed?.isDefault) ?? + true) + ) { return null; } return { - icon: 'mdi:magnify-close', - ...config.menu.buttons.default_zoom, + icon: 'mdi:home', + ...config.menu.buttons.ptz_home, type: 'custom:frigate-card-menu-icon', - title: localize('config.menu.buttons.default_zoom'), - tap_action: createFrigateCardChangeZoomAction(targetID) as FrigateCardCustomAction, + title: localize('config.menu.buttons.ptz_home'), + tap_action: createPTZMultiAction({ + targetID: target.targetID, + }) as FrigateCardCustomAction, }; } diff --git a/src/components-lib/menu-controller.ts b/src/components-lib/menu-controller.ts index 9f773a56..f61e4131 100644 --- a/src/components-lib/menu-controller.ts +++ b/src/components-lib/menu-controller.ts @@ -10,12 +10,12 @@ import type { import { FRIGATE_BUTTON_MENU_ICON } from '../const.js'; import { StateParameters } from '../types.js'; import { - convertActionToFrigateCardCustomAction, - frigateCardHandleActionConfig, + convertActionToCardCustomAction, getActionConfigGivenAction, } from '../utils/action'; import { arrayify, isTruthy, setOrRemoveAttribute } from '../utils/basic.js'; import { refreshDynamicStateParameters } from '../utils/ha/index.js'; +import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request.js'; export class MenuController { protected _host: LitElement; @@ -95,7 +95,6 @@ export class MenuController { } public actionHandler( - hass: HomeAssistant, ev: HASSDomEvent<{ action: string; config?: ActionsConfig }>, config?: ActionsConfig, ): void { @@ -134,7 +133,10 @@ export class MenuController { } if (toggleLessActions.length) { - frigateCardHandleActionConfig(this._host, hass, config, interaction, actions); + dispatchActionExecutionRequest(this._host, { + action: actions, + config: config, + }); } if (this._isHidingMenu()) { @@ -209,7 +211,7 @@ export class MenuController { } protected _isMenuToggleAction(action: ActionType): boolean { - const frigateCardAction = convertActionToFrigateCardCustomAction(action); + const frigateCardAction = convertActionToCardCustomAction(action); return !!frigateCardAction && frigateCardAction.frigate_card_action == 'menu_toggle'; } } diff --git a/src/components-lib/ptz-controller.ts b/src/components-lib/ptz-controller.ts deleted file mode 100644 index 5399574a..00000000 --- a/src/components-lib/ptz-controller.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { HASSDomEvent, HomeAssistant } from '@dermotduffy/custom-card-helpers'; -import { CameraManager } from '../camera-manager/manager'; -import { - Actions, - ActionsConfig, - FrigateCardPTZAction, - FrigateCardPTZActions, - FrigateCardPTZConfig, - PTZAction, - PTZControlAction, - PTZ_CONTROL_ACTIONS, -} from '../config/types'; -import { - frigateCardHandleActionConfig, - getActionConfigGivenAction, -} from '../utils/action'; - -export class PTZController { - private _host: HTMLElement; - - private _config: FrigateCardPTZConfig | null = null; - private _hass: HomeAssistant | null = null; - private _cameraManager: CameraManager | null = null; - private _cameraID: string | null = null; - private _actions: FrigateCardPTZActions | null = null; - private _forceVisibility?: boolean; - - constructor(host: HTMLElement) { - this._host = host; - } - - public setConfig(config?: FrigateCardPTZConfig) { - this._config = config ?? null; - - this._host.setAttribute('data-orientation', config?.orientation ?? 'horizontal'); - this._host.setAttribute('data-position', config?.position ?? 'bottom-right'); - this._host.setAttribute( - 'style', - Object.entries(config?.style ?? {}) - .map(([k, v]) => `${k}:${v}`) - .join(';'), - ); - } - - public getConfig(): FrigateCardPTZConfig | null { - return this._config; - } - - public setHASS(hass?: HomeAssistant) { - this._hass = hass ?? null; - } - - public setCamera(cameraManager?: CameraManager, cameraID?: string) { - this._cameraManager = cameraManager ?? null; - this._cameraID = cameraID ?? null; - - this._calculateActions(); - } - - public setForceVisibility(forceVisibility?: boolean): void { - this._forceVisibility = forceVisibility; - } - - public handleAction( - ev: HASSDomEvent<{ action: string }>, - config?: ActionsConfig | null, - ): void { - // Nothing else has the configuration for this action, so don't let it - // propagate further. - ev.stopPropagation(); - - const interaction: string = ev.detail.action; - const action = getActionConfigGivenAction(interaction, config); - if (config && action && this._hass) { - frigateCardHandleActionConfig(this._host, this._hass, config, interaction, action); - } - } - - public getPTZActions(actionName: PTZControlAction): Actions | null { - const propertyName = 'actions_' + actionName; - return this._config?.[propertyName] ?? this._actions?.[propertyName] ?? null; - } - - private _hasAnyAction(): boolean { - for (const actionName of PTZ_CONTROL_ACTIONS) { - if ('actions_' + actionName in (this._actions ?? {})) { - return true; - } - } - return false; - } - - public shouldDisplay(): boolean { - return this._forceVisibility === false - ? false - : this._config?.mode === 'on' && this._hasAnyAction(); - } - - private _calculateActions(): void { - const getDefaultAction = ( - ptzAction: PTZAction, - options?: { - phase?: 'start' | 'stop'; - preset?: string; - }, - ): FrigateCardPTZAction => ({ - action: 'fire-dom-event', - frigate_card_action: 'ptz', - ptz_action: ptzAction, - ...(options?.phase && { ptz_phase: options.phase }), - ...(options?.preset && { ptz_preset: options.preset }), - }); - - const getDefaultActions = ( - ptzAction: PTZAction, - continuous: boolean, - preset?: string, - ): Actions => - continuous - ? { - start_tap_action: getDefaultAction(ptzAction, { - phase: 'start', - preset: preset, - }), - end_tap_action: getDefaultAction(ptzAction, { - phase: 'stop', - preset: preset, - }), - } - : { - tap_action: getDefaultAction(ptzAction, { preset: preset }), - }; - - if (!this._cameraManager || !this._cameraID) { - return; - } - - const ptzCapabilities = this._cameraManager.getCameraCapabilities( - this._cameraID, - )?.getPTZCapabilities(); - - const defaultActions: FrigateCardPTZActions = {}; - const panTilt = ptzCapabilities?.panTilt; - const zoom = ptzCapabilities?.zoom; - const presets = ptzCapabilities?.presets; - - if (panTilt?.length) { - const continuous = panTilt.includes('continuous'); - defaultActions.actions_up = getDefaultActions('up', continuous); - defaultActions.actions_down = getDefaultActions('down', continuous); - defaultActions.actions_left = getDefaultActions('left', continuous); - defaultActions.actions_right = getDefaultActions('right', continuous); - } - - if (zoom?.length) { - const continuous = zoom.includes('continuous'); - defaultActions.actions_zoom_in = getDefaultActions('zoom_in', continuous); - defaultActions.actions_zoom_out = getDefaultActions('zoom_out', continuous); - } - - if (presets?.length) { - defaultActions.actions_home = getDefaultActions('preset', false, presets[0]); - } - - this._actions = { - ...defaultActions, - ...this._config, - }; - } -} diff --git a/src/components-lib/ptz/ptz-controller.ts b/src/components-lib/ptz/ptz-controller.ts new file mode 100644 index 00000000..88af5a20 --- /dev/null +++ b/src/components-lib/ptz/ptz-controller.ts @@ -0,0 +1,145 @@ +import { HASSDomEvent, HomeAssistant } from '@dermotduffy/custom-card-helpers'; +import { CameraManager } from '../../camera-manager/manager'; +import { dispatchActionExecutionRequest } from '../../card-controller/actions/utils/execution-request'; +import { PTZAction } from '../../config/ptz'; +import { Actions, ActionsConfig, PTZControlsConfig } from '../../config/types'; +import { createPTZMultiAction, getActionConfigGivenAction } from '../../utils/action'; +import { PTZActionNameToMultiAction, PTZActionPresence } from './types'; + +export class PTZController { + private _host: HTMLElement; + + private _config: PTZControlsConfig | null = null; + private _hass: HomeAssistant | null = null; + private _cameraManager: CameraManager | null = null; + private _cameraID: string | null = null; + + private _forceVisibility?: boolean; + + constructor(host: HTMLElement) { + this._host = host; + } + + public setConfig(config?: PTZControlsConfig) { + this._config = config ?? null; + + this._host.setAttribute('data-orientation', config?.orientation ?? 'horizontal'); + this._host.setAttribute('data-position', config?.position ?? 'bottom-right'); + this._host.setAttribute( + 'style', + Object.entries(config?.style ?? {}) + .map(([k, v]) => `${k}:${v}`) + .join(';'), + ); + } + + public getConfig(): PTZControlsConfig | null { + return this._config; + } + + public setCamera(cameraManager?: CameraManager, cameraID?: string): void { + this._cameraManager = cameraManager ?? null; + this._cameraID = cameraID ?? null; + } + + public setForceVisibility(forceVisibility?: boolean): void { + this._forceVisibility = forceVisibility; + } + + public handleAction( + ev: HASSDomEvent<{ action: string }>, + config?: ActionsConfig | null, + ): void { + // Nothing else has the configuration for this action, so don't let it + // propagate further. + ev.stopPropagation(); + + const interaction: string = ev.detail.action; + const action = getActionConfigGivenAction(interaction, config); + if (action) { + dispatchActionExecutionRequest(this._host, { + action: action, + ...(config && { config: config }), + }); + } + } + + public hasUsefulAction(): PTZActionPresence { + const allUsefulActions = { + pt: true, + z: true, + home: true, + }; + if (!this._cameraID) { + // Will use digital PTZ. + return allUsefulActions; + } + const capabilities = this._cameraManager?.getCameraCapabilities(this._cameraID); + if (!capabilities || !capabilities.hasPTZCapability()) { + // Will use digital PTZ. + return allUsefulActions; + } + + const ptzCapabilities = capabilities.getPTZCapabilities(); + return { + pt: + !!ptzCapabilities?.up || + !!ptzCapabilities?.down || + !!ptzCapabilities?.left || + !!ptzCapabilities?.right, + z: !!ptzCapabilities?.zoomIn || !!ptzCapabilities?.zoomOut, + home: !!ptzCapabilities?.presets?.length, + }; + } + + public shouldDisplay(): boolean { + return this._forceVisibility !== undefined + ? this._forceVisibility + : this._config?.mode === 'auto' + ? !!this._cameraID && + !!this._cameraManager?.getCameraCapabilities(this._cameraID)?.hasPTZCapability() + : this._config?.mode === 'on'; + } + + public getPTZActions(): PTZActionNameToMultiAction { + const getDefaultActions = (options?: { + ptzAction?: PTZAction; + preset?: string; + }): Actions => ({ + start_tap_action: createPTZMultiAction({ + ptzAction: options?.ptzAction, + ptzPhase: 'start', + ptzPreset: options?.preset, + }), + end_tap_action: createPTZMultiAction({ + ptzAction: options?.ptzAction, + ptzPhase: 'stop', + ptzPreset: options?.preset, + }), + }); + + const actions: PTZActionNameToMultiAction = {}; + actions.up = getDefaultActions({ + ptzAction: 'up', + }); + actions.down = getDefaultActions({ + ptzAction: 'down', + }); + actions.left = getDefaultActions({ + ptzAction: 'left', + }); + actions.right = getDefaultActions({ + ptzAction: 'right', + }); + actions.zoom_in = getDefaultActions({ + ptzAction: 'zoom_in', + }); + actions.zoom_out = getDefaultActions({ + ptzAction: 'zoom_out', + }); + actions.home = { + tap_action: createPTZMultiAction(), + }; + return actions; + } +} diff --git a/src/components-lib/ptz/types.ts b/src/components-lib/ptz/types.ts new file mode 100644 index 00000000..2c71cc60 --- /dev/null +++ b/src/components-lib/ptz/types.ts @@ -0,0 +1,21 @@ +import { PTZControlAction } from '../../config/ptz'; +import { Actions } from '../../config/types'; + +interface PTZControlsViewContext { + enabled?: boolean; +} +declare module 'view' { + interface ViewContext { + ptzControls?: PTZControlsViewContext; + } +} + +export type PTZActionNameToMultiAction = { + [K in PTZControlAction]?: Actions; +}; + +export interface PTZActionPresence { + pt: boolean; + z: boolean; + home: boolean; +} diff --git a/src/components-lib/zoom/types.ts b/src/components-lib/zoom/types.ts index 04f92855..533c8209 100644 --- a/src/components-lib/zoom/types.ts +++ b/src/components-lib/zoom/types.ts @@ -1,11 +1,29 @@ -export interface ZoomConfig { - pan?: { - x?: number; - y?: number; +import { PartialDeep } from 'type-fest'; + +export const ZOOM_DEFAULT_PAN_X = 50; +export const ZOOM_DEFAULT_PAN_Y = 50; +export const ZOOM_DEFAULT_SCALE = 1; +export const ZOOM_PRECISION = 4; + +export interface ZoomSettingsBase { + pan: { + x: number; + y: number; }; - zoom?: number; + zoom: number; } -export interface ZoomDefault { +export type PartialZoomSettings = PartialDeep; + +export interface ZoomSettingsObserved extends ZoomSettingsBase { isDefault: boolean; + unzoomed: boolean; } + +export const isZoomEmpty = (settings?: PartialZoomSettings | null): boolean => { + return ( + settings?.pan?.x === undefined && + settings?.pan?.y === undefined && + settings?.zoom === undefined + ); +}; diff --git a/src/components-lib/zoom/zoom-controller.ts b/src/components-lib/zoom/zoom-controller.ts index 8a6aeec6..7144bdd2 100644 --- a/src/components-lib/zoom/zoom-controller.ts +++ b/src/components-lib/zoom/zoom-controller.ts @@ -1,17 +1,20 @@ import Panzoom, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom'; -import debounce from 'lodash-es/debounce'; +import throttle from 'lodash-es/throttle'; import round from 'lodash-es/round'; import { arefloatsApproximatelyEqual, dispatchFrigateCardEvent, isHoverableDevice, } from '../../utils/basic'; -import { ZoomConfig } from './types'; - -const ZOOM_DEFAULT_PAN_X = 50; -const ZOOM_DEFAULT_PAN_Y = 50; -const ZOOM_DEFAULT_SCALE = 1; -const ZOOM_PRECISION = 4; +import { + PartialZoomSettings, + ZOOM_DEFAULT_PAN_X, + ZOOM_DEFAULT_PAN_Y, + ZOOM_DEFAULT_SCALE, + ZOOM_PRECISION, + ZoomSettingsObserved, + isZoomEmpty, +} from './types'; export class ZoomController { protected _element: HTMLElement; @@ -26,15 +29,14 @@ export class ZoomController { // Should clicks be allowed to propagate, or consumed as a pan/zoom action? protected _allowClick = true; - protected _defaultConfig: ZoomConfig | null; - protected _config: ZoomConfig | null; + protected _defaultSettings: PartialZoomSettings | null; + protected _settings: PartialZoomSettings | null; - // When the user pans/zooms changes may be created at a very high rate. - protected _debouncedChangeHandler = debounce(this._changeHandler.bind(this), 200); - - // Multiple calls to setConfig() or setDefaultConfig() or resizes should only - // update once. - protected _debouncedUpdater = debounce(this._updateBasedOnConfig.bind(this), 200); + // These values should be suitably less than the value of STEP_DELAY_SECONDS + // in the ptz-digital action, in order to ensure smooth movements of the + // digital PTZ actions. + protected _debouncedChangeHandler = throttle(this._changeHandler.bind(this), 50); + protected _debouncedUpdater = throttle(this._updateBasedOnConfig.bind(this), 50); protected _resizeObserver = new ResizeObserver(this._debouncedUpdater); @@ -70,6 +72,11 @@ export class ZoomController { // handler in the viewer). if (!this._allowClick) { ev.stopPropagation(); + + // Even though the click is stopped,the card still needs to gain focus so + // that keyboard shortcuts will work immediately after the card is clicked + // upon. + dispatchFrigateCardEvent(this._element, 'focus'); } this._allowClick = true; }; @@ -97,11 +104,14 @@ export class ZoomController { constructor( element: HTMLElement, - options?: { config?: ZoomConfig | null; defaultConfig?: ZoomConfig | null }, + options?: { + config?: PartialZoomSettings | null; + defaultConfig?: PartialZoomSettings | null; + }, ) { this._element = element; - this._config = options?.config ?? null; - this._defaultConfig = options?.defaultConfig ?? null; + this._settings = options?.config ?? null; + this._defaultSettings = options?.defaultConfig ?? null; } public activate(): void { @@ -182,44 +192,47 @@ export class ZoomController { this._element.removeEventListener('panzoomchange', this._debouncedChangeHandler); } - public setDefaultConfig(config: ZoomConfig | null): void { - this._defaultConfig = config; + public setDefaultSettings(config: PartialZoomSettings | null): void { + this._defaultSettings = config; this._debouncedUpdater(); } - public setConfig(config: ZoomConfig): void { - this._config = config; + public setSettings(config: PartialZoomSettings | null): void { + this._settings = config; this._debouncedUpdater(); } protected _changeHandler(ev: Event): void { const pz = (>ev).detail; - - const isUnzoomed = this._isUnzoomed(pz.scale); - const isAtDefault = this._isAtDefaultZoomAndPan(pz.x, pz.y, pz.scale); + const unzoomed = this._isUnzoomed(pz.scale); // Take care here to only dispatch the zoomed/unzoomed events when the // absolute state changes (rather than on every single zoom adjustment). - if (isUnzoomed && this._zoomed) { + if (unzoomed && this._zoomed) { this._zoomed = false; this._setTouchAction(true); dispatchFrigateCardEvent(this._element, 'zoom:unzoomed'); - } else if (!isUnzoomed && !this._zoomed) { + } else if (!unzoomed && !this._zoomed) { this._zoomed = true; this._setTouchAction(false); dispatchFrigateCardEvent(this._element, 'zoom:zoomed'); } - if (isAtDefault && !this._default) { - this._default = true; - dispatchFrigateCardEvent(this._element, 'zoom:default', { isDefault: true }); - } else if (!isAtDefault && this._default) { - this._default = false; - dispatchFrigateCardEvent(this._element, 'zoom:default', { isDefault: false }); - } + const converted = this._convertXYPanToPercent(pz.x, pz.y, pz.scale); + const observed: ZoomSettingsObserved = { + pan: { + x: converted?.x ?? ZOOM_DEFAULT_PAN_X, + y: converted?.y ?? ZOOM_DEFAULT_PAN_Y, + }, + zoom: pz.scale, + isDefault: this._isAtDefaultZoomAndPan(pz.x, pz.y, pz.scale), + unzoomed: unzoomed, + }; + + dispatchFrigateCardEvent(this._element, 'zoom:change', observed); } - protected _isZoomEqual(a: ZoomConfig, b: ZoomConfig): boolean { + protected _isZoomEqual(a: PartialZoomSettings, b: PartialZoomSettings): boolean { // The ?? clauses below cannot be reached since this function is only ever // used fully specified by this object. It's kept as-is for completeness. return ( @@ -247,16 +260,8 @@ export class ZoomController { ); } - protected _isZoomEmpty(config?: ZoomConfig | null): boolean { - return ( - config?.pan?.x === undefined && - config?.pan?.y === undefined && - config?.zoom === undefined - ); - } - - protected _getConfigToUse(): ZoomConfig | null { - return this._isZoomEmpty(this._config) ? this._defaultConfig : this._config; + protected _getConfigToUse(): PartialZoomSettings | null { + return isZoomEmpty(this._settings) ? this._defaultSettings : this._settings; } protected _updateBasedOnConfig(): void { @@ -292,6 +297,9 @@ export class ZoomController { } this._panzoom.zoom(desiredScale, { + // Zoom is stepped, not animated. If it is animated, there is interaction + // between the zoom and the pan below, and the pan would need to be + // delayed until after the zoom is complete. animate: false, }); @@ -300,10 +308,12 @@ export class ZoomController { // situation where we need to ensure the zoom completes first. Using // `requestAnimationFrame` appears to reliably allow the zoom to finish // rendering first, before the pain is applied. + // // See: https://github.com/timmywil/panzoom?tab=readme-ov-file#a-note-on-the-async-nature-of-panzoom window.requestAnimationFrame(() => { this._panzoom?.pan(x, y, { - animate: false, + animate: true, + duration: 100, }); }); } @@ -332,6 +342,28 @@ export class ZoomController { }; } + protected _convertXYPanToPercent( + x: number, + y: number, + scale: number, + ): { x: number; y: number } | null { + const minMax = this._getTransformMinMax(scale, this._panzoom?.getScale()); + if (minMax === null) { + return null; + } + + return { + x: + ((-x + Math.abs(minMax.minX)) / + (Math.abs(minMax.maxX) + Math.abs(minMax.minX))) * + 100, + y: + ((-y + Math.abs(minMax.minY)) / + (Math.abs(minMax.maxY) + Math.abs(minMax.minY))) * + 100, + }; + } + protected _getTransformMinMax( desiredScale: number, currentScale?: number, @@ -375,14 +407,14 @@ export class ZoomController { } protected _isAtDefaultZoomAndPan(x: number, y: number, scale: number): boolean { - if (!this._defaultConfig) { + if (!this._defaultSettings) { return this._isUnzoomed(scale); } const convertedDefault = this._convertPercentToXYPan( - this._defaultConfig.pan?.x ?? ZOOM_DEFAULT_PAN_X, - this._defaultConfig.pan?.y ?? ZOOM_DEFAULT_PAN_Y, - this._defaultConfig.zoom ?? ZOOM_DEFAULT_SCALE, + this._defaultSettings.pan?.x ?? ZOOM_DEFAULT_PAN_X, + this._defaultSettings.pan?.y ?? ZOOM_DEFAULT_PAN_Y, + this._defaultSettings.zoom ?? ZOOM_DEFAULT_SCALE, ); if (!convertedDefault) { return true; @@ -393,7 +425,7 @@ export class ZoomController { arefloatsApproximatelyEqual(y, convertedDefault.y) && arefloatsApproximatelyEqual( scale, - this._defaultConfig.zoom ?? + this._defaultSettings.zoom ?? // The ZOOM_DEFAULT_SCALE clause below cannot be reached since when // this._defaultConfig.zoom is undefined, convertedDefault will end up // null above and this function will have already returned. diff --git a/src/components-lib/zoom/zoom-view-context.ts b/src/components-lib/zoom/zoom-view-context.ts index bd8b169a..bd13e89c 100644 --- a/src/components-lib/zoom/zoom-view-context.ts +++ b/src/components-lib/zoom/zoom-view-context.ts @@ -1,15 +1,13 @@ import { ViewContext } from 'view'; -import { ZoomConfig, ZoomDefault } from './types.js'; import { dispatchViewContextChangeEvent } from '../../view/view.js'; +import { ZoomSettingsObserved, PartialZoomSettings } from './types.js'; interface ZoomViewContext { + observed?: ZoomSettingsObserved; + // Populate this to request zoom to a particular scale/x/y. An empty object // will reset to default, null will make no change. - zoom?: ZoomConfig | null; - - // This will be populated with whether or not the current zoom is at the - // default level. - isDefault?: boolean; + requested?: PartialZoomSettings | null; } interface ZoomsViewContext { @@ -22,36 +20,37 @@ declare module 'view' { } } -export const generateViewContextForZoomChange = ( +export const generateViewContextForZoom = ( targetID: string, options?: { - zoom?: ZoomConfig | null; - isDefault?: boolean; + observed?: ZoomSettingsObserved; + requested?: PartialZoomSettings | null; }, ): ViewContext | null => { return { zoom: { [targetID]: { - zoom: options?.zoom ?? null, - ...(options?.isDefault !== undefined && { isDefault: options.isDefault }), + observed: options?.observed ?? undefined, + requested: options?.requested ?? null, }, }, }; }; /** - * Convenience wrapper to convert a zoom default into a dispatched view context + * Convenience wrapper to convert zoom settings into a dispatched view context * change. */ -export const handleZoomDefaultEvent = ( +export const handleZoomSettingsObservedEvent = ( element: EventTarget, - ev: CustomEvent, + ev: CustomEvent, targetID?: string, ): void => { - targetID && dispatchViewContextChangeEvent( - element, - generateViewContextForZoomChange(targetID, { - isDefault: ev.detail.isDefault, - }), - ); + targetID && + dispatchViewContextChangeEvent( + element, + generateViewContextForZoom(targetID, { + observed: ev.detail, + }), + ); }; diff --git a/src/components/image.ts b/src/components/image.ts index 5a1850d3..63335d86 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -9,13 +9,10 @@ import { unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import { guard } from 'lit/directives/guard.js'; import { live } from 'lit/directives/live.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; import isEqual from 'lodash-es/isEqual'; import { CachedValueController } from '../components-lib/cached-value-controller.js'; -import { ZoomDefault } from '../components-lib/zoom/types.js'; -import { handleZoomDefaultEvent } from '../components-lib/zoom/zoom-view-context.js'; import { CameraConfig, ImageViewConfig } from '../config/types.js'; import defaultImage from '../images/frigate-bird-in-sky.jpg'; import { localize } from '../localize/localize.js'; @@ -31,6 +28,7 @@ import { } from '../utils/media-info.js'; import { View } from '../view/view.js'; import { dispatchErrorMessageEvent } from './message.js'; +import { CameraManager } from '../camera-manager/manager.js'; // See TOKEN_CHANGE_INTERVAL in https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py . const HASS_REJECTION_CUTOFF_MS = 5 * 60 * 1000; @@ -46,6 +44,9 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay @property({ attribute: false }) public cameraConfig?: CameraConfig; + @property({ attribute: false }) + public cameraManager?: CameraManager; + // Using contentsChanged to ensure overridden configs (e.g. when the // 'show_image_during_load' option is true for live views, an overridden // config may be used here). @@ -155,10 +156,6 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay () => dispatchMediaPauseEvent(this), ); } - - if (changedProps.has('imageConfig') && this.imageConfig?.zoomable) { - import('./zoomer.js'); - } } // If the camera or view changed, immediately discard the old value (view to @@ -279,67 +276,46 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay } } - protected _useZoomIfRequired(template: TemplateResult): TemplateResult { - const targetID = this.cameraConfig?.id; - const zoomConfig = targetID ? this.view?.context?.zoom?.[targetID]?.zoom : undefined; - - return this.imageConfig?.zoomable - ? html` - this.cameraConfig?.dimensions?.layout - ? { - pan: this.cameraConfig.dimensions.layout.pan, - zoom: this.cameraConfig.dimensions.layout.zoom, - } - : undefined, - )} - .config=${zoomConfig} - @frigate-card:zoom:default=${(ev: CustomEvent) => - handleZoomDefaultEvent(this, ev, targetID)} - > - ${template} - ` - : template; - } - protected render(): TemplateResult | void { const src = this._cachedValueController?.value; // Note the use of live() below to ensure the update will restore the image // src if it's been changed via _forceSafeImage(). return src - ? this._useZoomIfRequired(html` { - const mediaLoadedInfo = createMediaLoadedInfo(ev, { - player: this, - capabilities: { - supportsPause: !!this.imageConfig?.refresh_seconds, - }, - }); - // Avoid the media being reported as repeatedly loading unless the - // media info changes. - if (mediaLoadedInfo && !isEqual(this._mediaLoadedInfo, mediaLoadedInfo)) { - this._mediaLoadedInfo = mediaLoadedInfo; - dispatchExistingMediaLoadedInfoAsEvent(this, mediaLoadedInfo); - } - }} - @error=${() => { - if (this.imageConfig?.mode === 'camera') { - // In camera mode, the user has likely not made an error, but HA - // may be unavailble, so show the stock image. Don't let the URL - // override the stock image in this case, as this could create an - // error loop if that URL subsequently failed to load. - this._forceSafeImage(true); - } else if (this.imageConfig?.mode === 'url') { - // In url mode, the user likely specified a URL that cannot be - // resolved. Show an error message. - dispatchErrorMessageEvent(this, localize('error.image_load_error'), { - context: this.imageConfig, + ? html` + { + const mediaLoadedInfo = createMediaLoadedInfo(ev, { + player: this, + capabilities: { + supportsPause: !!this.imageConfig?.refresh_seconds, + }, }); - } - }} - />`) + // Avoid the media being reported as repeatedly loading unless the + // media info changes. + if (mediaLoadedInfo && !isEqual(this._mediaLoadedInfo, mediaLoadedInfo)) { + this._mediaLoadedInfo = mediaLoadedInfo; + dispatchExistingMediaLoadedInfoAsEvent(this, mediaLoadedInfo); + } + }} + @error=${() => { + if (this.imageConfig?.mode === 'camera') { + // In camera mode, the user has likely not made an error, but HA + // may be unavailble, so show the stock image. Don't let the URL + // override the stock image in this case, as this could create an + // error loop if that URL subsequently failed to load. + this._forceSafeImage(true); + } else if (this.imageConfig?.mode === 'url') { + // In url mode, the user likely specified a URL that cannot be + // resolved. Show an error message. + dispatchErrorMessageEvent(this, localize('error.image_load_error'), { + context: this.imageConfig, + }); + } + }} + /> + ` : html``; } diff --git a/src/components/key-assigner.ts b/src/components/key-assigner.ts new file mode 100644 index 00000000..3312dfec --- /dev/null +++ b/src/components/key-assigner.ts @@ -0,0 +1,93 @@ +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { KeyAssignerController } from '../components-lib/key-assigner-controller'; +import { KeyboardShortcut } from '../config/keyboard-shortcuts'; +import keyAssignerStyle from '../scss/key-assigner.scss'; +import { localize } from '../localize/localize'; + +@customElement('frigate-card-key-assigner') +export class FrigateCardKeyAssigner extends LitElement { + @property({ attribute: false }) + public label?: string; + + @property({ attribute: false }) + public value?: KeyboardShortcut | null; + + protected _controller = new KeyAssignerController(this); + + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('value')) { + this._controller.setValue(this.value ?? null); + } + } + + protected render(): TemplateResult | void { + if (!this.label) { + return; + } + + const renderKey = (key: string) => { + return html`
+
${key}
+
`; + }; + + return html` +
${this.label}
+ { + this._controller.toggleAssigning(); + }} + > + + + ${ + this._controller.isAssigning() + ? localize('key_assigner.assigning') + : localize('key_assigner.assign') + } + + + ${ + this._controller.hasValue() + ? html` { + this._controller.setValue(null); + }} + > + + ${localize('key_assigner.unassign')} + ` + : '' + } +
+ ${this.value?.ctrl ? renderKey(localize('key_assigner.modifiers.ctrl')) : ''} + ${this.value?.shift ? renderKey(localize('key_assigner.modifiers.shift')) : ''} + ${this.value?.meta ? renderKey(localize('key_assigner.modifiers.meta')) : ''} + ${this.value?.alt ? renderKey(localize('key_assigner.modifiers.alt')) : ''} + ${this.value?.key ? renderKey(this.value.key) : ''} +
+ `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(keyAssignerStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-key-assigner': FrigateCardKeyAssigner; + } +} diff --git a/src/components/live/live.ts b/src/components/live/live.ts index 8251fc07..9a1910b5 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -21,8 +21,6 @@ import { import { ReadonlyMicrophoneManager } from '../../card-controller/microphone-manager.js'; import { LiveController } from '../../components-lib/live/live-controller.js'; import { MediaGridSelected } from '../../components-lib/media-grid-controller.js'; -import { ZoomConfig, ZoomDefault } from '../../components-lib/zoom/types.js'; -import { handleZoomDefaultEvent } from '../../components-lib/zoom/zoom-view-context.js'; import { CameraConfig, CardWideConfig, @@ -62,6 +60,12 @@ import { FrigateCardTitleControl, getDefaultTitleConfigForView, } from '../title-control.js'; +import { + PartialZoomSettings, + ZoomSettingsObserved, +} from '../../components-lib/zoom/types.js'; +import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js'; +import { getStreamCameraID } from '../../utils/substream.js'; const FRIGATE_CARD_LIVE_PROVIDER = 'frigate-card-live-provider'; @@ -503,9 +507,9 @@ export class FrigateCardLiveCarousel extends LitElement { .liveConfig=${liveConfig} .hass=${this.hass} .cardWideConfig=${this.cardWideConfig} - .zoomConfig=${this.view?.context?.zoom?.[cameraID]?.zoom} - @frigate-card:zoom:default=${(ev: CustomEvent) => - handleZoomDefaultEvent(this, ev, cameraID)} + .zoomSettings=${this.view?.context?.zoom?.[cameraID]?.requested} + @frigate-card:zoom:change=${(ev: CustomEvent) => + handleZoomSettingsObservedEvent(this, ev, cameraID)} >
@@ -633,11 +637,11 @@ export class FrigateCardLiveCarousel extends LitElement { ${cameraMetadataCurrent && titleConfig @@ -694,7 +698,7 @@ export class FrigateCardLiveProvider public microphoneStream?: MediaStream; @property({ attribute: false }) - public zoomConfig?: ZoomConfig | null; + public zoomSettings?: PartialZoomSettings | null; @state() protected _isVideoMediaLoaded = false; @@ -857,7 +861,7 @@ export class FrigateCardLiveProvider protected _useZoomIfRequired(template: TemplateResult): TemplateResult { return this.liveConfig?.zoomable ? html` + .defaultSettings=${guard([this.cameraConfig?.dimensions?.layout], () => this.cameraConfig?.dimensions?.layout ? { pan: this.cameraConfig.dimensions.layout.pan, @@ -865,7 +869,7 @@ export class FrigateCardLiveProvider } : undefined, )} - .config=${this.zoomConfig} + .settings=${this.zoomSettings} @frigate-card:zoom:zoomed=${() => this.setControls(false)} @frigate-card:zoom:unzoomed=${() => this.setControls()} > @@ -944,17 +948,17 @@ export class FrigateCardLiveProvider ` : provider === 'go2rtc' ? html` - ` + ${ref(this._refProvider)} + class=${classMap(providerClasses)} + .hass=${this.hass} + .cameraConfig=${this.cameraConfig} + .cameraEndpoints=${this.cameraEndpoints} + .microphoneStream=${this.microphoneStream} + .microphoneConfig=${this.liveConfig.microphone} + ?controls=${this.liveConfig.controls.builtin} + @frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)} + > + ` : provider === 'webrtc-card' ? html` this.hass && this._controller.actionHandler(this.hass, ev)} + @action=${(ev) => this._controller.actionHandler(ev)} > `; } else if (button.type === 'custom:frigate-card-menu-submenu-select') { @@ -54,7 +54,7 @@ export class FrigateCardMenu extends LitElement { .hass=${this.hass} .submenuSelect=${button} .entityRegistryManager=${this.entityRegistryManager} - @action=${(ev) => this.hass && this._controller.actionHandler(this.hass, ev)} + @action=${(ev) => this._controller.actionHandler(ev)} > `; } @@ -84,8 +84,7 @@ export class FrigateCardMenu extends LitElement { hasDoubleClick: frigateCardHasAction(button.double_tap_action), })} .label=${buttonState.title || ''} - @action=${(ev) => - this.hass && this._controller.actionHandler(this.hass, ev, button)} + @action=${(ev) => this._controller.actionHandler(ev, button)} > ${svgPath ? html`` @@ -106,17 +105,13 @@ export class FrigateCardMenu extends LitElement { return html`
${matchingButtons.map((button) => this._renderButton(button))}
${opposingButtons.map((button) => this._renderButton(button))}
`; diff --git a/src/components/ptz.ts b/src/components/ptz.ts index eed0d6e2..45355010 100644 --- a/src/components/ptz.ts +++ b/src/components/ptz.ts @@ -1,18 +1,19 @@ -import { HASSDomEvent, HomeAssistant } from '@dermotduffy/custom-card-helpers'; +import { HASSDomEvent } from '@dermotduffy/custom-card-helpers'; import { CSSResultGroup, - html, LitElement, PropertyValues, TemplateResult, - unsafeCSS + html, + unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { actionHandler } from '../action-handler-directive.js'; import { CameraManager } from '../camera-manager/manager.js'; -import { PTZController } from '../components-lib/ptz-controller.js'; -import { Actions, FrigateCardPTZConfig } from '../config/types.js'; +import { PTZController } from '../components-lib/ptz/ptz-controller.js'; +import { PTZActionPresence } from '../components-lib/ptz/types.js'; +import { Actions, PTZControlsConfig } from '../config/types.js'; import { localize } from '../localize/localize.js'; import ptzStyle from '../scss/ptz.scss'; import { frigateCardHasAction } from '../utils/action.js'; @@ -20,10 +21,7 @@ import { frigateCardHasAction } from '../utils/action.js'; @customElement('frigate-card-ptz') export class FrigateCardPTZ extends LitElement { @property({ attribute: false }) - public hass?: HomeAssistant; - - @property({ attribute: false }) - public config?: FrigateCardPTZConfig; + public config?: PTZControlsConfig; @property({ attribute: false }) public cameraManager?: CameraManager; @@ -35,20 +33,22 @@ export class FrigateCardPTZ extends LitElement { public forceVisibility?: boolean; protected _controller = new PTZController(this); + protected _actions = this._controller.getPTZActions(); + protected _actionPresence: PTZActionPresence | null = null; protected willUpdate(changedProps: PropertyValues): void { if (changedProps.has('config')) { this._controller.setConfig(this.config); } - if (changedProps.has('hass')) { - this._controller.setHASS(this.hass); - } if (changedProps.has('cameraManager') || changedProps.has('cameraID')) { this._controller.setCamera(this.cameraManager, this.cameraID); } if (changedProps.has('forceVisibility')) { this._controller.setForceVisibility(this.forceVisibility); } + if (changedProps.has('cameraID') || changedProps.has('cameraManager')) { + this._actionPresence = this._controller.hasUsefulAction(); + } } protected render(): TemplateResult | void { @@ -59,62 +59,48 @@ export class FrigateCardPTZ extends LitElement { const renderIcon = ( name: string, icon: string, - actions: Actions | null, + actions?: Actions | null, ): TemplateResult => { const classes = { [name]: true, disabled: !actions, }; - return html`) => - this._controller.handleAction(ev, actions)} - >`; + return actions + ? html`) => + this._controller.handleAction(ev, actions)} + >` + : html``; }; const config = this._controller.getConfig(); - const actionsZoomIn = this._controller.getPTZActions('zoom_in'); - const actionsZoomOut = this._controller.getPTZActions('zoom_out'); - const actionsHome = this._controller.getPTZActions('home'); - return html`
- ${!config?.hide_pan_tilt + ${!config?.hide_pan_tilt && this._actionPresence?.pt ? html`
- ${renderIcon( - 'right', - 'mdi:arrow-right', - this._controller.getPTZActions('right'), - )} - ${renderIcon( - 'left', - 'mdi:arrow-left', - this._controller.getPTZActions('left'), - )} - ${renderIcon('up', 'mdi:arrow-up', this._controller.getPTZActions('up'))} - ${renderIcon( - 'down', - 'mdi:arrow-down', - this._controller.getPTZActions('down'), - )} + ${renderIcon('right', 'mdi:arrow-right', this._actions.right)} + ${renderIcon('left', 'mdi:arrow-left', this._actions.left)} + ${renderIcon('up', 'mdi:arrow-up', this._actions.up)} + ${renderIcon('down', 'mdi:arrow-down', this._actions.down)}
` : ''} - ${!config?.hide_zoom && (actionsZoomIn || actionsZoomOut) + ${!config?.hide_zoom && this._actionPresence?.z ? html`
- ${renderIcon('zoom_in', 'mdi:plus', actionsZoomIn)} - ${renderIcon('zoom_out', 'mdi:minus', actionsZoomOut)} + ${renderIcon('zoom_in', 'mdi:plus', this._actions.zoom_in)} + ${renderIcon('zoom_out', 'mdi:minus', this._actions.zoom_out)}
` : html``} - ${!config?.hide_home && actionsHome - ? html` -
${renderIcon('home', 'mdi:home', actionsHome)}
- ` + ${!config?.hide_home && this._actionPresence?.home + ? html`
+ ${renderIcon('home', 'mdi:home', this._actions.home)} +
` : html``}
`; } diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 1d3b4694..d8deceda 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -12,8 +12,8 @@ import { ifDefined } from 'lit/directives/if-defined.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { CameraManager } from '../camera-manager/manager.js'; import { MediaGridSelected } from '../components-lib/media-grid-controller.js'; -import { ZoomDefault } from '../components-lib/zoom/types.js'; -import { handleZoomDefaultEvent } from '../components-lib/zoom/zoom-view-context.js'; +import { ZoomSettingsObserved } from '../components-lib/zoom/types.js'; +import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-view-context.js'; import { dispatchMessageEvent, renderMessage, @@ -76,6 +76,7 @@ import { VideoContentType, ViewMedia } from '../view/media.js'; import { View } from '../view/view.js'; import type { EmblaCarouselPlugins } from './carousel.js'; import './next-prev-control.js'; +import './ptz'; import './surround.js'; import './title-control.js'; import { @@ -501,6 +502,13 @@ export class FrigateCardViewerCarousel extends LitElement { }} > + ${this.view + ? html` + ` + : ''}
@@ -876,7 +884,7 @@ export class FrigateCardViewerProvider return this.viewerConfig?.zoomable ? html` + .defaultSettings=${guard([cameraConfig?.dimensions?.layout], () => cameraConfig?.dimensions?.layout ? { pan: cameraConfig.dimensions.layout.pan, @@ -884,11 +892,13 @@ export class FrigateCardViewerProvider } : undefined, )} - .config=${mediaID ? this.view?.context?.zoom?.[mediaID]?.zoom : undefined} + .settings=${mediaID + ? this.view?.context?.zoom?.[mediaID]?.requested + : undefined} @frigate-card:zoom:zoomed=${() => this.setControls(false)} @frigate-card:zoom:unzoomed=${() => this.setControls()} - @frigate-card:zoom:default=${(ev: CustomEvent) => - handleZoomDefaultEvent(this, ev, mediaID)} + @frigate-card:zoom:change=${(ev: CustomEvent) => + handleZoomSettingsObservedEvent(this, ev, mediaID)} > ${template} ` @@ -995,6 +1005,6 @@ declare global { 'frigate-card-viewer-carousel': FrigateCardViewerCarousel; 'frigate-card-viewer': FrigateCardViewer; 'frigate-card-viewer-grid': FrigateCardViewerGrid; - FRIGATE_CARD_VIEWER_PROVIDER: FrigateCardViewerProvider; + 'frigate-card-viewer-provider': FrigateCardViewerProvider; } } diff --git a/src/components/views.ts b/src/components/views.ts index f5d08c0e..eefb89bc 100644 --- a/src/components/views.ts +++ b/src/components/views.ts @@ -171,7 +171,7 @@ export class FrigateCardViews extends LitElement { .view=${this.view} .hass=${this.hass} .cameraConfig=${cameraConfig} - .supportZoom=${true} + .cameraManager=${this.cameraManager} > ` : ``} diff --git a/src/components/zoomer.ts b/src/components/zoomer.ts index 7c690060..9a44c838 100644 --- a/src/components/zoomer.ts +++ b/src/components/zoomer.ts @@ -7,19 +7,19 @@ import { TemplateResult, } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; -import { ZoomConfig } from '../components-lib/zoom/types.js'; import { ZoomController } from '../components-lib/zoom/zoom-controller.js'; import { setOrRemoveAttribute } from '../utils/basic.js'; +import { PartialZoomSettings } from '../components-lib/zoom/types.js'; @customElement('frigate-card-zoomer') export class FrigateCardZoomer extends LitElement { protected _zoom: ZoomController | null = null; @property({ attribute: false }) - public defaultConfig?: ZoomConfig; + public defaultSettings?: PartialZoomSettings; @property({ attribute: false }) - public config?: ZoomConfig | null; + public settings?: PartialZoomSettings | null; @state() protected _zoomed = false; @@ -48,19 +48,19 @@ export class FrigateCardZoomer extends LitElement { } if (this._zoom) { - if (changedProps.has('defaultConfig')) { - this._zoom.setDefaultConfig(this.defaultConfig ?? null); + if (changedProps.has('defaultSettings')) { + this._zoom.setDefaultSettings(this.defaultSettings ?? null); } // If config is null, make no change to the zoom. - if (changedProps.has('config') && this.config) { - this._zoom.setConfig(this.config); + if (changedProps.has('settings') && this.settings) { + this._zoom.setSettings(this.settings); } } else { // Ensure that the configuration will be set before activation (vs // activating in `connectedCallback`). this._zoom = new ZoomController(this, { - config: this.config, - defaultConfig: this.defaultConfig, + config: this.settings, + defaultConfig: this.defaultSettings, }); this._zoom.activate(); } diff --git a/src/config/keyboard-shortcuts.ts b/src/config/keyboard-shortcuts.ts new file mode 100644 index 00000000..3c43ea20 --- /dev/null +++ b/src/config/keyboard-shortcuts.ts @@ -0,0 +1,47 @@ +import { z } from 'zod'; + +const keyboardShortcut = z.object({ + key: z.string(), + ctrl: z.boolean().optional(), + shift: z.boolean().optional(), + alt: z.boolean().optional(), + meta: z.boolean().optional(), +}); +export type KeyboardShortcut = z.infer; + +export const keyboardShortcutsDefault = { + enabled: true, + ptz_left: { key: 'ArrowLeft' }, + ptz_right: { key: 'ArrowRight' }, + ptz_up: { key: 'ArrowUp' }, + ptz_down: { key: 'ArrowDown' }, + ptz_zoom_in: { key: '+' }, + ptz_zoom_out: { key: '-' }, + ptz_home: { key: 'h' }, +}; + +export const keyboardShortcutsSchema = z.object({ + enabled: z.boolean().default(keyboardShortcutsDefault.enabled), + ptz_left: keyboardShortcut.nullable().default(keyboardShortcutsDefault.ptz_left), + ptz_right: keyboardShortcut.nullable().default(keyboardShortcutsDefault.ptz_right), + ptz_up: keyboardShortcut.nullable().default(keyboardShortcutsDefault.ptz_up), + ptz_down: keyboardShortcut.nullable().default(keyboardShortcutsDefault.ptz_down), + ptz_zoom_in: keyboardShortcut.nullable().default(keyboardShortcutsDefault.ptz_zoom_in), + ptz_zoom_out: keyboardShortcut + .nullable() + .default(keyboardShortcutsDefault.ptz_zoom_out), + ptz_home: keyboardShortcut.nullable().default(keyboardShortcutsDefault.ptz_home), +}); +export type KeyboardShortcuts = z.infer; + + +const KEYBOARD_SHORTCUT_PTZ_NAMES = [ + 'ptz_down', + 'ptz_home', + 'ptz_left', + 'ptz_right', + 'ptz_up', + 'ptz_zoom_in', + 'ptz_zoom_out', +] as const; +export type PTZKeyboardShortcutName = (typeof KEYBOARD_SHORTCUT_PTZ_NAMES)[number]; diff --git a/src/config/management.ts b/src/config/management.ts index 454826ee..a5a0c39e 100644 --- a/src/config/management.ts +++ b/src/config/management.ts @@ -14,6 +14,7 @@ import { CONF_CAMERAS_GLOBAL_DIMENSIONS_LAYOUT, CONF_CAMERAS_GLOBAL_IMAGE, CONF_CAMERAS_GLOBAL_JSMPEG, + CONF_CAMERAS_GLOBAL_PTZ, CONF_CAMERAS_GLOBAL_WEBRTC_CARD, CONF_ELEMENTS, CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE, @@ -545,6 +546,116 @@ const upgradePTZElementsToLive = function (): (data: unknown) => boolean { }; }; +const ptzActionsToCamerasGlobalTransform = (data: unknown): unknown => { + if (typeof data !== 'object' || !data) { + return undefined; + } + + const NON_PRESET_DATA_KEYS = [ + 'data_left', + 'data_right', + 'data_up', + 'data_down', + 'data_zoom_in', + 'data_zoom_out', + 'service', + ]; + + const NON_PRESET_ACTION_KEYS = [ + // 'actions_' will overwrite 'data_*' if there's duplication. + 'actions_left', + 'actions_right', + 'actions_up', + 'actions_down', + 'actions_zoom_in', + 'actions_zoom_out', + ]; + + const PRESET_TRANSFORM_KEYS = ['data_home', 'actions_home']; + const TRANSFORM_KEYS = [ + ...NON_PRESET_DATA_KEYS, + ...NON_PRESET_ACTION_KEYS, + ...PRESET_TRANSFORM_KEYS, + ]; + + const keys = Object.keys(data); + const hasTransformable = keys.some((key) => TRANSFORM_KEYS.includes(key)); + if (!hasTransformable) { + return undefined; + } + + const output = {}; + + NON_PRESET_DATA_KEYS.filter((key) => key in data).reduce((obj, key) => { + obj[key] = data[key]; + return obj; + }, output); + + NON_PRESET_ACTION_KEYS.filter((key) => key in data).reduce((obj, key) => { + if (typeof data[key] === 'object' && 'tap_action' in data[key]) { + obj[key] = data[key]['tap_action']; + } + return obj; + }, output); + + const createPresets = () => { + output['presets'] = + 'presets' in data && typeof data['presets'] === 'object' && !!data['presets'] + ? data['presets'] + : {}; + }; + + if ( + 'actions_home' in data && + typeof data['actions_home'] === 'object' && + data['actions_home'] && + 'tap_action' in data['actions_home'] + ) { + createPresets(); + output['presets']['home'] = data['actions_home']['tap_action']; + } else if ( + 'data_home' in data && + typeof data['data_home'] === 'object' && + data['data_home'] && + typeof data['service'] === 'string' + ) { + createPresets(); + output['presets']['service'] = data['service']; + output['presets']['data_home'] = data['data_home']; + } + + return output; +}; + +const ptzControlSettingsTransform = (data: unknown): unknown => { + if (typeof data !== 'object' || !data) { + return data; + } + + const TRANSFORM_KEYS = [ + 'mode', + 'position', + 'orientation', + 'hide_pan_tilt', + 'hide_zoom', + 'hide_home', + 'style', + ]; + + const keys = Object.keys(data); + const hasSomethingToFilter = keys.some((key) => !TRANSFORM_KEYS.includes(key)); + if (!hasSomethingToFilter) { + return undefined; + } + + return keys + .filter((key) => TRANSFORM_KEYS.includes(key)) + .reduce((obj, key) => { + obj[key] = data[key]; + return obj; + }, {}); +}; + const UPGRADES = [ // v4.0.0 -> v4.1.0 upgradeArrayOfObjects( @@ -681,7 +792,7 @@ const UPGRADES = [ upgradeArrayOfObjects( CONF_CAMERAS, upgradeMoveToWithOverrides('hide', 'capabilities', { - transform: (val) => (val === true ? { disable_except: 'substream' } : null), + transform: (val) => (val === true ? { disable_except: ['substream'] } : null), }), ), upgradeMoveToWithOverrides('performance.profile', CONF_PROFILES, { @@ -689,4 +800,9 @@ const UPGRADES = [ transform: (val) => (val === 'low' ? ['low-performance'] : null), }), upgradeArrayOfObjects(CONF_OVERRIDES, upgradeMoveTo('overrides', 'merge')), + upgradeMoveToWithOverrides('live.controls.ptz', CONF_CAMERAS_GLOBAL_PTZ, { + transform: ptzActionsToCamerasGlobalTransform, + keepOriginal: true, + }), + upgradeWithOverrides('live.controls.ptz', ptzControlSettingsTransform), ]; diff --git a/src/config/ptz.ts b/src/config/ptz.ts new file mode 100644 index 00000000..3dd77e98 --- /dev/null +++ b/src/config/ptz.ts @@ -0,0 +1,12 @@ +export const PTZ_PAN_TILT_ACTIONS = ['left', 'right', 'up', 'down'] as const; +export const PTZ_ZOOM_ACTIONS = ['zoom_in', 'zoom_out'] as const; +const PTZ_BASE_ACTIONS = [...PTZ_PAN_TILT_ACTIONS, ...PTZ_ZOOM_ACTIONS] as const; +export type PTZBaseAction = (typeof PTZ_BASE_ACTIONS)[number]; + +// PTZ actions as used by the PTZ control (includes a 'home' button). +const PTZ_CONTROL_ACTIONS = [...PTZ_BASE_ACTIONS, 'home'] as const; +export type PTZControlAction = (typeof PTZ_CONTROL_ACTIONS)[number]; + +// PTZ actions as used by the camera manager (includes generic presets). +export const PTZ_ACTIONS = [...PTZ_BASE_ACTIONS, 'preset'] as const; +export type PTZAction = (typeof PTZ_ACTIONS)[number]; diff --git a/src/config/types.ts b/src/config/types.ts index 6f2d6dd3..fdf7b372 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -11,6 +11,11 @@ import { z } from 'zod'; import { MEDIA_CHUNK_SIZE_DEFAULT, MEDIA_CHUNK_SIZE_MAX } from '../const.js'; import { capabilityKeys } from '../types.js'; import { deepRemoveDefaults } from '../utils/zod.js'; +import { + keyboardShortcutsDefault, + keyboardShortcutsSchema, +} from './keyboard-shortcuts.js'; +import { PTZ_ACTIONS } from './ptz'; // ************************************************************************* // Common Configuration Constants @@ -23,6 +28,7 @@ const FRIGATE_MENU_PRIORITY_DEFAULT = 50; export const FRIGATE_MENU_PRIORITY_MAX = 100; export const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [ + 'diagnostics', 'live', 'clip', 'clips', @@ -67,18 +73,8 @@ export const MEDIA_MUTE_CONDITIONS = [ ] as const; export type AutoMuteCondition = (typeof MEDIA_MUTE_CONDITIONS)[number]; -const PTZ_BASE_ACTIONS = ['left', 'right', 'up', 'down', 'zoom_in', 'zoom_out'] as const; - -// PTZ actions as used by the PTZ control (includes a 'home' button). -export const PTZ_CONTROL_ACTIONS = [...PTZ_BASE_ACTIONS, 'home'] as const; -export type PTZControlAction = (typeof PTZ_CONTROL_ACTIONS)[number]; - -// PTZ actions as used by the camera manager (includes generic presets). -const PTZ_ACTIONS = [...PTZ_BASE_ACTIONS, 'preset'] as const; -export type PTZAction = (typeof PTZ_ACTIONS)[number]; - -const PTZ_PHASES = ['start', 'stop'] as const; -export type PTZPhase = (typeof PTZ_PHASES)[number]; +const ACTION_PHASES = ['start', 'stop'] as const; +export type ActionPhase = (typeof ACTION_PHASES)[number]; const CAMERA_TRIGGER_EVENT_TYPES = [ // An event whether or not it has any media yet associated with it. @@ -90,15 +86,20 @@ const CAMERA_TRIGGER_EVENT_TYPES = [ ] as const; export type CameraTriggerEventType = (typeof CAMERA_TRIGGER_EVENT_TYPES)[number]; +const cardIDRegex = /^[-\w]+$/; + // ************************************************************************* // Pan / Zoom // ************************************************************************* +export const ZOOM_MIN = 1; +export const ZOOM_MAX = 10; + const panSchema = z.object({ x: z.number().min(0).max(100).optional(), y: z.number().min(0).max(100).optional(), }); -const zoomSchema = z.number().min(1).max(10); +const zoomSchema = z.number().min(ZOOM_MIN).max(ZOOM_MAX); // ************************************************************************* // View Display Mode @@ -224,7 +225,7 @@ export const frigateCardCustomActionsBaseSchema = customActionSchema.extend({ // Card this command is intended for. card_id: z .string() - .regex(/^\w+$/, 'card_id parameter can only contain [a-z][A-Z][0-9_]') + .regex(cardIDRegex, 'card_id parameter can only contain [a-z][A-Z][0-9_]-') .optional(), }); @@ -235,7 +236,6 @@ export const frigateCardCustomActionsBaseSchema = customActionSchema.extend({ const FRIGATE_CARD_GENERAL_ACTIONS = [ 'camera_ui', 'default', - 'diagnostics', 'download', 'expand', 'fullscreen', @@ -252,70 +252,120 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [ ] as const; export type FrigateCardGeneralAction = (typeof FRIGATE_CARD_GENERAL_ACTIONS)[number]; -const frigateCardViewActionSchema = frigateCardCustomActionsBaseSchema.extend({ +const viewActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({ frigate_card_action: z.enum(FRIGATE_CARD_VIEWS_USER_SPECIFIED), }); -export type FrigateCardViewAction = z.infer; +export type ViewActionConfig = z.infer; -const frigateCardGeneralActionSchema = frigateCardCustomActionsBaseSchema.extend({ +const generalActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({ frigate_card_action: z.enum(FRIGATE_CARD_GENERAL_ACTIONS), }); +export type GeneralActionConfig = z.infer; -const frigateCardCameraSelectActionSchema = frigateCardCustomActionsBaseSchema.extend({ +const cameraSelectActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({ frigate_card_action: z.literal('camera_select'), camera: z.string().optional(), triggered: z.boolean().optional(), }); +export type CameraSelectActionConfig = z.infer; -const frigateCardLiveDependencySelectActionSchema = - frigateCardCustomActionsBaseSchema.extend({ - frigate_card_action: z.literal('live_substream_select'), - camera: z.string(), - }); +const substreamSelectActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({ + frigate_card_action: z.literal('live_substream_select'), + camera: z.string(), +}); +export type SubstreamSelectActionConfig = z.infer< + typeof substreamSelectActionConfigSchema +>; -const frigateCardMediaPlayerActionSchema = frigateCardCustomActionsBaseSchema.extend({ +const mediaPlayerActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({ frigate_card_action: z.literal('media_player'), media_player: z.string(), media_player_action: z.enum(['play', 'stop']), }); +export type MediaPlayerActionConfig = z.infer; -const frigateCardViewDisplayModeActionSchema = frigateCardCustomActionsBaseSchema.extend( - { - frigate_card_action: z.literal('display_mode_select'), - display_mode: viewDisplayModeSchema, - }, -); +const viewDisplayModeActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({ + frigate_card_action: z.literal('display_mode_select'), + display_mode: viewDisplayModeSchema, +}); +export type DisplayModeActionConfig = z.infer; -const frigateCardPTZActionSchema = frigateCardCustomActionsBaseSchema.extend({ +const ptzActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({ frigate_card_action: z.literal('ptz'), - ptz_action: z.enum(PTZ_ACTIONS), - ptz_phase: z.enum(PTZ_PHASES).optional(), + camera: z.string().optional(), + ptz_action: z.enum(PTZ_ACTIONS).optional(), + ptz_phase: z.enum(ACTION_PHASES).optional(), ptz_preset: z.string().optional(), }); -export type FrigateCardPTZAction = z.infer; +export type PTZActionConfig = z.infer; -const frigateCardShowPTZActionSchema = frigateCardCustomActionsBaseSchema.extend({ - frigate_card_action: z.literal('show_ptz'), - show_ptz: z.boolean(), +const ptzDigitalActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({ + frigate_card_action: z.literal('ptz_digital'), + target_id: z.string().optional(), + absolute: z + .object({ + zoom: zoomSchema.optional(), + pan: panSchema.optional(), + }) + .optional(), + ptz_action: z.enum(PTZ_ACTIONS).optional(), + ptz_phase: z.enum(ACTION_PHASES).optional(), }); +export type PTZDigitialActionConfig = z.infer; -const frigateCardChangeZoomActionSchema = frigateCardCustomActionsBaseSchema.extend({ - frigate_card_action: z.literal('change_zoom'), - target_id: z.string(), - zoom: zoomSchema.optional(), - pan: panSchema.optional(), +const ptzMultiActionSchema = frigateCardCustomActionsBaseSchema.extend({ + frigate_card_action: z.literal('ptz_multi'), + target_id: z.string().optional(), + + ptz_action: z.enum(PTZ_ACTIONS).optional(), + ptz_phase: z.enum(ACTION_PHASES).optional(), + ptz_preset: z.string().optional(), }); +export type PTZMultiActionConfig = z.infer; + +const ptzControlsActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({ + frigate_card_action: z.literal('ptz_controls'), + enabled: z.boolean(), +}); +export type PTZControlsActionConfig = z.infer; + +const timeDeltaSchema = z.object({ + ms: z.number().optional(), + s: z.number().optional(), + m: z.number().optional(), + h: z.number().optional(), +}); +export type TimeDelta = z.infer; + +const sleepActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({ + frigate_card_action: z.literal('sleep'), + duration: timeDeltaSchema.optional().default({ s: 1 }), +}); +export type SleepActionConfig = z.infer; + +const LOG_ACTIONS_LEVELS = ['debug', 'info', 'warn', 'error'] as const; +export type LogActionLevel = (typeof LOG_ACTIONS_LEVELS)[number]; + +const logActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({ + frigate_card_action: z.literal('log'), + message: z.string(), + level: z.enum(LOG_ACTIONS_LEVELS).default('info'), +}); +export type LogActionConfig = z.infer; export const frigateCardCustomActionSchema = z.union([ - frigateCardCameraSelectActionSchema, - frigateCardChangeZoomActionSchema, - frigateCardGeneralActionSchema, - frigateCardLiveDependencySelectActionSchema, - frigateCardMediaPlayerActionSchema, - frigateCardPTZActionSchema, - frigateCardShowPTZActionSchema, - frigateCardViewActionSchema, - frigateCardViewDisplayModeActionSchema, + cameraSelectActionConfigSchema, + generalActionConfigSchema, + substreamSelectActionConfigSchema, + logActionConfigSchema, + mediaPlayerActionConfigSchema, + ptzActionConfigSchema, + ptzDigitalActionConfigSchema, + ptzMultiActionSchema, + ptzControlsActionConfigSchema, + viewActionConfigSchema, + viewDisplayModeActionConfigSchema, + sleepActionConfigSchema, ]); export type FrigateCardCustomAction = z.infer; @@ -572,6 +622,15 @@ const microphoneConditionSchema = z.object({ connected: z.boolean().optional(), muted: z.boolean().optional(), }); +const keyConditionSchema = z.object({ + condition: z.literal('key'), + key: z.string(), + state: z.enum(['down', 'up']).optional(), + ctrl: z.boolean().optional(), + shift: z.boolean().optional(), + alt: z.boolean().optional(), + meta: z.boolean().optional(), +}); export const frigateCardConditionSchema = z.discriminatedUnion('condition', [ // Stock conditions: @@ -590,6 +649,7 @@ export const frigateCardConditionSchema = z.discriminatedUnion('condition', [ triggeredConditionSchema, interactionConditionSchema, microphoneConditionSchema, + keyConditionSchema, ]); export type FrigateCardCondition = z.infer; @@ -664,6 +724,129 @@ const aspectRatioSchema = z .transform((input) => input.split(/[:\/]/).map((d) => Number(d))), ); +// ************************************************************************* +// PTZ Configuration +// ************************************************************************* + +const ptzCameraConfigDefaults = { + r2c_delay_between_calls_seconds: 0.5, + c2r_delay_between_calls_seconds: 0.2, +}; + +// To avoid lots of YAML duplication, provide an easy way to just specify the +// service data as actions for each PTZ action, and it will be preprocessed +// into the full form. This also provides compatability with the AlexIT/WebRTC +// PTZ configuration. +const dataPTZFormatToFullFormat = function ( + suffix: string, +): (data: unknown) => unknown { + return (data) => { + if (!data || typeof data !== 'object' || !data['service']) { + return data; + } + const out = { ...data }; + Object.keys(data).forEach((key) => { + const match = key.match(/^data_(.+)$/); + const name = match?.[1]; + if (name && !(`${suffix}${name}` in data)) { + out[`${suffix}${name}`] = { + action: 'call-service', + service: data['service'], + data: data[key], + }; + delete out[key]; + delete out['service']; + } + }); + return out; + }; +}; + +const ptzCameraConfigSchema = z.preprocess( + dataPTZFormatToFullFormat('actions_'), + z + .object({ + actions_left: callServiceActionSchema.optional(), + actions_left_start: callServiceActionSchema.optional(), + actions_left_stop: callServiceActionSchema.optional(), + + actions_right: callServiceActionSchema.optional(), + actions_right_start: callServiceActionSchema.optional(), + actions_right_stop: callServiceActionSchema.optional(), + + actions_up: callServiceActionSchema.optional(), + actions_up_start: callServiceActionSchema.optional(), + actions_up_stop: callServiceActionSchema.optional(), + + actions_down: callServiceActionSchema.optional(), + actions_down_start: callServiceActionSchema.optional(), + actions_down_stop: callServiceActionSchema.optional(), + + actions_zoom_in: callServiceActionSchema.optional(), + actions_zoom_in_start: callServiceActionSchema.optional(), + actions_zoom_in_stop: callServiceActionSchema.optional(), + + actions_zoom_out: callServiceActionSchema.optional(), + actions_zoom_out_start: callServiceActionSchema.optional(), + actions_zoom_out_stop: callServiceActionSchema.optional(), + + // The number of seconds between subsequent relative calls when converting a + // relative request into a continuous request. + r2c_delay_between_calls_seconds: z + .number() + .default(ptzCameraConfigDefaults.r2c_delay_between_calls_seconds), + + // The number of seconds between the start/stop call when converting a + // continuous request into a relative request. + c2r_delay_between_calls_seconds: z + .number() + .default(ptzCameraConfigDefaults.c2r_delay_between_calls_seconds), + + presets: z + .preprocess( + dataPTZFormatToFullFormat(''), + z.union([ + z.record(callServiceActionSchema), + + // This is used by the data_ style of action. + z.object({ service: z.string().optional() }), + ]), + ) + .optional(), + + // This is used by the data_ style of action. + service: z.string().optional(), + }) + // We allow passthrough as there may be user-configured presets as "actions_" . + .passthrough(), +); + +const ptzControlsDefaults = { + orientation: 'horizontal' as const, + mode: 'auto' as const, + hide_pan_tilt: false, + hide_zoom: false, + hide_home: false, + position: 'bottom-right' as const, +}; + +export const ptzControlsConfigSchema = z.object({ + mode: z.enum(['off', 'auto', 'on']).default(ptzControlsDefaults.mode), + position: z + .enum(['top-left', 'top-right', 'bottom-left', 'bottom-right']) + .default(ptzControlsDefaults.position), + orientation: z + .enum(['vertical', 'horizontal']) + .default(ptzControlsDefaults.orientation), + + hide_pan_tilt: z.boolean().default(ptzControlsDefaults.hide_pan_tilt), + hide_zoom: z.boolean().default(ptzControlsDefaults.hide_zoom), + hide_home: z.boolean().default(ptzControlsDefaults.hide_home), + + style: z.object({}).passthrough().optional(), +}); +export type PTZControlsConfig = z.infer; + // ************************************************************************* // Image Configuration // Image config base options are used both for the `image` live provider and the @@ -683,13 +866,15 @@ const IMAGE_MODES = ['screensaver', 'camera', 'url'] as const; const imageConfigDefault = { mode: 'url' as const, zoomable: true, + controls: { + ptz: ptzControlsDefaults, + }, ...imageBaseConfigDefault, }; const imageConfigSchema = imageBaseConfigSchema .extend({ mode: z.enum(IMAGE_MODES).default(imageConfigDefault.mode), - zoomable: z.boolean().default(imageConfigDefault.zoomable), }) .merge(actionsSchema) .default(imageConfigDefault); @@ -929,70 +1114,6 @@ const jsmpegConfigSchema = z.object({ .optional(), }); -const frigateCardPTZActions = z.object({ - actions_left: actionsBaseSchema.optional(), - actions_right: actionsBaseSchema.optional(), - actions_up: actionsBaseSchema.optional(), - actions_down: actionsBaseSchema.optional(), - actions_zoom_in: actionsBaseSchema.optional(), - actions_zoom_out: actionsBaseSchema.optional(), - actions_home: actionsBaseSchema.optional(), -}); -export type FrigateCardPTZActions = z.infer; - -const livePTZControlsDefaults = { - orientation: 'horizontal' as const, - mode: 'on' as const, - hide_pan_tilt: false, - hide_zoom: false, - hide_home: false, - position: 'bottom-right' as const, -}; - -export const frigateCardPTZSchema = z.preprocess( - // To avoid lots of YAML duplication, provide an easy way to just specify the - // service data as actions for each PTZ icon, and it will be preprocessed into - // the full form. This also provides compatability with the AlexIT/WebRTC PTZ - // configuration. - (data) => { - if (!data || typeof data !== 'object' || !data['service']) { - return data; - } - const out = { ...data }; - PTZ_CONTROL_ACTIONS.forEach((name) => { - if (`data_${name}` in data && !(`actions_${name}` in data)) { - out[`actions_${name}`] = { - tap_action: { - action: 'call-service', - service: data['service'], - data: data[`data_${name}`], - }, - }; - delete out[`data_${name}`]; - } - }); - return out; - }, - frigateCardPTZActions.extend({ - mode: z.enum(['off', 'on']).default(livePTZControlsDefaults.mode), - position: z - .enum(['top-left', 'top-right', 'bottom-left', 'bottom-right']) - .default(livePTZControlsDefaults.position), - - orientation: z - .enum(['vertical', 'horizontal']) - .default(livePTZControlsDefaults.orientation), - - hide_pan_tilt: z.boolean().default(livePTZControlsDefaults.hide_pan_tilt), - hide_zoom: z.boolean().default(livePTZControlsDefaults.hide_zoom), - hide_home: z.boolean().default(livePTZControlsDefaults.hide_home), - - service: z.string().optional(), - style: z.object({}).passthrough().optional(), - }), -); -export type FrigateCardPTZConfig = z.infer; - const liveThumbnailControlsDefaults = { ...thumbnailControlsDefaults, media_type: 'events' as const, @@ -1018,7 +1139,7 @@ const liveConfigDefault = { size: 48, style: 'chevrons' as const, }, - ptz: livePTZControlsDefaults, + ptz: ptzControlsDefaults, thumbnails: liveThumbnailControlsDefaults, timeline: miniTimelineConfigDefault, }, @@ -1068,7 +1189,7 @@ const liveConfigSchema = z ), }) .default(liveConfigDefault.controls.next_previous), - ptz: frigateCardPTZSchema.default(liveConfigDefault.controls.ptz), + ptz: ptzControlsConfigSchema.default(liveConfigDefault.controls.ptz), thumbnails: livethumbnailsControlSchema.default( liveConfigDefault.controls.thumbnails, ), @@ -1154,6 +1275,7 @@ const cameraConfigDefault = { file_pattern: '%H-%M-%S' as const, }, }, + ptz: ptzCameraConfigDefaults, triggers: { motion: false, occupancy: false, @@ -1251,6 +1373,8 @@ export const cameraConfigSchema = z cast: castSchema.optional(), + ptz: ptzCameraConfigSchema.default(cameraConfigDefault.ptz), + dimensions: z .object({ aspect_ratio: aspectRatioSchema.optional(), @@ -1289,6 +1413,7 @@ const viewConfigDefault = { }, untrigger_seconds: 0, }, + keyboard_shortcuts: keyboardShortcutsDefault, }; export const triggersSchema = z.object({ @@ -1335,6 +1460,9 @@ const viewConfigSchema = z render_entities: z.string().array().optional(), dark_mode: z.enum(['on', 'off', 'auto']).optional(), triggers: triggersSchema.default(viewConfigDefault.triggers), + keyboard_shortcuts: keyboardShortcutsSchema.default( + viewConfigDefault.keyboard_shortcuts, + ), }) .merge(actionsSchema) .default(viewConfigDefault); @@ -1371,7 +1499,7 @@ const menuConfigDefault = { camera_ui: visibleButtonDefault, cameras: visibleButtonDefault, clips: visibleButtonDefault, - default_zoom: visibleButtonDefault, + ptz_home: visibleButtonDefault, display_mode: visibleButtonDefault, download: visibleButtonDefault, expand: hiddenButtonDefault, @@ -1386,7 +1514,7 @@ const menuConfigDefault = { }, mute: hiddenButtonDefault, play: hiddenButtonDefault, - ptz: hiddenButtonDefault, + ptz_controls: hiddenButtonDefault, recordings: hiddenButtonDefault, screenshot: hiddenButtonDefault, snapshots: visibleButtonDefault, @@ -1417,8 +1545,8 @@ export const menuConfigSchema = z camera_ui: visibleButtonSchema.default(menuConfigDefault.buttons.camera_ui), cameras: visibleButtonSchema.default(menuConfigDefault.buttons.cameras), clips: visibleButtonSchema.default(menuConfigDefault.buttons.clips), - default_zoom: visibleButtonSchema.default( - menuConfigDefault.buttons.default_zoom, + ptz_home: visibleButtonSchema.default( + menuConfigDefault.buttons.ptz_home, ), display_mode: visibleButtonSchema.default( menuConfigDefault.buttons.display_mode, @@ -1441,7 +1569,7 @@ export const menuConfigSchema = z .default(menuConfigDefault.buttons.microphone), mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute), play: hiddenButtonSchema.default(menuConfigDefault.buttons.play), - ptz: hiddenButtonSchema.default(menuConfigDefault.buttons.ptz), + ptz_controls: hiddenButtonSchema.default(menuConfigDefault.buttons.ptz_controls), recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings), screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot), snapshots: visibleButtonSchema.default(menuConfigDefault.buttons.snapshots), @@ -1477,6 +1605,10 @@ const viewerConfigDefault = { }, thumbnails: thumbnailControlsDefaults, timeline: miniTimelineConfigDefault, + ptz: { + ...ptzControlsDefaults, + mode: 'off' as const, + } }, }; @@ -1526,6 +1658,10 @@ const viewerConfigSchema = z next_previous: viewerNextPreviousControlConfigSchema.default( viewerConfigDefault.controls.next_previous, ), + ptz: ptzControlsConfigSchema.extend({ + // The media_viewer ptz has no 'auto' mode. + mode: z.enum(['off', 'on']).default(viewerConfigDefault.controls.ptz.mode), + }).default(viewerConfigDefault.controls.ptz), thumbnails: thumbnailsControlSchema.default( viewerConfigDefault.controls.thumbnails, ), @@ -1634,8 +1770,7 @@ const automationSchema = z.object({ }); export type Automation = z.infer; -const automationsSchema = automationSchema.array().optional(); -export type Automations = z.infer; +const automationsSchema = automationSchema.array(); // ************************************************************************* // Performance Configuration @@ -1727,7 +1862,7 @@ export const frigateCardConfigSchema = z.object({ timeline: timelineConfigSchema, performance: performanceConfigSchema, debug: debugConfigSchema, - automations: automationsSchema, + automations: automationsSchema.optional(), profiles: profilesSchema, @@ -1739,7 +1874,7 @@ export const frigateCardConfigSchema = z.object({ // Card ID (used for query string commands). Restrict contents to only values // that be easily used in a URL. - card_id: z.string().regex(/^\w+$/).optional(), + card_id: z.string().regex(cardIDRegex).optional(), // Stock lovelace card config. type: z.string(), diff --git a/src/const.ts b/src/const.ts index b3d9c9ec..0593f1e5 100644 --- a/src/const.ts +++ b/src/const.ts @@ -57,7 +57,7 @@ export const CONF_CAMERAS_ARRAY_DEPENDENCIES_ALL_CAMERAS = `${CONF_CAMERAS}.#.dependencies.all_cameras` as const; export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_FIT = `${CONF_CAMERAS}.#.dimensions.layout.fit` as const; - export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_X = +export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_X = `${CONF_CAMERAS}.#.dimensions.layout.pan.x` as const; export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_Y = `${CONF_CAMERAS}.#.dimensions.layout.pan.y` as const; @@ -97,6 +97,7 @@ export const CONF_CAMERAS_GLOBAL_IMAGE_REFRESH_SECONDS = `${CONF_CAMERAS_GLOBAL}.image.refresh_seconds` as const; export const CONF_CAMERAS_GLOBAL_DIMENSIONS_LAYOUT = `${CONF_CAMERAS_GLOBAL}.dimensions.layout` as const; +export const CONF_CAMERAS_GLOBAL_PTZ = `${CONF_CAMERAS_GLOBAL}.ptz` as const; export const CONF_ELEMENTS = 'elements' as const; @@ -105,6 +106,23 @@ export const CONF_VIEW_CAMERA_SELECT = `${CONF_VIEW}.camera_select` as const; export const CONF_VIEW_DARK_MODE = `${CONF_VIEW}.dark_mode` as const; export const CONF_VIEW_DEFAULT = `${CONF_VIEW}.default` as const; export const CONF_VIEW_INTERACTION_SECONDS = `${CONF_VIEW}.interaction_seconds` as const; +export const CONF_VIEW_KEYBOARD_SHORTCUTS = `${CONF_VIEW}.keyboard_shortcuts` as const; +export const CONF_VIEW_KEYBOARD_SHORTCUTS_ENABLED = + `${CONF_VIEW}.keyboard_shortcuts.enabled` as const; +export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_LEFT = + `${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_left` as const; +export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_RIGHT = + `${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_right` as const; +export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_UP = + `${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_up` as const; +export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_DOWN = + `${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_down` as const; +export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_IN = + `${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_zoom_in` as const; +export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_OUT = + `${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_zoom_out` as const; +export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_HOME = + `${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_home` as const; export const CONF_VIEW_UPDATE_CYCLE_CAMERA = `${CONF_VIEW}.update_cycle_camera` as const; export const CONF_VIEW_UPDATE_FORCE = `${CONF_VIEW}.update_force` as const; export const CONF_VIEW_UPDATE_SECONDS = `${CONF_VIEW}.update_seconds` as const; diff --git a/src/declarations.d.ts b/src/declarations.d.ts index 9fd8e18c..6563f776 100644 --- a/src/declarations.d.ts +++ b/src/declarations.d.ts @@ -5,3 +5,7 @@ declare module 'view' { // eslint-disable-next-line @typescript-eslint/no-empty-interface interface ViewContext {} } +declare module 'action' { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface ActionContext {} +} diff --git a/src/editor.ts b/src/editor.ts index f79390e8..2c378b42 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -192,6 +192,15 @@ import { CONF_VIEW_DARK_MODE, CONF_VIEW_DEFAULT, CONF_VIEW_INTERACTION_SECONDS, + CONF_VIEW_KEYBOARD_SHORTCUTS, + CONF_VIEW_KEYBOARD_SHORTCUTS_ENABLED, + CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_DOWN, + CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_HOME, + CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_LEFT, + CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_RIGHT, + CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_UP, + CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_IN, + CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_OUT, CONF_VIEW_RESET_AFTER_INTERACTION, CONF_VIEW_TRIGGERS, CONF_VIEW_TRIGGERS_ACTIONS, @@ -215,6 +224,8 @@ import { getEntityTitle, sideLoadHomeAssistantElements, } from './utils/ha'; +import './components/key-assigner.js'; +import { KeyboardShortcut } from './config/keyboard-shortcuts.js'; const MENU_BUTTONS = 'buttons'; const MENU_CAMERAS = 'cameras'; @@ -251,6 +262,7 @@ const MENU_OPTIONS = 'options'; const MENU_PERFORMANCE_FEATURES = 'performance.features'; const MENU_PERFORMANCE_STYLE = 'performance.style'; const MENU_TIMELINE_CONTROLS_THUMBNAILS = 'timeline.controls.thumbnails'; +const MENU_VIEW_KEYBOARD_SHORTCUTS = 'view.keyboard_shortcuts'; const MENU_VIEW_TRIGGERS = 'view.triggers'; const MENU_VIEW_TRIGGERS_ACTIONS = 'view.triggers.actions'; @@ -1111,6 +1123,60 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ); } + protected _renderKeyAssigner(configPath: string, defaultValue: KeyboardShortcut): TemplateResult { + return html` this._valueChangedHandler(configPath, ev)} + >`; + } + + protected _renderViewKeyboardShortcutMenu(): TemplateResult { + return this._putInSubmenu( + MENU_VIEW_KEYBOARD_SHORTCUTS, + true, + `config.${CONF_VIEW_KEYBOARD_SHORTCUTS}.editor_label`, + { name: 'mdi:keyboard' }, + html` + ${this._renderSwitch( + CONF_VIEW_KEYBOARD_SHORTCUTS_ENABLED, + this._defaults.view.keyboard_shortcuts.enabled, + { + label: localize(`config.${CONF_VIEW_KEYBOARD_SHORTCUTS_ENABLED}`), + }, + )} + ${this._renderKeyAssigner( + CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_LEFT, + this._defaults.view.keyboard_shortcuts.ptz_left, + )} + ${this._renderKeyAssigner( + CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_RIGHT, + this._defaults.view.keyboard_shortcuts.ptz_right, + )} + ${this._renderKeyAssigner( + CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_UP, + this._defaults.view.keyboard_shortcuts.ptz_up, + )} + ${this._renderKeyAssigner( + CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_DOWN, + this._defaults.view.keyboard_shortcuts.ptz_down, + )} + ${this._renderKeyAssigner( + CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_IN, + this._defaults.view.keyboard_shortcuts.ptz_zoom_in, + )} + ${this._renderKeyAssigner( + CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_OUT, + this._defaults.view.keyboard_shortcuts.ptz_zoom_out, + )} + ${this._renderKeyAssigner( + CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_HOME, + this._defaults.view.keyboard_shortcuts.ptz_home, + )} + `, + ); + } + /** * Render an editor menu for the card menu buttons. * @param button The name of the button. @@ -2272,6 +2338,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor this._defaults.view.update_cycle_camera, )} ${this._renderViewTriggersMenu()} + ${this._renderViewKeyboardShortcutMenu()}
` : ''} @@ -2311,8 +2378,8 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderMenuButton('mute') /* */} ${this._renderMenuButton('screenshot')} ${this._renderMenuButton('display_mode')} - ${this._renderMenuButton('ptz')} - ${this._renderMenuButton('default_zoom')} + ${this._renderMenuButton('ptz_controls')} + ${this._renderMenuButton('ptz_home')} ` : ''} @@ -2787,9 +2854,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor this._updateConfig(newConfig); } - /** - * Return compiled CSS styles. - */ static get styles(): CSSResultGroup { return unsafeCSS(frigate_card_editor_style); } diff --git a/src/localize/languages/ca.json b/src/localize/languages/ca.json index f6bd7c74..fbbc3de0 100644 --- a/src/localize/languages/ca.json +++ b/src/localize/languages/ca.json @@ -362,7 +362,6 @@ "camera_ui": "Interfície d'usuari de la càmera", "cameras": "Càmeres", "clips": "Clips", - "default_zoom": "", "display_mode": "Mode de visualització", "download": "Descarregar", "enabled": "Botó habilitat", @@ -377,7 +376,8 @@ "mute": "Silenciar / Activar el so", "play": "Reproduir / Pausa", "priority": "Prioritat", - "ptz": "", + "ptz_controls": "", + "ptz_home": "", "recordings": "Enregistraments", "screenshot": "Captura de pantalla", "snapshots": "Imatges instantànies", @@ -437,6 +437,10 @@ }, "default": "Vista per defecte", "interaction_seconds": "Segons després de l'acció de l'usuari per continuar interactuant (0=mai)", + "keyboard_shortcuts": { + "editor_label": "", + "enabled": "" + }, "reset_after_interaction": "Restableix la vista predeterminada després de la interacció de l'usuari", "triggers": { "actions": { @@ -580,6 +584,17 @@ "what": "Què", "where": "On" }, + "key_assigner": { + "assign": "", + "assigning": "", + "modifiers": { + "alt": "", + "ctrl": "", + "meta": "", + "shift": "" + }, + "unassign": "" + }, "media_filter": { "all": "Tots", "camera": "Càmera", @@ -630,4 +645,4 @@ "timeline": { "select_date": "Escolliu la data" } -} +} \ No newline at end of file diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index d145acab..b67be927 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -362,7 +362,7 @@ "camera_ui": "Camera user interface", "cameras": "Cameras", "clips": "Clips", - "default_zoom": "Zoom to default", + "ptz_home": "PTZ Home", "display_mode": "Display mode", "download": "Download", "enabled": "Button enabled", @@ -377,7 +377,7 @@ "mute": "Mute / Unmute", "play": "Play / Pause", "priority": "Priority", - "ptz": "Show PTZ controls", + "ptz_controls": "Show PTZ controls", "recordings": "Recordings", "screenshot": "Screenshot", "snapshots": "Snapshots", @@ -437,6 +437,17 @@ }, "default": "Default view", "interaction_seconds": "Seconds after user action to remain interacted with (0=never)", + "keyboard_shortcuts": { + "editor_label": "Keyboard shortcuts", + "enabled": "Keyboard shortcuts enabled", + "ptz_down": "PTZ Down", + "ptz_home": "PTZ Home", + "ptz_left": "PTZ Left", + "ptz_right": "PTZ Right", + "ptz_up": "PTZ Up", + "ptz_zoom_in": "PTZ Zoom In", + "ptz_zoom_out": "PTZ Zoom Out" + }, "reset_after_interaction": "Reset to the default view after user interaction", "triggers": { "actions": { @@ -580,6 +591,17 @@ "what": "What", "where": "Where" }, + "key_assigner": { + "assign": "Assign", + "assigning": "Assigning", + "modifiers": { + "alt": "Alt", + "ctrl": "Ctrl", + "meta": "Meta", + "shift": "Shift" + }, + "unassign": "Unassign" + }, "media_filter": { "all": "All", "camera": "Camera", diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json index 0c639c16..4b72a638 100644 --- a/src/localize/languages/fr.json +++ b/src/localize/languages/fr.json @@ -362,7 +362,6 @@ "camera_ui": "Interface utilisateur de la caméra", "cameras": "Appareils photo", "clips": "Extraits", - "default_zoom": "", "display_mode": "", "download": "Télécharger", "enabled": "Bouton activé", @@ -377,7 +376,8 @@ "mute": "Désactiver/Réactiver le son", "play": "Jouer / Pause", "priority": "Priorité", - "ptz": "", + "ptz_controls": "", + "ptz_home": "", "recordings": "Enregistrements", "screenshot": "Capture d'écran", "snapshots": "Instantanés", @@ -437,6 +437,10 @@ }, "default": "Vue par défaut", "interaction_seconds": "", + "keyboard_shortcuts": { + "editor_label": "", + "enabled": "" + }, "reset_after_interaction": "", "triggers": { "actions": { @@ -580,6 +584,17 @@ "what": "Quoi", "where": "Où" }, + "key_assigner": { + "assign": "", + "assigning": "", + "modifiers": { + "alt": "", + "ctrl": "", + "meta": "", + "shift": "" + }, + "unassign": "" + }, "media_filter": { "all": "Tous", "camera": "Caméra", @@ -630,4 +645,4 @@ "timeline": { "select_date": "Choisir une date" } -} +} \ No newline at end of file diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index cf078f21..eab11dcb 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -362,7 +362,6 @@ "camera_ui": "Interfaccia utente della fotocamera", "cameras": "Telecamere", "clips": "Clip", - "default_zoom": "", "display_mode": "", "download": "Download", "enabled": "Pulsante abilitato", @@ -377,7 +376,8 @@ "mute": "", "play": "", "priority": "Priorità", - "ptz": "", + "ptz_controls": "", + "ptz_home": "", "recordings": "", "screenshot": "", "snapshots": "Istantanee", @@ -437,6 +437,10 @@ }, "default": "Visualizzazione predefinita", "interaction_seconds": "", + "keyboard_shortcuts": { + "editor_label": "", + "enabled": "" + }, "reset_after_interaction": "", "triggers": { "actions": { @@ -580,6 +584,17 @@ "what": "Che cosa", "where": "Dove" }, + "key_assigner": { + "assign": "", + "assigning": "", + "modifiers": { + "alt": "", + "ctrl": "", + "meta": "", + "shift": "" + }, + "unassign": "" + }, "media_filter": { "all": "Tutto", "camera": "Telecamera", @@ -630,4 +645,4 @@ "timeline": { "select_date": "Scegli la data" } -} +} \ No newline at end of file diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index ad57b80a..862c3239 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -362,7 +362,6 @@ "camera_ui": "Interface de usuário da câmera", "cameras": "Selecionar câmera", "clips": "Clipes", - "default_zoom": "", "display_mode": "", "download": "Baixe a mídia do evento", "enabled": "Botão ativado", @@ -377,7 +376,8 @@ "mute": "", "play": "", "priority": "Prioridade", - "ptz": "", + "ptz_controls": "", + "ptz_home": "", "recordings": "Gravações", "screenshot": "", "snapshots": "Instantâneos", @@ -437,6 +437,10 @@ }, "default": "Visualização padrão", "interaction_seconds": "", + "keyboard_shortcuts": { + "editor_label": "", + "enabled": "" + }, "reset_after_interaction": "", "triggers": { "actions": { @@ -580,6 +584,17 @@ "what": "O que", "where": "Onde" }, + "key_assigner": { + "assign": "", + "assigning": "", + "modifiers": { + "alt": "", + "ctrl": "", + "meta": "", + "shift": "" + }, + "unassign": "" + }, "media_filter": { "all": "Todos", "camera": "Câmera", @@ -630,4 +645,4 @@ "timeline": { "select_date": "Escolha a data" } -} +} \ No newline at end of file diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index 46cd0f18..8800e74a 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -362,7 +362,6 @@ "camera_ui": "Camera", "cameras": "Selecionar câmera", "clips": "Clipes", - "default_zoom": "", "display_mode": "", "download": "Descarregar mídia do evento", "enabled": "Botão ativado", @@ -377,7 +376,8 @@ "mute": "", "play": "", "priority": "Prioridade", - "ptz": "", + "ptz_controls": "", + "ptz_home": "", "recordings": "", "screenshot": "", "snapshots": "Instantâneos", @@ -437,6 +437,10 @@ }, "default": "Visualização padrão", "interaction_seconds": "", + "keyboard_shortcuts": { + "editor_label": "", + "enabled": "" + }, "reset_after_interaction": "", "triggers": { "actions": { @@ -580,6 +584,17 @@ "what": "O quê", "where": "Onde" }, + "key_assigner": { + "assign": "", + "assigning": "", + "modifiers": { + "alt": "", + "ctrl": "", + "meta": "", + "shift": "" + }, + "unassign": "" + }, "media_filter": { "all": "Todos", "camera": "Camera", @@ -630,4 +645,4 @@ "timeline": { "select_date": "Selecionar a data" } -} +} \ No newline at end of file diff --git a/src/scss/card.scss b/src/scss/card.scss index 0ed212a9..04286562 100644 --- a/src/scss/card.scss +++ b/src/scss/card.scss @@ -18,6 +18,9 @@ max-height: var(--frigate-card-max-height); min-height: var(--frigate-card-min-height); + // Ensure all clicks at the top level work. + pointer-events: all; + // The standard HA header is 56 pixels tall, so that much off the top (header) // and bottom (to maintain center), before doing the calculation of // max-height. This matters on small mobile devices in landscape orientation. diff --git a/src/scss/key-assigner.scss b/src/scss/key-assigner.scss new file mode 100644 index 00000000..01036432 --- /dev/null +++ b/src/scss/key-assigner.scss @@ -0,0 +1,79 @@ +@use 'dotdotdot.scss'; + +:host { + display: flex; + flex-direction: row; + align-items: center; + + // Values match other HA editor components. + padding: 10px; + height: 56px; + border: 1px solid var(--divider-color); +} + +:host([assigning]) ha-button.assign span, +:host([assigning]) ha-button.assign ha-icon { + color: var(--warning-color); +} + +ha-icon { + padding: 10px; +} + +div.label { + width: 100px; + margin-left: 4px; +} + +div.key-row { + flex: 1; + + display: flex; + justify-content: center; + align-items: center; + + padding-left: 10px; + padding-right: 10px; +} + +div.key { + display: flex; + align-items: center; + + height: 90%; + width: min-content; + + margin-left: 5px; + margin-right: 5px; +} + +div.key-inner { + height: 100%; + width: 100%; + + padding-top: 2px; + padding-bottom: 2px; + padding-left: 4px; + padding-right: 4px; + + border: 2px; + border-radius: 4px; + border-style: outset; + border-color: var(--divider-color); + + font-family: monospace; + text-transform: capitalize; +} + +div.unassigned { + font-style: italic; +} + +div.key + div.key:before { + display: flex; + align-items: center; + + margin-right: 5px; + + content: ' + '; +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 06258cf6..a512154e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -93,8 +93,13 @@ export interface CardHelpers { export type PTZMovementType = 'relative' | 'continuous'; export interface PTZCapabilities { - panTilt?: PTZMovementType[]; - zoom?: PTZMovementType[]; + left?: PTZMovementType[]; + right?: PTZMovementType[]; + up?: PTZMovementType[]; + down?: PTZMovementType[]; + zoomIn?: PTZMovementType[]; + zoomOut?: PTZMovementType[]; + presets?: string[]; } diff --git a/src/utils/action.ts b/src/utils/action.ts index cb4e2b18..b3ec8805 100644 --- a/src/utils/action.ts +++ b/src/utils/action.ts @@ -1,24 +1,28 @@ +import { ActionConfig, hasAction } from '@dermotduffy/custom-card-helpers'; +import { ZoomSettingsBase } from '../components-lib/zoom/types.js'; +import { PTZAction } from '../config/ptz.js'; import { - ActionConfig, - handleActionConfig, - hasAction, - HomeAssistant, -} from '@dermotduffy/custom-card-helpers'; -import { - Actions, + ActionPhase, ActionType, + Actions, FrigateCardCustomAction, - frigateCardCustomActionSchema, FrigateCardGeneralAction, FrigateCardUserSpecifiedView, + LogActionConfig, + LogActionLevel, + PTZActionConfig, + PTZDigitialActionConfig, + PTZMultiActionConfig, + frigateCardCustomActionSchema, } from '../config/types.js'; +import { arrayify } from './basic.js'; /** * Convert a generic Action to a FrigateCardCustomAction if it parses correctly. * @param action The generic action configuration. * @returns A FrigateCardCustomAction or null if it cannot be converted. */ -export function convertActionToFrigateCardCustomAction( +export function convertActionToCardCustomAction( action: unknown, ): FrigateCardCustomAction | null { if (!action) { @@ -30,7 +34,7 @@ export function convertActionToFrigateCardCustomAction( return parseResult.success ? parseResult.data : null; } -export function createFrigateCardSimpleAction( +export function createGeneralAction( action: FrigateCardGeneralAction | FrigateCardUserSpecifiedView, options?: { cardID?: string; @@ -43,7 +47,7 @@ export function createFrigateCardSimpleAction( }; } -export function createFrigateCardCameraAction( +export function createCameraAction( action: 'camera_select' | 'live_substream_select', camera: string, options?: { @@ -58,7 +62,7 @@ export function createFrigateCardCameraAction( }; } -export function createFrigateCardMediaPlayerAction( +export function createMediaPlayerAction( mediaPlayer: string, mediaPlayerAction: 'play' | 'stop', options?: { @@ -74,7 +78,7 @@ export function createFrigateCardMediaPlayerAction( }; } -export function createFrigateCardDisplayModeAction( +export function createDisplayModeAction( displayMode: 'single' | 'grid', options?: { cardID?: string; @@ -88,38 +92,87 @@ export function createFrigateCardDisplayModeAction( }; } -export function createFrigateCardShowPTZAction( - showPTZ: boolean, +export function createPTZControlsAction( + enabled: boolean, options?: { cardID?: string; }, ): FrigateCardCustomAction { return { action: 'fire-dom-event', - frigate_card_action: 'show_ptz', - show_ptz: showPTZ, + frigate_card_action: 'ptz_controls', + enabled: enabled, ...(options?.cardID && { card_id: options.cardID }), }; } -export function createFrigateCardChangeZoomAction( - targetID: string, - options?: { - cardID?: string; - pan?: { - x?: number; - y?: number; - }; - zoom?: number; - }, -): FrigateCardCustomAction { +export function createPTZAction(options?: { + cardID?: string; + ptzAction?: PTZAction; + ptzPhase?: ActionPhase; + ptzPreset?: string; + cameraID?: string; +}): PTZActionConfig { return { action: 'fire-dom-event', - frigate_card_action: 'change_zoom', - target_id: targetID, + frigate_card_action: 'ptz', + ...(options?.cardID && { card_id: options.cardID }), + ...(options?.ptzAction && { ptz_action: options.ptzAction }), + ...(options?.ptzPhase && { ptz_phase: options.ptzPhase }), + ...(options?.ptzPreset && { ptz_preset: options.ptzPreset }), + ...(options?.cameraID && { camera: options.cameraID }), + }; +} + +export function createPTZDigitalAction(options?: { + cardID?: string; + ptzPhase?: ActionPhase; + ptzAction?: PTZAction; + absolute?: ZoomSettingsBase; + targetID?: string; +}): PTZDigitialActionConfig { + return { + action: 'fire-dom-event', + frigate_card_action: 'ptz_digital', + ...(options?.cardID && { card_id: options.cardID }), + ...(options?.ptzAction && { ptz_action: options.ptzAction }), + ...(options?.ptzPhase && { ptz_phase: options.ptzPhase }), + ...(options?.absolute && { absolute: options.absolute }), + ...(options?.targetID && { target_id: options.targetID }), + }; +} + +export function createPTZMultiAction(options?: { + cardID?: string; + ptzAction?: PTZAction; + ptzPhase?: ActionPhase; + ptzPreset?: string; + targetID?: string; +}): PTZMultiActionConfig { + return { + action: 'fire-dom-event', + frigate_card_action: 'ptz_multi', + ...(options?.cardID && { card_id: options.cardID }), + ...(options?.ptzAction && { ptz_action: options.ptzAction }), + ...(options?.ptzPhase && { ptz_phase: options.ptzPhase }), + ...(options?.ptzPreset && { ptz_preset: options.ptzPreset }), + ...(options?.targetID && { target_id: options.targetID }), + }; +} + +export function createLogAction( + message: string, + options?: { + cardID?: string; + level?: LogActionLevel; + }, +): LogActionConfig { + return { + action: 'fire-dom-event', + frigate_card_action: 'log', + message: message, + level: options?.level ?? 'info', ...(options?.cardID && { card_id: options.cardID }), - ...(options?.pan && { pan: options.pan }), - ...(options?.zoom && { zoom: options.zoom }), }; } @@ -150,63 +203,6 @@ export function getActionConfigGivenAction( return null; } -/** - * Frigate card custom version of handleAction - * (https://github.com/custom-cards/custom-card-helpers/blob/master/src/handle-action.ts) - * that handles the custom action events the card supports. - * @param node The node that fired the event. - * @param hass The Home Assistant object. - * @param actionConfig A single action config, array of action configs or - * undefined for the default action config for 'tap'. - * @param action The action string (e.g. 'hold') - * @returns Whether or not an action was executed. - */ -export const frigateCardHandleActionConfig = ( - node: HTMLElement, - hass: HomeAssistant, - config: { - camera_image?: string; - entity?: string; - }, - action: string, - actionConfig?: ActionType | ActionType[] | null, -): boolean => { - // Only allow a tap action to use a default non-config (the more-info config). - if (actionConfig || action == 'tap') { - frigateCardHandleAction(node, hass, config, actionConfig); - return true; - } - return false; -}; - -export const frigateCardHandleAction = ( - node: HTMLElement, - hass: HomeAssistant, - config: { - camera_image?: string; - entity?: string; - }, - actionConfig?: ActionType | ActionType[] | null, -): void => { - // ActionConfig vs ActionType: - // * There is a slight typing (but not functional) difference between - // ActionType in this card and ActionConfig in `custom-card-helpers`. See - // `ExtendedConfirmationRestrictionConfig` in `types.ts` for the source and - // reason behind this difference. - if (Array.isArray(actionConfig)) { - actionConfig.forEach((action) => - handleActionConfig(node, hass, config, action as ActionConfig | undefined), - ); - } else { - handleActionConfig( - node, - hass, - config, - (actionConfig ?? undefined) as ActionConfig | undefined, - ); - } -}; - /** * Determine if an action config has a real action. A modified version of * custom-card-helpers hasAction to also work with arrays of action configs. @@ -216,10 +212,7 @@ export const frigateCardHandleAction = ( export const frigateCardHasAction = (config?: ActionType | ActionType[]): boolean => { // See note above on 'ActionConfig vs ActionType' for why this cast is // necessary and harmless. - if (Array.isArray(config)) { - return !!config.find((item) => hasAction(item as ActionConfig | undefined)); - } - return hasAction(config as ActionConfig | undefined); + return arrayify(config).some((item) => hasAction(item as ActionConfig | undefined)); }; /** diff --git a/src/utils/basic.ts b/src/utils/basic.ts index 4c3c3bda..b3f4ee6e 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -5,9 +5,10 @@ import { format, } from 'date-fns'; import { StyleInfo } from 'lit/directives/style-map'; -import { round } from 'lodash-es'; import isEqualWith from 'lodash-es/isEqualWith'; import mergeWith from 'lodash-es/mergeWith'; +import round from 'lodash-es/round'; +import uniq from 'lodash-es/uniq'; import { FrigateCardError } from '../types'; export type ModifyInterface = Omit & R; @@ -254,6 +255,16 @@ export const recursivelyMergeObjectsNotArrays = (target: T, src1: T, src2: T) return mergeWith(target, src1, src2, (_a, b) => (Array.isArray(b) ? b : undefined)); }; +export const recursivelyMergeObjectsConcatenatingArraysUniquely = ( + target: T, + src1: T, + src2: T, +): T => { + return mergeWith(target, src1, src2, (a, b) => + Array.isArray(a) ? uniq(a.concat(b)) : undefined, + ); +}; + export const aspectRatioToString = (options?: { ratio?: number[]; defaultStatic?: boolean; diff --git a/src/utils/ha/index.ts b/src/utils/ha/index.ts index 3b6e18b2..1d52a860 100644 --- a/src/utils/ha/index.ts +++ b/src/utils/ha/index.ts @@ -301,17 +301,18 @@ export function getEntityIcon( */ export const sideLoadHomeAssistantElements = async (): Promise => { const neededElements = [ - 'ha-selector', - 'ha-menu-button', - 'ha-camera-stream', - 'ha-hls-player', - 'ha-web-rtc-player', - 'ha-icon', - 'ha-circular-progress', - 'ha-icon-button', - 'ha-card', - 'ha-svg-icon', 'ha-button-menu', + 'ha-button', + 'ha-camera-stream', + 'ha-card', + 'ha-circular-progress', + 'ha-hls-player', + 'ha-icon-button', + 'ha-icon', + 'ha-menu-button', + 'ha-selector', + 'ha-svg-icon', + 'ha-web-rtc-player', ]; if (neededElements.every((element) => customElements.get(element))) { diff --git a/src/utils/ptz.ts b/src/utils/ptz.ts index 2cde0016..c4391a4f 100644 --- a/src/utils/ptz.ts +++ b/src/utils/ptz.ts @@ -1,19 +1,75 @@ -import { Capabilities } from '../camera-manager/capabilities'; -import { FrigateCardPTZConfig, PTZ_CONTROL_ACTIONS } from '../config/types'; +import { CameraManager } from '../camera-manager/manager'; +import { PTZAction } from '../config/ptz'; +import { PTZCapabilities } from '../types'; +import { View } from '../view/view'; +import { getStreamCameraID } from './substream'; -export const hasUsablePTZ = ( - capabilities: Capabilities | null, - config: FrigateCardPTZConfig, -): boolean => { - for (const actionName of PTZ_CONTROL_ACTIONS) { - if ('actions_' + actionName in config) { - return true; +export type PTZType = 'digital' | 'ptz'; +interface PTZTarget { + targetID: string; + type: PTZType; +} + +export const getPTZTarget = ( + view: View, + options?: { + type?: PTZType; + cameraManager?: CameraManager; + }, +): PTZTarget | null => { + if (view.isViewerView()) { + const targetID = view.queryResults?.getSelectedResult()?.getID() ?? null; + return options?.type === 'ptz' || !targetID + ? null + : { + targetID: targetID, + type: 'digital', + }; + } else if (view.is('live')) { + const substreamAwareCameraID = getStreamCameraID(view); + let type: PTZType = 'digital'; + + if (options?.type !== 'digital' && options?.cameraManager) { + if (hasCameraTruePTZ(options.cameraManager, substreamAwareCameraID)) { + type = 'ptz'; + } + if (type !== 'ptz' && options?.type === 'ptz') { + return null; + } } + + return { + targetID: substreamAwareCameraID, + type: type, + }; } - const ptzCapabilities = capabilities?.getPTZCapabilities(); - return ( - !!ptzCapabilities?.panTilt?.length || - !!ptzCapabilities?.zoom?.length || - !!ptzCapabilities?.presets?.length - ); + return null; +}; + +export const hasCameraTruePTZ = ( + cameraManager: CameraManager, + cameraID: string, +): boolean => { + return !!cameraManager + .getStore() + .getCamera(cameraID) + ?.getCapabilities() + ?.hasPTZCapability(); +}; + +export const ptzActionToCapabilityKey = ( + action: PTZAction, +): keyof PTZCapabilities | null => { + switch (action) { + case 'left': + case 'right': + case 'up': + case 'down': + return action; + case 'zoom_in': + return 'zoomIn'; + case 'zoom_out': + return 'zoomOut'; + } + return null; }; diff --git a/src/utils/substream.ts b/src/utils/substream.ts index 8ada684e..938eb218 100644 --- a/src/utils/substream.ts +++ b/src/utils/substream.ts @@ -1,7 +1,7 @@ import { View } from '../view/view'; -export const getStreamCameraID = (view: View): string => { - return view?.context?.live?.overrides?.get(view.camera) ?? view.camera; +export const getStreamCameraID = (view: View, cameraID?: string): string => { + return view.context?.live?.overrides?.get(cameraID ?? view.camera) ?? view.camera; }; export const hasSubstream = (view: View): boolean => { diff --git a/src/view/view.ts b/src/view/view.ts index 9e9e54e6..dd9b0ed8 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -64,7 +64,7 @@ export class View { } public static adoptFromViewIfAppropriate(next: View, curr?: View | null): void { - if (!curr) { + if (!curr) { return; } @@ -273,10 +273,7 @@ export class View { export interface FrigateCardViewChangeEventTarget extends EventTarget { addEventListener( event: 'frigate-card:view:change', - listener: ( - this: FrigateCardViewChangeEventTarget, - ev: CustomEvent, - ) => void, + listener: (this: FrigateCardViewChangeEventTarget, ev: CustomEvent) => void, options?: AddEventListenerOptions | boolean, ): void; addEventListener( @@ -286,10 +283,7 @@ export interface FrigateCardViewChangeEventTarget extends EventTarget { ): void; removeEventListener( event: 'frigate-card:view:change', - listener: ( - this: FrigateCardViewChangeEventTarget, - ev: CustomEvent, - ) => void, + listener: (this: FrigateCardViewChangeEventTarget, ev: CustomEvent) => void, options?: boolean | EventListenerOptions, ): void; removeEventListener( diff --git a/tests/camera-manager/capabilities.test.ts b/tests/camera-manager/capabilities.test.ts index 9710e489..b84fc5cc 100644 --- a/tests/camera-manager/capabilities.test.ts +++ b/tests/camera-manager/capabilities.test.ts @@ -68,17 +68,19 @@ describe('Capabilities', () => { it('when unset', () => { const capabilities = new Capabilities({}); expect(capabilities.getPTZCapabilities()).toBeNull(); + expect(capabilities.hasPTZCapability()).toBeFalsy(); }); it('when set', () => { const ptz: PTZCapabilities = { - panTilt: ['continuous' as const], + left: ['continuous' as const], presets: ['1', '2'], }; const capabilities = new Capabilities({ ptz: ptz, }); expect(capabilities.getPTZCapabilities()).toBe(ptz); + expect(capabilities.hasPTZCapability()).toBeTruthy(); }); }); diff --git a/tests/camera-manager/frigate/camera.test.ts b/tests/camera-manager/frigate/camera.test.ts index 7852ae52..15ed29e8 100644 --- a/tests/camera-manager/frigate/camera.test.ts +++ b/tests/camera-manager/frigate/camera.test.ts @@ -191,6 +191,7 @@ describe('FrigateCamera', () => { await camera.initialize(createHASS(), mock()); expect(camera.getCapabilities()?.has('ptz')).toBeFalsy(); + expect(camera.getCapabilities()?.hasPTZCapability()).toBeFalsy(); expect(consoleSpy).toBeCalled(); }); @@ -214,10 +215,15 @@ describe('FrigateCamera', () => { expect(camera.getCapabilities()?.has('ptz')).toBeTruthy(); expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ - panTilt: ['continuous'], - zoom: ['continuous'], + left: ['continuous'], + right: ['continuous'], + up: ['continuous'], + down: ['continuous'], + zoomIn: ['continuous'], + zoomOut: ['continuous'], presets: ['preset01'], }); + expect(camera.getCapabilities()?.hasPTZCapability()).toBeTruthy(); }); it('when getPTZInfo call succeeds with relative motion', async () => { @@ -240,10 +246,15 @@ describe('FrigateCamera', () => { expect(camera.getCapabilities()?.has('ptz')).toBeTruthy(); expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ - panTilt: ['relative'], - zoom: ['relative'], + left: [], + right: [], + up: [], + down: [], + zoomIn: [], + zoomOut: [], presets: ['preset01'], }); + expect(camera.getCapabilities()?.hasPTZCapability()).toBeTruthy(); }); }); }); diff --git a/tests/camera-manager/frigate/engine-frigate.test.ts b/tests/camera-manager/frigate/engine-frigate.test.ts index 61acff83..1c0a7cab 100644 --- a/tests/camera-manager/frigate/engine-frigate.test.ts +++ b/tests/camera-manager/frigate/engine-frigate.test.ts @@ -6,10 +6,10 @@ import { FrigateRecordingViewMedia, } from '../../../src/camera-manager/frigate/media'; import { FrigateEvent, eventSchema } from '../../../src/camera-manager/frigate/types.js'; +import { PTZAction } from '../../../src/config/ptz'; import { CameraConfig, FrigateCardView, - PTZAction, RawFrigateCardConfig, } from '../../../src/config/types'; import { ViewMedia } from '../../../src/view/media'; diff --git a/tests/camera-manager/manager.test.ts b/tests/camera-manager/manager.test.ts index 8529c37d..ee9b53bc 100644 --- a/tests/camera-manager/manager.test.ts +++ b/tests/camera-manager/manager.test.ts @@ -1070,7 +1070,7 @@ describe('CameraManager', async () => { snapshots: true, ptz: { - panTilt: ['continuous'], + left: ['continuous'], }, }), }, @@ -1102,7 +1102,50 @@ describe('CameraManager', async () => { expect(engine.executePTZAction).not.toBeCalled(); }); - it('successfully', async () => { + it('without hass', async () => { + const api = createCardAPI(); + const engine = mock(); + const manager = createCameraManager(api, engine); + + const hass = createHASS(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null); + manager.executePTZAction('id', 'left'); + + expect(engine.executePTZAction).not.toBeCalled(); + }); + + it('successfully from config', async () => { + const api = createCardAPI(); + const engine = mock(); + const hass = createHASS(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + const action = { + action: 'call-service' as const, + service: 'service', + } + const manager = createCameraManager(api, engine, [ + { + config: createCameraConfig({ + baseCameraConfig, + id: 'another', + ptz: { + actions_left: action, + }, + }), + }, + ]); + expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + + manager.executePTZAction('another', 'left'); + + expect(api.getActionsManager().executeActions).toBeCalledWith(action); + expect(engine.executePTZAction).not.toBeCalled(); + }); + + it('successfully from engine', async () => { const api = createCardAPI(); const engine = mock(); const hass = createHASS(); diff --git a/tests/camera-manager/utils/ptz.test.ts b/tests/camera-manager/utils/ptz.test.ts new file mode 100644 index 00000000..44788a86 --- /dev/null +++ b/tests/camera-manager/utils/ptz.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + getConfiguredPTZAction, + getConfiguredPTZMovementType, + getPTZCapabilitiesFromCameraConfig, +} from '../../../src/camera-manager/utils/ptz'; +import { createCameraConfig } from '../../test-utils'; +import { PTZAction } from '../../../src/config/ptz'; + +const action = { + action: 'call-service' as const, + service: 'service', + data: { + device: '048123', + cmd: 'preset', + preset: 'window', + }, +}; + +describe('getConfiguredPTZAction', () => { + describe('should return preset', () => { + it('with preset', () => { + expect( + getConfiguredPTZAction( + createCameraConfig({ + ptz: { + presets: { + window: action, + }, + }, + }), + 'preset', + { + preset: 'window', + }, + ), + ).toEqual(action); + }); + + it('without preset', () => { + expect( + getConfiguredPTZAction( + createCameraConfig({ + ptz: { + presets: { + window: action + }, + }, + }), + 'preset', + ), + ).toBeNull(); + }); + }); + + describe('should return continuous action', () => { + it('with action', () => { + expect( + getConfiguredPTZAction( + createCameraConfig({ + ptz: { + actions_left_start: action, + }, + }), + 'left', + { + phase: 'start', + }, + ), + ).toEqual(action); + }); + + it('without action', () => { + expect( + getConfiguredPTZAction( + createCameraConfig({ + ptz: {}, + }), + 'left', + { + phase: 'start', + }, + ), + ).toBeNull(); + }); + }); +}); + +describe('getConfiguredPTZMovementType', () => { + it('with continuous', () => { + expect( + getConfiguredPTZMovementType( + createCameraConfig({ + ptz: { + actions_left_start: action, + actions_left_stop: action, + }, + }), + 'left', + ), + )?.toEqual(['continuous']); + }); + + it('with relative', () => { + expect( + getConfiguredPTZMovementType( + createCameraConfig({ + ptz: { + actions_left: action, + }, + }), + 'left', + ), + )?.toEqual(['relative']); + }); + + it('with continuous and relative', () => { + expect( + getConfiguredPTZMovementType( + createCameraConfig({ + ptz: { + actions_left: action, + actions_left_start: action, + actions_left_stop: action, + }, + }), + 'left', + ), + )?.toEqual(['continuous', 'relative']); + }); + + it('with no actions', () => { + expect( + getConfiguredPTZMovementType( + createCameraConfig({ + ptz: {}, + }), + 'left', + ), + )?.toBeNull(); + }); +}); + +describe('getPTZCapabilitiesFromCameraConfig', () => { + it('with nothing', () => { + expect(getPTZCapabilitiesFromCameraConfig(createCameraConfig()))?.toBeNull(); + }); + + describe('with individual actions', () => { + it.each([ + ['left' as const, 'left'], + ['right' as const, 'right'], + ['up' as const, 'up'], + ['down' as const, 'down'], + ['zoom_in' as const, 'zoomIn'], + ['zoom_out' as const, 'zoomOut'], + ])('%s', async (actionName: PTZAction, capabilityName: string) => { + expect( + getPTZCapabilitiesFromCameraConfig( + createCameraConfig({ + ptz: { + ['actions_' + actionName]: action, + }, + }), + ), + )?.toEqual( + expect.objectContaining({ + [capabilityName]: ['relative'], + }), + ); + }); + }); + + it('with preset', () => { + expect( + getPTZCapabilitiesFromCameraConfig( + createCameraConfig({ + ptz: { + presets: { + window: action, + }, + }, + }), + ), + )?.toEqual( + expect.objectContaining({ + presets: ['window'], + }), + ); + }); +}); diff --git a/tests/card-controller/actions-manager.test.ts b/tests/card-controller/actions-manager.test.ts deleted file mode 100644 index 0da864a7..00000000 --- a/tests/card-controller/actions-manager.test.ts +++ /dev/null @@ -1,914 +0,0 @@ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { afterAll, describe, expect, it, vi } from 'vitest'; -import { mock } from 'vitest-mock-extended'; -import { - ActionType, - FrigateCardCustomAction, - FrigateCardView, - frigateCardCustomActionSchema, -} from '../../src/config/types'; -import { FrigateCardMediaPlayer } from '../../src/types'; -import { - convertActionToFrigateCardCustomAction, - frigateCardHandleAction, - frigateCardHandleActionConfig, - getActionConfigGivenAction, -} from '../../src/utils/action.js'; -import { ActionsManager, Interaction } from '../../src/card-controller/actions-manager'; -import { - createCardAPI, - createConfig, - createHASS, - createMediaLoadedInfo, - createView, - createViewWithMedia, -} from '../test-utils'; - -vi.mock('../../src/utils/action.js'); - -const createAction = ( - action: Record, -): FrigateCardCustomAction | null => { - const result = frigateCardCustomActionSchema.safeParse({ - action: 'custom:frigate-card-action', - ...action, - }); - return result.success ? result.data : null; -}; - -describe('ActionsManager.getMergedActions', () => { - const config = { - view: { - actions: { - tap_action: { - action: 'navigate', - navigation_path: '1', - }, - }, - }, - live: { - actions: { - tap_action: { - action: 'navigate', - navigation_path: '2', - }, - }, - }, - media_gallery: { - actions: { - tap_action: { - action: 'navigate', - navigation_path: '3', - }, - }, - }, - media_viewer: { - actions: { - tap_action: { - action: 'navigate', - navigation_path: '4', - }, - }, - }, - image: { - actions: { - tap_action: { - action: 'navigate', - navigation_path: '5', - }, - }, - }, - }; - - afterAll(() => { - vi.restoreAllMocks(); - }); - - it('should get no merged actions with a message', () => { - const api = createCardAPI(); - vi.mocked(api.getViewManager().getView).mockReturnValue( - createView({ view: 'live' }), - ); - vi.mocked(api.getMessageManager().hasMessage).mockReturnValue(true); - - const manager = new ActionsManager(api); - - expect(manager.getMergedActions()).toEqual({}); - }); - - describe('should get merged actions with live view', () => { - it.each([ - [ - 'live' as const, - { - tap_action: { - action: 'navigate', - navigation_path: '2', - }, - }, - ], - [ - 'clips' as const, - { - tap_action: { - action: 'navigate', - navigation_path: '3', - }, - }, - ], - [ - 'clip' as const, - { - tap_action: { - action: 'navigate', - navigation_path: '4', - }, - }, - ], - [ - 'image' as const, - { - tap_action: { - action: 'navigate', - navigation_path: '5', - }, - }, - ], - ['timeline' as const, {}], - ])('%s', (viewName: FrigateCardView, result: Record) => { - const api = createCardAPI(); - vi.mocked(api.getViewManager().getView).mockReturnValue( - createView({ view: viewName }), - ); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig(config)); - - const manager = new ActionsManager(api); - - expect(manager.getMergedActions()).toEqual(result); - }); - }); -}); - -// @vitest-environment jsdom -describe('ActionsManager.handleInteraction', () => { - it('should handle interaction', () => { - const api = createCardAPI(); - const element = document.createElement('div'); - vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); - const manager = new ActionsManager(api); - - const hass = createHASS(); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); - - const actionForThisInteraction: ActionType = { - action: 'none', - }; - vi.mocked(getActionConfigGivenAction).mockReturnValue(actionForThisInteraction); - - manager.handleInteractionEvent( - new CustomEvent('event', { detail: { action: 'tap' } }), - ); - - expect(frigateCardHandleActionConfig).toBeCalledWith( - element, - hass, - manager.getMergedActions(), - 'tap', - actionForThisInteraction, - ); - }); - - it('should not handle interaction without hass', () => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null); - - // No values of hass. - manager.handleInteractionEvent( - new CustomEvent('event', { detail: { action: 'tap' } }), - ); - expect(frigateCardHandleActionConfig).not.toBeCalledWith(); - }); - - it('should not handle malformed interaction', () => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - - manager.handleInteractionEvent( - new CustomEvent('event', { - // Malformed interaction type. - detail: { action: 'double_finger_snap' } as unknown as Interaction, - }), - ); - expect(frigateCardHandleActionConfig).not.toBeCalledWith(); - }); -}); - -describe('ActionsManager.handleActionEvent', () => { - it('should handle event', () => { - const action = createAction({ frigate_card_action: 'default' })!; - const event: CustomEvent = new CustomEvent('ll-custom', { - detail: action, - }); - - // The file containing convertActionToFrigateCardCustomAction (action.ts) is - // mocked, so need to provide a value here. - vi.mocked(convertActionToFrigateCardCustomAction).mockReturnValue(action); - - const api = createCardAPI(); - const manager = new ActionsManager(api); - - manager.handleActionEvent(event); - expect(api.getViewManager().setViewDefault).toBeCalled(); - }); - - it('should not handle event without detail', () => { - const action = createAction({ frigate_card_action: 'default' })!; - const event = new Event('ll-custom'); - - // Mock this out just so that if the sentinel in handleActionEvent failed, - // it would still trigger a test failure below. - vi.mocked(convertActionToFrigateCardCustomAction).mockReturnValue(action); - - const api = createCardAPI(); - const manager = new ActionsManager(api); - manager.handleActionEvent(event); - - expect(api.getViewManager().setViewDefault).not.toBeCalled(); - }); - - it('should not handle malformed action', () => { - const action = createAction({ frigate_card_action: 'default' })!; - const event: CustomEvent = new CustomEvent('ll-custom', { - detail: action, - }); - - vi.mocked(convertActionToFrigateCardCustomAction).mockReturnValue(null); - - const api = createCardAPI(); - const manager = new ActionsManager(api); - manager.handleActionEvent(event); - - expect(api.getViewManager().setViewDefault).not.toBeCalled(); - }); -}); - -describe('ActionsManager.executeAction', () => { - it('should not handle actions with different card_id', async () => { - const api = createCardAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - card_id: 'foo', - }), - ); - - const manager = new ActionsManager(api); - - await manager.executeFrigateAction( - createAction({ - card_id: 'NOT_foo', - frigate_card_action: 'default', - })!, - ); - - expect(api.getViewManager().setViewDefault).not.toBeCalled(); - }); - - it('should handle default action', async () => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'default', - })!, - ); - - expect(api.getViewManager().setViewDefault).toBeCalled(); - }); - - describe('should handle view action', async () => { - it.each([ - ['clip' as const], - ['clips' as const], - ['image' as const], - ['live' as const], - ['recording' as const], - ['recordings' as const], - ['snapshot' as const], - ['snapshots' as const], - ['timeline' as const], - ])('%s', async (viewName: FrigateCardView) => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: viewName, - })!, - ); - - expect(api.getViewManager().setViewByParameters).toBeCalledWith( - expect.objectContaining({ - viewName: viewName, - }), - ); - }); - }); - - it('should handle download action', async () => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'download', - })!, - ); - - expect(api.getDownloadManager().downloadViewerMedia).toBeCalled(); - }); - - it('should handle camera ui action', async () => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'camera_ui', - })!, - ); - - expect(api.getCameraURLManager().openURL).toBeCalled(); - }); - - it('should handle expand action', async () => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'expand', - })!, - ); - - expect(api.getExpandManager().toggleExpanded).toBeCalled(); - }); - - it('should handle fullscreen action', async () => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'fullscreen', - })!, - ); - - expect(api.getFullscreenManager().toggleFullscreen).toBeCalled(); - }); - - it('should handle menu toggle action', async () => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'menu_toggle', - })!, - ); - - expect(api.getCardElementManager().toggleMenu).toBeCalled(); - }); - - describe('should handle camera_select action', () => { - it('with valid camera and view', async () => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - - vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); - vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'camera_select', - camera: 'camera', - })!, - ); - - expect(api.getViewManager().setViewByParameters).toBeCalledWith( - expect.objectContaining({ - viewName: 'live', - cameraID: 'camera', - failSafe: true, - }), - ); - }); - - it('without config', async () => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(null); - vi.mocked(api.getViewManager().getView).mockReturnValue( - createView({ - view: 'timeline', - }), - ); - vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'camera_select', - camera: 'camera', - })!, - ); - - expect(api.getViewManager().setViewByParameters).toBeCalledWith( - expect.objectContaining({ - viewName: 'timeline', - cameraID: 'camera', - failSafe: true, - }), - ); - }); - - it('with target view', async () => { - const api = createCardAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - view: { - // Change to clips view when the camera changes. - camera_select: 'clips', - }, - }), - ); - vi.mocked(api.getViewManager().getView).mockReturnValue( - createView({ - view: 'live', - }), - ); - vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); - const manager = new ActionsManager(api); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'camera_select', - camera: 'camera', - })!, - ); - - expect(api.getViewManager().setViewByParameters).toBeCalledWith( - expect.objectContaining({ - viewName: 'clips', - cameraID: 'camera', - failSafe: true, - }), - ); - }); - - it('with triggered camera', async () => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - - vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); - vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); - vi.mocked( - api.getTriggersManager().getMostRecentlyTriggeredCameraID, - ).mockReturnValue('camera'); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'camera_select', - triggered: true, - })!, - ); - - expect(api.getViewManager().setViewByParameters).toBeCalledWith( - expect.objectContaining({ - viewName: 'live', - cameraID: 'camera', - failSafe: true, - }), - ); - }); - - it('without camera or triggered camera', async () => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - - vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); - vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); - vi.mocked( - api.getTriggersManager().getMostRecentlyTriggeredCameraID, - ).mockReturnValue('camera'); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'camera_select', - })!, - ); - - expect(api.getViewManager().setViewByParameters).not.toBeCalled(); - }); - - it('without a current view', async () => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'camera_select', - camera: 'camera', - })!, - ); - - expect(api.getViewManager().setViewByParameters).not.toBeCalled(); - }); - }); - - it('should handle live_substream_select action', async () => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'live_substream_select', - camera: 'substream', - })!, - ); - - expect(api.getViewManager().setViewWithSubstream).toBeCalledWith('substream'); - }); - - it('should handle live_substream_off action', async () => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'live_substream_off', - })!, - ); - - expect(api.getViewManager().setViewWithoutSubstream).toBeCalled(); - }); - - it('should handle live_substream_on action', async () => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'live_substream_on', - })!, - ); - - expect(api.getViewManager().setViewWithSubstream).toBeCalledWith(); - }); - - describe('should handle media_player action', () => { - it('to stop', async () => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'media_player', - media_player_action: 'stop', - media_player: 'this_is_a_media_player', - })!, - ); - - expect(api.getMediaPlayerManager().stop).toBeCalledWith('this_is_a_media_player'); - }); - - it('to play live', async () => { - const api = createCardAPI(); - vi.mocked(api.getViewManager().getView).mockReturnValue( - createView({ - camera: 'camera', - view: 'live', - }), - ); - const manager = new ActionsManager(api); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'media_player', - media_player_action: 'play', - media_player: 'this_is_a_media_player', - })!, - ); - - expect(api.getMediaPlayerManager().playLive).toBeCalledWith( - 'this_is_a_media_player', - 'camera', - ); - }); - - it('to play media', async () => { - const api = createCardAPI(); - const view = createViewWithMedia({ - camera: 'camera', - view: 'media', - }); - - vi.mocked(api.getViewManager().getView).mockReturnValue(view); - const manager = new ActionsManager(api); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'media_player', - media_player_action: 'play', - media_player: 'this_is_a_media_player', - })!, - ); - - expect(api.getMediaPlayerManager().playMedia).toBeCalledWith( - 'this_is_a_media_player', - view.queryResults?.getSelectedResult(), - ); - }); - - it('to play media without selected media', async () => { - const api = createCardAPI(); - vi.mocked(api.getViewManager().getView).mockReturnValue( - createView({ - view: 'media', - }), - ); - const manager = new ActionsManager(api); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'media_player', - media_player_action: 'play', - media_player: 'this_is_a_media_player', - })!, - ); - - expect(api.getMediaPlayerManager().playMedia).not.toBeCalled(); - }); - }); - - it('should handle diagnostics action', async () => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'diagnostics', - })!, - ); - - expect(api.getViewManager().setViewByParameters).toBeCalledWith( - expect.objectContaining({ - viewName: 'diagnostics', - }), - ); - }); - - it('should handle microphone_mute action', async () => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'microphone_mute', - })!, - ); - - expect(api.getMicrophoneManager().mute).toBeCalled(); - }); - - it('should handle microphone_unmute action', async () => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'microphone_unmute', - })!, - ); - - expect(api.getMicrophoneManager().unmute).toBeCalled(); - }); - - describe('should handle media player action', () => { - it.each([ - ['mute' as const], - ['unmute' as const], - ['play' as const], - ['pause' as const], - ])('%s', async (action: 'mute' | 'unmute' | 'play' | 'pause') => { - const api = createCardAPI(); - const player = mock(); - vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue( - createMediaLoadedInfo({ - player: player, - }), - ); - const manager = new ActionsManager(api); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: action, - })!, - ); - - expect(player[action]).toBeCalled(); - }); - }); - - it('should handle screenshot action', async () => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'screenshot', - })!, - ); - - expect(api.getDownloadManager().downloadScreenshot).toBeCalled(); - }); - - it('should handle display_mode_select action', async () => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'display_mode_select', - display_mode: 'grid', - })!, - ); - - expect(api.getViewManager().setViewWithNewDisplayMode).toBeCalledWith('grid'); - }); - - describe('should handle ptz action', () => { - it('with selected camera', async () => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - vi.mocked(api.getViewManager().getView).mockReturnValue( - createView({ camera: 'camera.office' }), - ); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'ptz', - ptz_action: 'left', - })!, - ); - - expect(api.getCameraManager().executePTZAction).toBeCalledWith( - 'camera.office', - 'left', - { - phase: undefined, - preset: undefined, - }, - ); - }); - - it('without selected camera', async () => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'ptz', - ptz_action: 'left', - })!, - ); - - expect(api.getCameraManager().executePTZAction).not.toBeCalled(); - }); - }); - - it('should handle show_ptz action', async () => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'show_ptz', - show_ptz: true, - })!, - ); - - expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith( - expect.objectContaining({ live: { ptzVisible: true } }), - ); - }); - - describe('should handle change_zoom action', () => { - it('default', async () => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'change_zoom', - target_id: 'camera.office', - })!, - ); - - expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith( - expect.objectContaining({ - zoom: { - 'camera.office': { - zoom: {}, - }, - }, - }), - ); - }); - - it('non-default', async () => { - const api = createCardAPI(); - const manager = new ActionsManager(api); - - await manager.executeFrigateAction( - createAction({ - frigate_card_action: 'change_zoom', - target_id: 'camera.office', - zoom: 2, - pan: { - x: 3, - y: 4, - }, - })!, - ); - - expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith( - expect.objectContaining({ - zoom: { - 'camera.office': { - zoom: { - zoom: 2, - pan: { - x: 3, - y: 4, - }, - }, - }, - }, - }), - ); - }); - }); - - it('should handle unknown action', async () => { - const manager = new ActionsManager(createCardAPI()); - - const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => true); - - await manager.executeFrigateAction( - // Have to manually create the action (vs using `createAction()`) since - // it's malformed. - { - frigate_card_action: 'not_a_real_action', - } as unknown as FrigateCardCustomAction, - ); - - expect(spy).toBeCalledWith( - 'Frigate card received unknown card action: not_a_real_action', - ); - }); -}); - -describe('ActionsManager.executeActions', () => { - it('should execute actions', async () => { - const api = createCardAPI(); - const hass = createHASS(); - const element = document.createElement('div'); - - vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); - - const manager = new ActionsManager(api); - const action = createAction({ - frigate_card_action: 'default', - })!; - manager.executeActions(action); - - expect(frigateCardHandleAction).toBeCalledWith(element, hass, {}, action); - }); - - it('should not execute actions without hass', async () => { - const api = createCardAPI(); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null); - - const manager = new ActionsManager(api); - manager.executeActions( - createAction({ - frigate_card_action: 'default', - })!, - ); - - expect(api.getViewManager().setViewDefault).not.toBeCalled(); - }); -}); diff --git a/tests/card-controller/actions/actions-manager.test.ts b/tests/card-controller/actions/actions-manager.test.ts new file mode 100644 index 00000000..8da0dde3 --- /dev/null +++ b/tests/card-controller/actions/actions-manager.test.ts @@ -0,0 +1,289 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ActionsManager, + Interaction, + InteractionName, +} from '../../../src/card-controller/actions/actions-manager'; +import { FrigateCardView } from '../../../src/config/types'; +import { createLogAction } from '../../../src/utils/action'; +import { + createAction, + createCardAPI, + createConfig, + createHASS, + createView, +} from '../../test-utils'; + +describe('ActionsManager', () => { + describe('getMergedActions', () => { + const config = { + view: { + actions: { + tap_action: { + action: 'navigate', + navigation_path: '1', + }, + }, + }, + live: { + actions: { + tap_action: { + action: 'navigate', + navigation_path: '2', + }, + }, + }, + media_gallery: { + actions: { + tap_action: { + action: 'navigate', + navigation_path: '3', + }, + }, + }, + media_viewer: { + actions: { + tap_action: { + action: 'navigate', + navigation_path: '4', + }, + }, + }, + image: { + actions: { + tap_action: { + action: 'navigate', + navigation_path: '5', + }, + }, + }, + }; + + afterAll(() => { + vi.restoreAllMocks(); + }); + + it('should get no merged actions with a message', () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ view: 'live' }), + ); + vi.mocked(api.getMessageManager().hasMessage).mockReturnValue(true); + + const manager = new ActionsManager(api); + + expect(manager.getMergedActions()).toEqual({}); + }); + + describe('should get merged actions with live view', () => { + it.each([ + [ + 'live' as const, + { + tap_action: { + action: 'navigate', + navigation_path: '2', + }, + }, + ], + [ + 'clips' as const, + { + tap_action: { + action: 'navigate', + navigation_path: '3', + }, + }, + ], + [ + 'clip' as const, + { + tap_action: { + action: 'navigate', + navigation_path: '4', + }, + }, + ], + [ + 'image' as const, + { + tap_action: { + action: 'navigate', + navigation_path: '5', + }, + }, + ], + ['timeline' as const, {}], + ])('%s', (viewName: FrigateCardView, result: Record) => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ view: viewName }), + ); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig(config), + ); + + const manager = new ActionsManager(api); + + expect(manager.getMergedActions()).toEqual(result); + }); + }); + }); + + // @vitest-environment jsdom + describe('handleInteractionEvent', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('should handle interaction', () => { + const api = createCardAPI(); + const element = document.createElement('div'); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + actions: { + tap_action: createLogAction("Hello, world!"), + }, + }, + }), + ); + const manager = new ActionsManager(api); + + const hass = createHASS(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); + manager.handleInteractionEvent( + new CustomEvent('event', { detail: { action: 'tap' } }), + ); + expect(consoleSpy).toBeCalled(); + }); + + describe('should handle unexpected interactions', () => { + it.each([['malformed_type_of_tap' as const], ['double_tap' as const]])( + '%s', + (interaction: string) => { + const api = createCardAPI(); + const element = document.createElement('div'); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + actions: { + tap_action: createLogAction("Hello, world!"), + }, + }, + }), + ); + const manager = new ActionsManager(api); + + const hass = createHASS(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); + manager.handleInteractionEvent( + new CustomEvent('event', { + detail: { action: interaction as unknown as InteractionName }, + }), + ); + expect(consoleSpy).not.toBeCalled(); + }, + ); + }); + }); + + describe('handleCustomActionEvent', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('should handle event', () => { + const action = createLogAction("Hello, world!"); + const event = new CustomEvent('ll-custom', { + detail: action, + }); + + const api = createCardAPI(); + const manager = new ActionsManager(api); + + const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); + manager.handleCustomActionEvent(event); + expect(consoleSpy).toBeCalled(); + }); + + it('should not handle event without detail', () => { + const manager = new ActionsManager(createCardAPI()); + + const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); + manager.handleCustomActionEvent(new Event('ll-custom')); + expect(consoleSpy).not.toBeCalled(); + }); + }); + + describe('handleActionExecutionRequestEvent', () => { + it('should execute actions', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); + await manager.handleActionExecutionRequestEvent( + new CustomEvent('frigate-card:action:execution-request', { + detail: { action: createLogAction("Hello, world!") }, + }), + ); + expect(consoleSpy).toBeCalled(); + }); + }); + + describe('executeAction', () => { + it('should execute actions', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); + await manager.executeActions(createLogAction("Hello, world!")); + expect(consoleSpy).toBeCalled(); + }); + }); + + describe('uninitialize', () => { + beforeAll(() => { + vi.useFakeTimers(); + }); + afterAll(() => { + vi.useRealTimers(); + }); + + it('should stop actions', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); + const promise = manager.executeActions([ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + createAction({ + frigate_card_action: 'sleep', + duration: { + m: 1, + }, + })!, + createLogAction("Hello, world!"), + ]); + + // Stop inflight actions. + manager.uninitialize(); + + // Advance timers (causes the sleep to end). + vi.runOnlyPendingTimers(); + + await promise; + + // Action set will not continue. + expect(consoleSpy).not.toBeCalled(); + }); + }); +}); diff --git a/tests/card-controller/actions/actions/base.test.ts b/tests/card-controller/actions/actions/base.test.ts new file mode 100644 index 00000000..72ec9e72 --- /dev/null +++ b/tests/card-controller/actions/actions/base.test.ts @@ -0,0 +1,19 @@ +import { it } from 'vitest'; +import { BaseAction } from '../../../../src/card-controller/actions/actions/base'; +import { createCardAPI } from '../../../test-utils'; + +it('should construct', async () => { + const api = createCardAPI(); + const action = new BaseAction( + {}, + { + action: 'fire-dom-event', + }, + ); + + await action.execute(api); + await action.stop(); + + // These methods have no observable effect on the base class, so this test is + // currently only providing coverage and proof of no exceptions! +}); diff --git a/tests/card-controller/actions/actions/camera-select.test.ts b/tests/card-controller/actions/actions/camera-select.test.ts new file mode 100644 index 00000000..7c2abc2c --- /dev/null +++ b/tests/card-controller/actions/actions/camera-select.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, vi } from 'vitest'; +import { CameraSelectAction } from '../../../../src/card-controller/actions/actions/camera-select'; +import { createCardAPI, createConfig, createView } from '../../../test-utils'; + +describe('should handle camera_select action', () => { + it('with valid camera and view', async () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); + vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); + + const action = new CameraSelectAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'camera_select', + camera: 'camera', + }, + ); + + await action.execute(api); + + expect(api.getViewManager().setViewByParameters).toBeCalledWith( + expect.objectContaining({ + viewName: 'live', + cameraID: 'camera', + failSafe: true, + }), + ); + }); + + it('without config', async () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(null); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + view: 'timeline', + }), + ); + vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); + + const action = new CameraSelectAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'camera_select', + camera: 'camera', + }, + ); + + await action.execute(api); + + expect(api.getViewManager().setViewByParameters).toBeCalledWith( + expect.objectContaining({ + viewName: 'timeline', + cameraID: 'camera', + failSafe: true, + }), + ); + }); + + it('with target view', async () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + // Change to clips view when the camera changes. + camera_select: 'clips', + }, + }), + ); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + view: 'live', + }), + ); + vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); + + const action = new CameraSelectAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'camera_select', + camera: 'camera', + }, + ); + + await action.execute(api); + + expect(api.getViewManager().setViewByParameters).toBeCalledWith( + expect.objectContaining({ + viewName: 'clips', + cameraID: 'camera', + failSafe: true, + }), + ); + }); + + it('with triggered camera', async () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); + vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(api.getTriggersManager().getMostRecentlyTriggeredCameraID).mockReturnValue( + 'camera', + ); + + const action = new CameraSelectAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'camera_select', + triggered: true, + }, + ); + + await action.execute(api); + + expect(api.getViewManager().setViewByParameters).toBeCalledWith( + expect.objectContaining({ + viewName: 'live', + cameraID: 'camera', + failSafe: true, + }), + ); + }); + + it('without camera or triggered camera', async () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); + vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); + vi.mocked(api.getTriggersManager().getMostRecentlyTriggeredCameraID).mockReturnValue( + 'camera', + ); + + const action = new CameraSelectAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'camera_select', + }, + ); + + await action.execute(api); + + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + }); + + it('without a current view', async () => { + const api = createCardAPI(); + + const action = new CameraSelectAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'camera_select', + camera: 'camera', + }, + ); + + await action.execute(api); + + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + }); +}); diff --git a/tests/card-controller/actions/actions/camera-ui.test.ts b/tests/card-controller/actions/actions/camera-ui.test.ts new file mode 100644 index 00000000..09703fb1 --- /dev/null +++ b/tests/card-controller/actions/actions/camera-ui.test.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'vitest'; +import { CameraUIAction } from '../../../../src/card-controller/actions/actions/camera-ui'; +import { createCardAPI } from '../../../test-utils'; + +it('should handle camera_ui action', async () => { + const api = createCardAPI(); + const action = new CameraUIAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'camera_ui', + }, + ); + + await action.execute(api); + + expect(api.getCameraURLManager().openURL).toBeCalled(); +}); diff --git a/tests/card-controller/actions/actions/default.test.ts b/tests/card-controller/actions/actions/default.test.ts new file mode 100644 index 00000000..ac9692c8 --- /dev/null +++ b/tests/card-controller/actions/actions/default.test.ts @@ -0,0 +1,15 @@ +import { expect, it } from "vitest"; +import { DefaultAction } from "../../../../src/card-controller/actions/actions/default"; +import { createCardAPI } from "../../../test-utils"; + +it('should handle default action', async () => { + const api = createCardAPI(); + const action = new DefaultAction({}, { + action: 'fire-dom-event', + frigate_card_action: 'default', + }); + + await action.execute(api); + + expect(api.getViewManager().setViewDefault).toBeCalled(); +}); diff --git a/tests/card-controller/actions/actions/display-mode-select.test.ts b/tests/card-controller/actions/actions/display-mode-select.test.ts new file mode 100644 index 00000000..1e999a8b --- /dev/null +++ b/tests/card-controller/actions/actions/display-mode-select.test.ts @@ -0,0 +1,16 @@ +import { expect, it } from "vitest"; +import { DisplayModeSelectAction } from "../../../../src/card-controller/actions/actions/display-mode-select"; +import { createCardAPI } from "../../../test-utils"; + +it('should handle default action', async () => { + const api = createCardAPI(); + const action = new DisplayModeSelectAction({}, { + action: 'fire-dom-event', + frigate_card_action: 'display_mode_select', + display_mode: 'grid', + }); + + await action.execute(api); + + expect(api.getViewManager().setViewWithNewDisplayMode).toBeCalledWith('grid'); +}); diff --git a/tests/card-controller/actions/actions/download.test.ts b/tests/card-controller/actions/actions/download.test.ts new file mode 100644 index 00000000..3e9b321d --- /dev/null +++ b/tests/card-controller/actions/actions/download.test.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'vitest'; +import { DownloadAction } from '../../../../src/card-controller/actions/actions/download'; +import { createCardAPI } from '../../../test-utils'; + +it('should handle download action', async () => { + const api = createCardAPI(); + const action = new DownloadAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'download', + }, + ); + + await action.execute(api); + + expect(api.getDownloadManager().downloadViewerMedia).toBeCalled(); +}); diff --git a/tests/card-controller/actions/actions/expand.test.ts b/tests/card-controller/actions/actions/expand.test.ts new file mode 100644 index 00000000..1a3ac265 --- /dev/null +++ b/tests/card-controller/actions/actions/expand.test.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'vitest'; +import { ExpandAction } from '../../../../src/card-controller/actions/actions/expand'; +import { createCardAPI } from '../../../test-utils'; + +it('should handle expand action', async () => { + const api = createCardAPI(); + const action = new ExpandAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'expand', + }, + ); + + await action.execute(api); + + expect(api.getExpandManager().toggleExpanded).toBeCalled(); +}); diff --git a/tests/card-controller/actions/actions/fullscreen.test.ts b/tests/card-controller/actions/actions/fullscreen.test.ts new file mode 100644 index 00000000..d678b1a3 --- /dev/null +++ b/tests/card-controller/actions/actions/fullscreen.test.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'vitest'; +import { FullscreenAction } from '../../../../src/card-controller/actions/actions/fullscreen'; +import { createCardAPI } from '../../../test-utils'; + +it('should handle fullscreen action', async () => { + const api = createCardAPI(); + const action = new FullscreenAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'fullscreen', + }, + ); + + await action.execute(api); + + expect(api.getFullscreenManager().toggleFullscreen).toBeCalled(); +}); diff --git a/tests/card-controller/actions/actions/generic.test.ts b/tests/card-controller/actions/actions/generic.test.ts new file mode 100644 index 00000000..ae84a103 --- /dev/null +++ b/tests/card-controller/actions/actions/generic.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createCardAPI, createHASS, createLitElement } from '../../../test-utils'; +import { GenericAction } from '../../../../src/card-controller/actions/actions/generic'; +import { handleActionConfig } from '@dermotduffy/custom-card-helpers'; + +vi.mock('@dermotduffy/custom-card-helpers'); + +describe('should handle generic action', () => { + it('without hass', async () => { + const api = createCardAPI(); + const action = new GenericAction( + {}, + { + action: 'fire-dom-event', + }, + ); + + await action.execute(api); + + expect(handleActionConfig).not.toBeCalled(); + }); + + // @vitest-environment jsdom + it('with hass', async () => { + const api = createCardAPI(); + const hass = createHASS(); + const element = createLitElement(); + vi.mocked(api.getCardElementManager()).getElement.mockReturnValue(element); + vi.mocked(api.getHASSManager()).getHASS.mockReturnValue(hass); + const action = new GenericAction( + {}, + { + action: 'fire-dom-event', + }, + ); + + await action.execute(api); + + expect(handleActionConfig).toBeCalledWith( + element, + hass, + {}, + { action: 'fire-dom-event' }, + ); + }); +}); diff --git a/tests/card-controller/actions/actions/log.test.ts b/tests/card-controller/actions/actions/log.test.ts new file mode 100644 index 00000000..889f32c8 --- /dev/null +++ b/tests/card-controller/actions/actions/log.test.ts @@ -0,0 +1,24 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { LogAction } from '../../../../src/card-controller/actions/actions/log'; +import { createCardAPI } from '../../../test-utils'; + +afterEach(() => { + vi.resetAllMocks(); +}); + +it('should handle log action', async () => { + const api = createCardAPI(); + const action = new LogAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'log', + message: 'Hello, world!', + level: 'warn', + }, + ); + + const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => true); + await action.execute(api); + expect(spy).toBeCalledWith('Hello, world!'); +}); diff --git a/tests/card-controller/actions/actions/media-player.test.ts b/tests/card-controller/actions/actions/media-player.test.ts new file mode 100644 index 00000000..49a539e9 --- /dev/null +++ b/tests/card-controller/actions/actions/media-player.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { MediaPlayerAction } from '../../../../src/card-controller/actions/actions/media-player'; +import { createCardAPI, createView, createViewWithMedia } from '../../../test-utils'; + +afterEach(() => { + vi.resetAllMocks(); +}); + +describe('should handle media_player action', () => { + it('to stop', async () => { + const api = createCardAPI(); + + const action = new MediaPlayerAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'media_player', + media_player_action: 'stop', + media_player: 'this_is_a_media_player', + }, + ); + + await action.execute(api); + + expect(api.getMediaPlayerManager().stop).toBeCalledWith('this_is_a_media_player'); + }); + + it('to play live', async () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + camera: 'camera', + view: 'live', + }), + ); + + const action = new MediaPlayerAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'media_player', + media_player_action: 'play', + media_player: 'this_is_a_media_player', + }, + ); + + await action.execute(api); + + expect(api.getMediaPlayerManager().playLive).toBeCalledWith( + 'this_is_a_media_player', + 'camera', + ); + }); + + it('to play media', async () => { + const api = createCardAPI(); + const view = createViewWithMedia({ + camera: 'camera', + view: 'media', + }); + + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + + const action = new MediaPlayerAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'media_player', + media_player_action: 'play', + media_player: 'this_is_a_media_player', + }, + ); + + await action.execute(api); + + expect(api.getMediaPlayerManager().playMedia).toBeCalledWith( + 'this_is_a_media_player', + view.queryResults?.getSelectedResult(), + ); + }); + + it('to play media without selected media', async () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + view: 'media', + }), + ); + + const action = new MediaPlayerAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'media_player', + media_player_action: 'play', + media_player: 'this_is_a_media_player', + }, + ); + + await action.execute(api); + + expect(api.getMediaPlayerManager().playMedia).not.toBeCalled(); + }); +}); diff --git a/tests/card-controller/actions/actions/menu-toggle.test.ts b/tests/card-controller/actions/actions/menu-toggle.test.ts new file mode 100644 index 00000000..c9d1d276 --- /dev/null +++ b/tests/card-controller/actions/actions/menu-toggle.test.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'vitest'; +import { MenuToggleAction } from '../../../../src/card-controller/actions/actions/menu-toggle'; +import { createCardAPI } from '../../../test-utils'; + +it('should handle menu toggle action', async () => { + const api = createCardAPI(); + const action = new MenuToggleAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'menu_toggle', + }, + ); + + await action.execute(api); + + expect(api.getCardElementManager().toggleMenu).toBeCalled(); +}); diff --git a/tests/card-controller/actions/actions/microphone-mute.test.ts b/tests/card-controller/actions/actions/microphone-mute.test.ts new file mode 100644 index 00000000..6b343c48 --- /dev/null +++ b/tests/card-controller/actions/actions/microphone-mute.test.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'vitest'; +import { MicrophoneMuteAction } from '../../../../src/card-controller/actions/actions/microphone-mute'; +import { createCardAPI } from '../../../test-utils'; + +it('should handle microphone_mute action', async () => { + const api = createCardAPI(); + const action = new MicrophoneMuteAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'microphone_mute', + }, + ); + + await action.execute(api); + + expect(api.getMicrophoneManager().mute).toBeCalled(); +}); diff --git a/tests/card-controller/actions/actions/microphone-unmute.test.ts b/tests/card-controller/actions/actions/microphone-unmute.test.ts new file mode 100644 index 00000000..dd53b923 --- /dev/null +++ b/tests/card-controller/actions/actions/microphone-unmute.test.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'vitest'; +import { createCardAPI } from '../../../test-utils'; +import { MicrophoneUnmuteAction } from '../../../../src/card-controller/actions/actions/microphone-unmute'; + +it('should handle microphone_unmute action', async () => { + const api = createCardAPI(); + const action = new MicrophoneUnmuteAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'microphone_unmute', + }, + ); + + await action.execute(api); + + expect(api.getMicrophoneManager().unmute).toBeCalled(); +}); diff --git a/tests/card-controller/actions/actions/mute.test.ts b/tests/card-controller/actions/actions/mute.test.ts new file mode 100644 index 00000000..5ba80cce --- /dev/null +++ b/tests/card-controller/actions/actions/mute.test.ts @@ -0,0 +1,26 @@ +import { expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { MuteAction } from '../../../../src/card-controller/actions/actions/mute'; +import { FrigateCardMediaPlayer } from '../../../../src/types'; +import { createCardAPI, createMediaLoadedInfo } from '../../../test-utils'; + +it('should handle mute action', async () => { + const api = createCardAPI(); + const player = mock(); + vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue( + createMediaLoadedInfo({ + player: player, + }), + ); + const action = new MuteAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'mute', + }, + ); + + await action.execute(api); + + expect(player.mute).toBeCalled(); +}); diff --git a/tests/card-controller/actions/actions/pause.test.ts b/tests/card-controller/actions/actions/pause.test.ts new file mode 100644 index 00000000..733bf8a0 --- /dev/null +++ b/tests/card-controller/actions/actions/pause.test.ts @@ -0,0 +1,26 @@ +import { expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { PauseAction } from '../../../../src/card-controller/actions/actions/pause'; +import { FrigateCardMediaPlayer } from '../../../../src/types'; +import { createCardAPI, createMediaLoadedInfo } from '../../../test-utils'; + +it('should handle pause action', async () => { + const api = createCardAPI(); + const player = mock(); + vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue( + createMediaLoadedInfo({ + player: player, + }), + ); + const action = new PauseAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'pause', + }, + ); + + await action.execute(api); + + expect(player.pause).toBeCalled(); +}); diff --git a/tests/card-controller/actions/actions/play.test.ts b/tests/card-controller/actions/actions/play.test.ts new file mode 100644 index 00000000..f0f3879e --- /dev/null +++ b/tests/card-controller/actions/actions/play.test.ts @@ -0,0 +1,26 @@ +import { expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { PlayAction } from '../../../../src/card-controller/actions/actions/play'; +import { FrigateCardMediaPlayer } from '../../../../src/types'; +import { createCardAPI, createMediaLoadedInfo } from '../../../test-utils'; + +it('should handle play action', async () => { + const api = createCardAPI(); + const player = mock(); + vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue( + createMediaLoadedInfo({ + player: player, + }), + ); + const action = new PlayAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'play', + }, + ); + + await action.execute(api); + + expect(player.play).toBeCalled(); +}); diff --git a/tests/card-controller/actions/actions/ptz-controls.test.ts b/tests/card-controller/actions/actions/ptz-controls.test.ts new file mode 100644 index 00000000..7ab107fb --- /dev/null +++ b/tests/card-controller/actions/actions/ptz-controls.test.ts @@ -0,0 +1,21 @@ +import { expect, it } from 'vitest'; +import { createCardAPI } from '../../../test-utils'; +import { PTZControlsAction } from '../../../../src/card-controller/actions/actions/ptz-controls'; + +it('should handle ptz_controls action', async () => { + const api = createCardAPI(); + const action = new PTZControlsAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'ptz_controls', + enabled: true, + }, + ); + + await action.execute(api); + + expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith( + expect.objectContaining({ ptzControls: { enabled: true } }), + ); +}); diff --git a/tests/card-controller/actions/actions/ptz-digital.test.ts b/tests/card-controller/actions/actions/ptz-digital.test.ts new file mode 100644 index 00000000..7652fbc3 --- /dev/null +++ b/tests/card-controller/actions/actions/ptz-digital.test.ts @@ -0,0 +1,479 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { PTZDigitalAction } from '../../../../src/card-controller/actions/actions/ptz-digital'; +import { + PartialZoomSettings, + ZoomSettingsObserved, +} from '../../../../src/components-lib/zoom/types'; +import { PTZAction } from '../../../../src/config/ptz'; +import { createCardAPI, createView } from '../../../test-utils'; + +describe('should handle ptz digital action', () => { + const defaultSettings = { + pan: { + x: 50, + y: 50, + }, + zoom: 1, + }; + + const createObserved = ( + observed?: Partial, + ): ZoomSettingsObserved => ({ + ...defaultSettings, + isDefault: true, + unzoomed: true, + ...observed, + }); + + it('should honor absolute parameters', async () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); + + const action = new PTZDigitalAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'ptz_digital', + absolute: { + zoom: 2, + pan: { + x: 3, + y: 4, + }, + }, + }, + ); + + await action.execute(api); + + expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith({ + zoom: { + camera: { + observed: undefined, + requested: { + pan: { + x: 3, + y: 4, + }, + zoom: 2, + }, + }, + }, + }); + }); + + it('should return to default without absolute parameters or action', async () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); + + const action = new PTZDigitalAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'ptz_digital', + }, + ); + + await action.execute(api); + + expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith({ + zoom: { + camera: { + observed: undefined, + requested: {}, + }, + }, + }); + }); + + it('should do nothing without a view', async () => { + const api = createCardAPI(); + + const action = new PTZDigitalAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'ptz_digital', + ptz_action: 'left', + }, + ); + + await action.execute(api); + + expect(api.getViewManager().setViewWithMergedContext).not.toBeCalledWith(); + }); + + it('should do nothing without a camera', async () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + // There is no media associated with a timeline, so there's no camera to + // change the PTZ settings for. + view: 'timeline', + }), + ); + + const action = new PTZDigitalAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'ptz_digital', + ptz_action: 'left', + }, + ); + + await action.execute(api); + + expect(api.getViewManager().setViewWithMergedContext).not.toBeCalled(); + }); + + describe('should honor ptz_action', () => { + it.each([ + [ + 'zoom_in', + 'zoom_in' as const, + { + zoom: 1.1, + }, + createObserved(), + ], + [ + 'zoom_in at maximum zoom', + 'zoom_in' as const, + { + zoom: 10, + }, + createObserved({ + zoom: 10, + }), + ], + [ + 'zoom_out', + 'zoom_out' as const, + { + zoom: 1.9, + }, + createObserved({ + zoom: 2, + }), + ], + [ + 'zoom_out at minimum zoom', + 'zoom_out' as const, + { + zoom: 1, + }, + createObserved({ + zoom: 1, + }), + ], + [ + 'left', + 'left' as const, + { + pan: { + x: 45, + y: 50, + }, + }, + createObserved({ + pan: { + x: 50, + y: 50, + }, + }), + ], + [ + 'left at left edge', + 'left' as const, + { + pan: { + x: 0, + y: 50, + }, + }, + createObserved({ + pan: { + x: 0, + y: 50, + }, + }), + ], + [ + 'right', + 'right' as const, + { + pan: { + x: 55, + y: 50, + }, + }, + createObserved({ + pan: { + x: 50, + y: 50, + }, + }), + ], + [ + 'right at right edge', + 'right' as const, + { + pan: { + x: 100, + y: 50, + }, + }, + createObserved({ + pan: { + x: 100, + y: 50, + }, + }), + ], + [ + 'up', + 'up' as const, + { + pan: { + x: 50, + y: 45, + }, + }, + createObserved({ + pan: { + x: 50, + y: 50, + }, + }), + ], + [ + 'up at top edge', + 'up' as const, + { + pan: { + x: 50, + y: 0, + }, + }, + createObserved({ + pan: { + x: 50, + y: 0, + }, + }), + ], + [ + 'down', + 'down' as const, + { + pan: { + x: 50, + y: 55, + }, + }, + createObserved({ + pan: { + x: 50, + y: 50, + }, + }), + ], + [ + 'down at bottom edge', + 'down' as const, + { + pan: { + x: 50, + y: 100, + }, + }, + createObserved({ + pan: { + x: 50, + y: 100, + }, + }), + ], + [ + 'action with undefined observed', + 'down' as const, + { + pan: { + x: 50, + y: 55, + }, + }, + ], + ])( + '%s', + async ( + _testTitle: string, + ptzAction: PTZAction, + expectedSettings: PartialZoomSettings, + current?: ZoomSettingsObserved, + ) => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + context: { + zoom: { + camera: { + observed: current, + }, + }, + }, + }), + ); + + const action = new PTZDigitalAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'ptz_digital', + ptz_action: ptzAction, + }, + ); + + await action.execute(api); + + expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith({ + zoom: { + camera: { + observed: undefined, + requested: { + ...defaultSettings, + ...expectedSettings, + }, + }, + }, + }); + }, + ); + }); + + // @vitest-environment jsdom + describe('should honor ptz_phase', () => { + beforeAll(() => { + vi.useFakeTimers(); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + it('start', async () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); + + const action = new PTZDigitalAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'ptz_digital', + ptz_action: 'right', + ptz_phase: 'start', + }, + ); + + await action.execute(api); + + expect(api.getViewManager().setViewWithMergedContext).toHaveBeenLastCalledWith({ + zoom: { + camera: { + observed: undefined, + requested: { + ...defaultSettings, + pan: { + x: 55, + y: 50, + }, + }, + }, + }, + }); + + // Update the context to reflect the first step. + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + context: { + zoom: { + camera: { + observed: createObserved({ + pan: { + x: 55, + y: 50, + }, + }), + }, + }, + }, + }), + ); + + vi.runOnlyPendingTimers(); + + expect(api.getViewManager().setViewWithMergedContext).toHaveBeenLastCalledWith({ + zoom: { + camera: { + observed: undefined, + requested: { + ...defaultSettings, + pan: { + x: 60, + y: 50, + }, + }, + }, + }, + }); + expect(api.getViewManager().setViewWithMergedContext).toBeCalledTimes(2); + + action.stop(); + vi.runOnlyPendingTimers(); + + expect(api.getViewManager().setViewWithMergedContext).toBeCalledTimes(2); + }); + + it('stop', async () => { + const api = createCardAPI(); + const context = {}; + vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); + + const startAction = new PTZDigitalAction(context, { + action: 'fire-dom-event', + frigate_card_action: 'ptz_digital', + ptz_action: 'right', + ptz_phase: 'start', + }); + await startAction.execute(api); + + expect(api.getViewManager().setViewWithMergedContext).toHaveBeenLastCalledWith({ + zoom: { + camera: { + observed: undefined, + requested: { + ...defaultSettings, + pan: { + x: 55, + y: 50, + }, + }, + }, + }, + }); + expect(api.getViewManager().setViewWithMergedContext).toBeCalledTimes(1); + + const stopAction = new PTZDigitalAction(context, { + action: 'fire-dom-event', + frigate_card_action: 'ptz_digital', + ptz_phase: 'stop', + }); + await stopAction.execute(api); + + vi.runOnlyPendingTimers(); + + expect(api.getViewManager().setViewWithMergedContext).toBeCalledTimes(1); + }); + }); +}); diff --git a/tests/card-controller/actions/actions/ptz-multi.test.ts b/tests/card-controller/actions/actions/ptz-multi.test.ts new file mode 100644 index 00000000..92ee7590 --- /dev/null +++ b/tests/card-controller/actions/actions/ptz-multi.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Capabilities } from '../../../../src/camera-manager/capabilities'; +import { PTZMultiAction } from '../../../../src/card-controller/actions/actions/ptz-multi'; +import { + createCameraManager, + createCardAPI, + createStore, + createView, +} from '../../../test-utils'; + +describe('should handle ptz multi action', () => { + describe.each([ + ['with explicit target_id', 'camera.office'], + ['without explicit target_id', null], + ])('%s', async (_testTitle: string, targetID: string | null) => { + it('should use real ptz when camera has ptz support', async () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + camera: 'camera.office', + }), + ); + const store = createStore([ + { + cameraID: 'camera.office', + capabilities: new Capabilities({ ptz: { left: ['relative'] } }), + }, + ]); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); + + const action = new PTZMultiAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'ptz_multi', + ptz_action: 'left', + ...(targetID && { target_id: targetID }), + }, + ); + + await action.execute(api); + + expect(api.getCameraManager().executePTZAction).toBeCalledWith( + 'camera.office', + 'left', + { + phase: undefined, + preset: undefined, + }, + ); + expect(api.getViewManager().setViewWithMergedContext).not.toBeCalled(); + }); + + it('should use digital ptz when camera does not have ptz support', async () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + camera: 'camera.office', + }), + ); + const store = createStore([ + { + cameraID: 'camera.office', + }, + ]); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); + + const action = new PTZMultiAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'ptz_multi', + ptz_action: 'right', + ...(targetID && { target_id: targetID }), + }, + ); + + await action.execute(api); + + expect(api.getCameraManager().executePTZAction).not.toBeCalled(); + expect(api.getViewManager().setViewWithMergedContext).toHaveBeenLastCalledWith({ + zoom: { + 'camera.office': { + observed: undefined, + requested: expect.objectContaining({ + pan: { + x: 55, + y: 50, + }, + zoom: 1, + }), + }, + }, + }); + }); + }); + + it('should do nothing without a view or explicit target_id', async () => { + const api = createCardAPI(); + const store = createStore([ + { + cameraID: 'camera.office', + }, + ]); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); + + const action = new PTZMultiAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'ptz_multi', + ptz_action: 'right', + }, + ); + + await action.execute(api); + + expect(api.getCameraManager().executePTZAction).not.toBeCalled(); + expect(api.getViewManager().setViewWithMergedContext).not.toBeCalled(); + }); + + it('should do nothing with a media-less view without an explicit target_id', async () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + view: 'timeline', + }), + ); + const store = createStore([ + { + cameraID: 'camera.office', + }, + ]); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); + + const action = new PTZMultiAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'ptz_multi', + ptz_action: 'right', + }, + ); + + await action.execute(api); + + expect(api.getCameraManager().executePTZAction).not.toBeCalled(); + expect(api.getViewManager().setViewWithMergedContext).not.toBeCalled(); + }); +}); diff --git a/tests/card-controller/actions/actions/ptz.test.ts b/tests/card-controller/actions/actions/ptz.test.ts new file mode 100644 index 00000000..7217d4d8 --- /dev/null +++ b/tests/card-controller/actions/actions/ptz.test.ts @@ -0,0 +1,513 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { PTZAction } from '../../../../src/card-controller/actions/actions/ptz'; +import { + createCameraConfig, + createCameraManager, + createCardAPI, + createStore, + createView, +} from '../../../test-utils'; +import { Capabilities } from '../../../../src/camera-manager/capabilities'; + +describe('should handle ptz action', () => { + it('should execute simple action', async () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + camera: 'camera.office', + }), + ); + const store = createStore([ + { + cameraID: 'camera.office', + capabilities: new Capabilities({ ptz: { left: ['relative'] } }), + }, + ]); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); + + const action = new PTZAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'ptz', + ptz_action: 'left', + camera: 'camera.office', + }, + ); + + await action.execute(api); + + expect(api.getCameraManager().executePTZAction).toBeCalledWith( + 'camera.office', + 'left', + { + phase: undefined, + preset: undefined, + }, + ); + }); + + describe('without explicit camera', () => { + it('when current camera supports PTZ', async () => { + const api = createCardAPI(); + const store = createStore([ + { + cameraID: 'camera.office', + capabilities: new Capabilities({ ptz: { left: ['relative'] } }), + }, + ]); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ camera: 'camera.office' }), + ); + + const action = new PTZAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'ptz', + ptz_action: 'left', + }, + ); + + await action.execute(api); + + expect(api.getCameraManager().executePTZAction).toBeCalledWith( + 'camera.office', + 'left', + { + phase: undefined, + preset: undefined, + }, + ); + }); + + it('when substream supports PTZ', async () => { + const api = createCardAPI(); + const store = createStore([ + { + cameraID: 'camera.office', + config: createCameraConfig({ + dependencies: { cameras: ['camera.office_hd'] }, + }), + }, + { + cameraID: 'camera.office_hd', + capabilities: new Capabilities({ ptz: { left: ['relative'] } }), + }, + ]); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + camera: 'camera.office', + context: { + live: { + overrides: new Map([['camera.office', 'camera.office_hd']]), + }, + }, + }), + ); + + const action = new PTZAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'ptz', + ptz_action: 'left', + }, + ); + + await action.execute(api); + + expect(api.getCameraManager().executePTZAction).toBeCalledWith( + 'camera.office_hd', + 'left', + { + phase: undefined, + preset: undefined, + }, + ); + }); + + it('when no camera supports PTZ', async () => { + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ camera: 'camera.office' }), + ); + + const action = new PTZAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'ptz', + ptz_action: 'left', + }, + ); + + await action.execute(api); + + expect(api.getCameraManager().executePTZAction).not.toBeCalled(); + }); + }); + + it('when there is no view', async () => { + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + + const action = new PTZAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'ptz', + ptz_action: 'left', + }, + ); + + await action.execute(api); + + expect(api.getCameraManager().executePTZAction).not.toBeCalled(); + }); + + describe('when there is no action', () => { + it('should call first preset', async () => { + const api = createCardAPI(); + const store = createStore([ + { + cameraID: 'camera.office', + capabilities: new Capabilities({ ptz: { presets: ['home'] } }), + }, + ]); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ camera: 'camera.office' }), + ); + + const action = new PTZAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'ptz', + }, + ); + + await action.execute(api); + + expect(api.getCameraManager().executePTZAction).toBeCalledWith( + 'camera.office', + 'preset', + { + phase: undefined, + preset: 'home', + }, + ); + }); + + it('should not call preset when there are no presets', async () => { + const api = createCardAPI(); + const store = createStore([ + { + cameraID: 'camera.office', + capabilities: new Capabilities({ + ptz: { + left: ['relative'], + presets: [], + }, + }), + }, + ]); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ camera: 'camera.office' }), + ); + + const action = new PTZAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'ptz', + }, + ); + + await action.execute(api); + + expect(api.getCameraManager().executePTZAction).not.toBeCalled(); + }); + }); + + it('should execute preset', async () => { + const api = createCardAPI(); + const store = createStore([ + { + cameraID: 'camera.office', + capabilities: new Capabilities({ + ptz: { + presets: ['window'], + }, + }), + }, + ]); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ camera: 'camera.office' }), + ); + + const action = new PTZAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'ptz', + ptz_action: 'preset', + ptz_preset: 'window', + }, + ); + + await action.execute(api); + + expect(api.getCameraManager().executePTZAction).toBeCalledWith( + 'camera.office', + 'preset', + { + phase: undefined, + preset: 'window', + }, + ); + }); + + it('should execute action with phase', async () => { + const api = createCardAPI(); + const store = createStore([ + { + cameraID: 'camera.office', + capabilities: new Capabilities({ + ptz: { + left: ['continuous'], + }, + }), + }, + ]); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ camera: 'camera.office' }), + ); + + const action = new PTZAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'ptz', + ptz_action: 'left', + ptz_phase: 'start', + }, + ); + + await action.execute(api); + + expect(api.getCameraManager().executePTZAction).toBeCalledWith( + 'camera.office', + 'left', + { + phase: 'start', + }, + ); + }); + + // @vitest-environment jsdom + describe('when relative is requested but unsupported', () => { + beforeAll(() => { + vi.useFakeTimers(); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + it('should emulate relative', async () => { + const api = createCardAPI(); + const store = createStore([ + { + cameraID: 'camera.office', + capabilities: new Capabilities({ + ptz: { + left: ['continuous'], + }, + }), + }, + ]); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ camera: 'camera.office' }), + ); + + const action = new PTZAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'ptz', + ptz_action: 'left', + }, + ); + + await action.execute(api); + + expect(api.getCameraManager().executePTZAction).toBeCalledWith( + 'camera.office', + 'left', + { + phase: 'start', + }, + ); + + vi.runOnlyPendingTimers(); + expect(api.getCameraManager().executePTZAction).toBeCalledWith( + 'camera.office', + 'left', + { + phase: 'stop', + }, + ); + }); + + it('should honor stop', async () => { + const api = createCardAPI(); + const store = createStore([ + { + cameraID: 'camera.office', + capabilities: new Capabilities({ + ptz: { + left: ['continuous'], + }, + }), + }, + ]); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ camera: 'camera.office' }), + ); + + const context = {}; + const action = new PTZAction(context, { + action: 'fire-dom-event', + frigate_card_action: 'ptz', + ptz_action: 'left', + }); + + await action.execute(api); + + expect(api.getCameraManager().executePTZAction).toBeCalledTimes(1); + + action.stop(); + vi.runOnlyPendingTimers(); + + expect(api.getCameraManager().executePTZAction).toBeCalledTimes(1); + }); + }); + + describe('when continuous is requested but unsupported', () => { + beforeAll(() => { + vi.useFakeTimers(); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + it('should emulate continuous', async () => { + const api = createCardAPI(); + const store = createStore([ + { + cameraID: 'camera.office', + capabilities: new Capabilities({ + ptz: { + left: ['relative'], + }, + }), + }, + ]); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ camera: 'camera.office' }), + ); + + const context = {}; + const startAction = new PTZAction( + context, + { + action: 'fire-dom-event', + frigate_card_action: 'ptz', + ptz_action: 'left', + ptz_phase: 'start', + }, + ); + await startAction.execute(api); + + expect(api.getCameraManager().executePTZAction).toBeCalledWith( + 'camera.office', + 'left', + { + phase: undefined, + }, + ); + expect(api.getCameraManager().executePTZAction).toBeCalledTimes(1); + + await vi.runOnlyPendingTimersAsync() + expect(api.getCameraManager().executePTZAction).toBeCalledTimes(2); + + await vi.runOnlyPendingTimersAsync() + expect(api.getCameraManager().executePTZAction).toBeCalledTimes(3); + + const stopAction = new PTZAction( + context, + { + action: 'fire-dom-event', + frigate_card_action: 'ptz', + ptz_action: 'left', + ptz_phase: 'stop', + }, + ); + await stopAction.execute(api); + + // There should be no additional calls. + await vi.runOnlyPendingTimersAsync() + expect(api.getCameraManager().executePTZAction).toBeCalledTimes(3); + }); + + it('should honor stop', async () => { + const api = createCardAPI(); + const store = createStore([ + { + cameraID: 'camera.office', + capabilities: new Capabilities({ + ptz: { + left: ['relative'], + }, + }), + }, + ]); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ camera: 'camera.office' }), + ); + + const context = {}; + const action = new PTZAction(context, { + action: 'fire-dom-event', + frigate_card_action: 'ptz', + ptz_action: 'left', + ptz_phase: 'start', + }); + + await action.execute(api); + expect(api.getCameraManager().executePTZAction).toBeCalledTimes(1); + + await vi.runOnlyPendingTimersAsync() + expect(api.getCameraManager().executePTZAction).toBeCalledTimes(2); + + action.stop(); + await vi.runOnlyPendingTimersAsync() + + // There should be no additional calls. + expect(api.getCameraManager().executePTZAction).toBeCalledTimes(2); + }); + }); +}); diff --git a/tests/card-controller/actions/actions/screenshot.test.ts b/tests/card-controller/actions/actions/screenshot.test.ts new file mode 100644 index 00000000..3e773aa0 --- /dev/null +++ b/tests/card-controller/actions/actions/screenshot.test.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'vitest'; +import { ScreenshotAction } from '../../../../src/card-controller/actions/actions/screenshot'; +import { createCardAPI } from '../../../test-utils'; + +it('should handle screenshot action', async () => { + const api = createCardAPI(); + const action = new ScreenshotAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'screenshot', + }, + ); + + await action.execute(api); + + expect(api.getDownloadManager().downloadScreenshot).toBeCalled(); +}); diff --git a/tests/card-controller/actions/actions/set.test.ts b/tests/card-controller/actions/actions/set.test.ts new file mode 100644 index 00000000..e05d6f6a --- /dev/null +++ b/tests/card-controller/actions/actions/set.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ActionSet } from '../../../../src/card-controller/actions/actions/set'; +import { createLogAction } from '../../../../src/utils/action'; +import { createCardAPI } from '../../../test-utils'; + +describe('ActionSet', () => { + it('should execute single action', async () => { + const api = createCardAPI(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const set = new ActionSet({}, createLogAction('Hello, world!')); + + const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); + await set.execute(api); + expect(consoleSpy).toBeCalled(); + }); + + it('should not execute invalid action', async () => { + const api = createCardAPI(); + const set = new ActionSet( + {}, + createLogAction('Hello, world!', { + cardID: 'another-card', + }), + ); + + const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); + await set.execute(api); + expect(consoleSpy).not.toBeCalled(); + }); + + it('should stop execution', async () => { + const api = createCardAPI(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const set = new ActionSet({}, createLogAction('Hello, world!')); + + const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); + await set.stop(); + await set.execute(api); + expect(consoleSpy).not.toBeCalled(); + }); +}); diff --git a/tests/card-controller/actions/actions/sleep.test.ts b/tests/card-controller/actions/actions/sleep.test.ts new file mode 100644 index 00000000..0515b45a --- /dev/null +++ b/tests/card-controller/actions/actions/sleep.test.ts @@ -0,0 +1,29 @@ +import { afterAll, expect, it, vi } from 'vitest'; +import { createCardAPI } from '../../../test-utils'; +import { SleepAction } from '../../../../src/card-controller/actions/actions/sleep'; +import { sleep } from '../../../../src/utils/basic'; + +vi.mock('../../../../src/utils/basic'); + +afterAll(() => { + vi.restoreAllMocks(); +}); + +it('should handle sleep action', async () => { + const api = createCardAPI(); + const action = new SleepAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'sleep', + duration: { + s: 5, + ms: 200 + }, + }, + ); + + await action.execute(api); + + expect(sleep).toBeCalledWith(5.2); +}); diff --git a/tests/card-controller/actions/actions/substream-off.test.ts b/tests/card-controller/actions/actions/substream-off.test.ts new file mode 100644 index 00000000..149b2d17 --- /dev/null +++ b/tests/card-controller/actions/actions/substream-off.test.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'vitest'; +import { SubstreamOffAction } from '../../../../src/card-controller/actions/actions/substream-off'; +import { createCardAPI } from '../../../test-utils'; + +it('should handle live_substream_off action', async () => { + const api = createCardAPI(); + const action = new SubstreamOffAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'live_substream_off', + }, + ); + + await action.execute(api); + + expect(api.getViewManager().setViewWithoutSubstream).toBeCalled(); +}); diff --git a/tests/card-controller/actions/actions/substream-on.test.ts b/tests/card-controller/actions/actions/substream-on.test.ts new file mode 100644 index 00000000..21ede4bf --- /dev/null +++ b/tests/card-controller/actions/actions/substream-on.test.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'vitest'; +import { SubstreamOnAction } from '../../../../src/card-controller/actions/actions/substream-on'; +import { createCardAPI } from '../../../test-utils'; + +it('should handle live_substream_on action', async () => { + const api = createCardAPI(); + const action = new SubstreamOnAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'live_substream_on', + }, + ); + + await action.execute(api); + + expect(api.getViewManager().setViewWithSubstream).toBeCalledWith(); +}); diff --git a/tests/card-controller/actions/actions/substream-select.test.ts b/tests/card-controller/actions/actions/substream-select.test.ts new file mode 100644 index 00000000..7ab186f8 --- /dev/null +++ b/tests/card-controller/actions/actions/substream-select.test.ts @@ -0,0 +1,19 @@ +import { expect, it } from 'vitest'; +import { SubstreamSelectAction } from '../../../../src/card-controller/actions/actions/substream-select'; +import { createCardAPI } from '../../../test-utils'; + +it('should handle live_substream_select action', async () => { + const api = createCardAPI(); + const action = new SubstreamSelectAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'live_substream_select', + camera: 'substream', + }, + ); + + await action.execute(api); + + expect(api.getViewManager().setViewWithSubstream).toBeCalledWith('substream'); +}); diff --git a/tests/card-controller/actions/actions/unmute.test.ts b/tests/card-controller/actions/actions/unmute.test.ts new file mode 100644 index 00000000..34a59a46 --- /dev/null +++ b/tests/card-controller/actions/actions/unmute.test.ts @@ -0,0 +1,26 @@ +import { expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { UnmuteAction } from '../../../../src/card-controller/actions/actions/unmute'; +import { FrigateCardMediaPlayer } from '../../../../src/types'; +import { createCardAPI, createMediaLoadedInfo } from '../../../test-utils'; + +it('should handle unmute action', async () => { + const api = createCardAPI(); + const player = mock(); + vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue( + createMediaLoadedInfo({ + player: player, + }), + ); + const action = new UnmuteAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: 'unmute', + }, + ); + + await action.execute(api); + + expect(player.unmute).toBeCalled(); +}); diff --git a/tests/card-controller/actions/actions/view.test.ts b/tests/card-controller/actions/actions/view.test.ts new file mode 100644 index 00000000..16f17f79 --- /dev/null +++ b/tests/card-controller/actions/actions/view.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { ViewAction } from '../../../../src/card-controller/actions/actions/view'; +import { createCardAPI } from '../../../test-utils'; + +describe('should handle view action', () => { + it.each([ + ['clip' as const], + ['clips' as const], + ['diagnostics' as const], + ['image' as const], + ['live' as const], + ['recording' as const], + ['recordings' as const], + ['snapshot' as const], + ['snapshots' as const], + ['timeline' as const], + ])('%s', async (viewName) => { + const api = createCardAPI(); + + const action = new ViewAction( + {}, + { + action: 'fire-dom-event', + frigate_card_action: viewName, + }, + ); + + await action.execute(api); + + expect(api.getViewManager().setViewByParameters).toBeCalledWith( + expect.objectContaining({ + viewName: viewName, + }), + ); + }); +}); diff --git a/tests/card-controller/actions/factory.test.ts b/tests/card-controller/actions/factory.test.ts new file mode 100644 index 00000000..84bb954d --- /dev/null +++ b/tests/card-controller/actions/factory.test.ts @@ -0,0 +1,138 @@ +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'; +import { DisplayModeSelectAction } from '../../../src/card-controller/actions/actions/display-mode-select'; +import { DownloadAction } from '../../../src/card-controller/actions/actions/download'; +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 { 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'; +import { MicrophoneMuteAction } from '../../../src/card-controller/actions/actions/microphone-mute'; +import { MicrophoneUnmuteAction } from '../../../src/card-controller/actions/actions/microphone-unmute'; +import { MuteAction } from '../../../src/card-controller/actions/actions/mute'; +import { PauseAction } from '../../../src/card-controller/actions/actions/pause'; +import { PlayAction } from '../../../src/card-controller/actions/actions/play'; +import { PTZAction } from '../../../src/card-controller/actions/actions/ptz'; +import { PTZControlsAction } from '../../../src/card-controller/actions/actions/ptz-controls'; +import { PTZDigitalAction } from '../../../src/card-controller/actions/actions/ptz-digital'; +import { PTZMultiAction } from '../../../src/card-controller/actions/actions/ptz-multi'; +import { ScreenshotAction } from '../../../src/card-controller/actions/actions/screenshot'; +import { SleepAction } from '../../../src/card-controller/actions/actions/sleep'; +import { SubstreamOffAction } from '../../../src/card-controller/actions/actions/substream-off'; +import { SubstreamOnAction } from '../../../src/card-controller/actions/actions/substream-on'; +import { SubstreamSelectAction } from '../../../src/card-controller/actions/actions/substream-select'; +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 { FrigateCardCustomAction } from '../../../src/config/types'; + +// @vitest-environment jsdom +describe('ActionFactory', () => { + it('mismatched card-id', () => { + const factory = new ActionFactory(); + expect( + factory.createAction( + {}, + { action: 'fire-dom-event', frigate_card_action: 'clip', card_id: 'card_id' }, + { + cardID: 'different_card_id', + }, + ), + ).toBeNull(); + }); + + describe('generic', () => { + it('non frigate card action', () => { + const factory = new ActionFactory(); + expect(factory.createAction({}, { action: 'fire-dom-event' })).toBeInstanceOf( + GenericAction, + ); + }); + + it('non fire-dom-event', () => { + const factory = new ActionFactory(); + expect(factory.createAction({}, { action: 'more-info' })).toBeInstanceOf( + GenericAction, + ); + }); + }); + + describe('actions', () => { + it.each([ + [{ frigate_card_action: 'camera_select' as const }, CameraSelectAction], + [{ frigate_card_action: 'camera_ui' as const }, CameraUIAction], + [{ frigate_card_action: 'clip' as const }, ViewAction], + [{ frigate_card_action: 'clips' as const }, ViewAction], + [{ frigate_card_action: 'default' as const }, DefaultAction], + [{ frigate_card_action: 'diagnostics' as const }, ViewAction], + [ + { + frigate_card_action: 'display_mode_select' as const, + display_mode: 'single' as const, + }, + DisplayModeSelectAction, + ], + [{ frigate_card_action: 'download' as const }, DownloadAction], + [{ frigate_card_action: 'expand' as const }, ExpandAction], + [{ frigate_card_action: 'fullscreen' as const }, FullscreenAction], + [{ frigate_card_action: 'image' as const }, ViewAction], + [{ frigate_card_action: 'live_substream_off' as const }, SubstreamOffAction], + [{ frigate_card_action: 'live_substream_on' as const }, SubstreamOnAction], + [ + { + frigate_card_action: 'live_substream_select' as const, + camera: 'camera.office', + }, + SubstreamSelectAction, + ], + [{ frigate_card_action: 'live' as const }, ViewAction], + [ + { frigate_card_action: 'log' as const, message: 'Hello, world!' as const }, + LogAction, + ], + [ + { + frigate_card_action: 'media_player' as const, + media_player: 'media_player.foo' as const, + media_player_action: 'play' as const, + }, + MediaPlayerAction, + ], + [{ frigate_card_action: 'menu_toggle' as const }, MenuToggleAction], + [{ frigate_card_action: 'microphone_mute' as const }, MicrophoneMuteAction], + [{ frigate_card_action: 'microphone_unmute' as const }, MicrophoneUnmuteAction], + [{ frigate_card_action: 'mute' as const }, MuteAction], + [{ frigate_card_action: 'pause' as const }, PauseAction], + [{ frigate_card_action: 'play' as const }, PlayAction], + [{ frigate_card_action: 'ptz_digital' as const }, PTZDigitalAction], + [ + { frigate_card_action: 'ptz_multi' as const, ptz_action: 'right' as const }, + PTZMultiAction, + ], + [{ frigate_card_action: 'ptz' as const, ptz_action: 'right' as const }, PTZAction], + [{ frigate_card_action: 'recording' as const }, ViewAction], + [{ frigate_card_action: 'recordings' as const }, ViewAction], + [{ frigate_card_action: 'screenshot' as const }, ScreenshotAction], + [ + { frigate_card_action: 'ptz_controls' as const, enabled: true }, + PTZControlsAction, + ], + [{ frigate_card_action: 'sleep' as const }, SleepAction], + [{ frigate_card_action: 'snapshot' as const }, ViewAction], + [{ frigate_card_action: 'snapshots' as const }, ViewAction], + [{ frigate_card_action: 'timeline' as const }, ViewAction], + [{ frigate_card_action: 'unmute' as const }, UnmuteAction], + ])( + 'frigate_card_action: $frigate_card_action', + (action: Partial, classObject: object) => { + const factory = new ActionFactory(); + expect( + factory.createAction({}, { action: 'fire-dom-event', ...action }), + ).toBeInstanceOf(classObject); + }, + ); + }); +}); diff --git a/tests/card-controller/actions/utils/time-delta.test.ts b/tests/card-controller/actions/utils/time-delta.test.ts new file mode 100644 index 00000000..6efdb63c --- /dev/null +++ b/tests/card-controller/actions/utils/time-delta.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { timeDeltaToSeconds } from '../../../../src/card-controller/actions/utils/time-delta'; + +describe('timeDeltaToSeconds', () => { + it('hours', () => { + expect(timeDeltaToSeconds({ h: 1 })).toBe(3600); + }); + it('minutes', () => { + expect(timeDeltaToSeconds({ m: 1 })).toBe(60); + }); + it('seconds', () => { + expect(timeDeltaToSeconds({ s: 1 })).toBe(1); + }); + it('milliseconds', () => { + expect(timeDeltaToSeconds({ ms: 1 })).toBe(0.001); + }); + it('combination', () => { + expect(timeDeltaToSeconds({ h: 1, m: 2, s: 3, ms: 4 })).toBe(3723.004); + }); +}); diff --git a/tests/card-controller/automations-manager.test.ts b/tests/card-controller/automations-manager.test.ts index b1e1e4ac..a60fa696 100644 --- a/tests/card-controller/automations-manager.test.ts +++ b/tests/card-controller/automations-manager.test.ts @@ -1,18 +1,25 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { AutomationsManager } from '../../src/card-controller/automations-manager.js'; -import { frigateCardHandleAction } from '../../src/utils/action.js'; -import { createCardAPI, createConfig, createHASS } from '../test-utils.js'; - -vi.mock('../../src/utils/action.js'); +import { createCardAPI, createHASS } from '../test-utils.js'; +import { ActionType } from '../../src/config/types.js'; +import { AuxillaryActionConfig } from '../../src/card-controller/actions/types.js'; describe('AutomationsManager', () => { const actions = [ { - action: 'custom:frigate-card-action', + action: 'fire-dom-event' as const, frigate_card_action: 'clips', }, ]; - const conditions = [{ condition: 'fullscreen', fullscreen: true }]; + const conditions = [{ condition: 'fullscreen' as const, fullscreen: true }]; + const automation = { + conditions: conditions, + actions: actions, + }; + const not_automation = { + conditions: conditions, + actions_not: actions, + }; afterEach(() => { vi.clearAllMocks(); @@ -23,7 +30,8 @@ describe('AutomationsManager', () => { const automationsManager = new AutomationsManager(api); automationsManager.execute(); - expect(frigateCardHandleAction).not.toBeCalled(); + + expect(api.getActionsManager().executeActions).not.toBeCalled(); }); it('should do nothing without automations', () => { @@ -31,105 +39,79 @@ describe('AutomationsManager', () => { vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const automationsManager = new AutomationsManager(api); - automationsManager.setAutomationsFromConfig(); automationsManager.execute(); - expect(frigateCardHandleAction).not.toBeCalled(); + + expect(api.getActionsManager().executeActions).not.toBeCalled(); }); it('should execute actions', () => { - const config = createConfig({ - automations: [ - { - conditions: conditions, - actions: actions, - }, - ], - }); - const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); - vi.mocked(api.getConfigManager().getNonOverriddenConfig).mockReturnValue(config); const automationsManager = new AutomationsManager(api); - automationsManager.setAutomationsFromConfig(); + automationsManager.addAutomations([automation]); automationsManager.execute(); - expect(frigateCardHandleAction).not.toBeCalled(); + expect(api.getActionsManager().executeActions).not.toBeCalled(); vi.mocked(api.getConditionsManager().evaluateConditions).mockReturnValue(true); automationsManager.execute(); - expect(frigateCardHandleAction).toBeCalledTimes(1); + expect(api.getActionsManager().executeActions).toBeCalledTimes(1); // Automation will not re-fire when condition continues to evaluate the // same. automationsManager.execute(); - expect(frigateCardHandleAction).toBeCalledTimes(1); + expect(api.getActionsManager().executeActions).toBeCalledTimes(1); vi.mocked(api.getConditionsManager().evaluateConditions).mockReturnValue(false); automationsManager.execute(); - expect(frigateCardHandleAction).toBeCalledTimes(1); + expect(api.getActionsManager().executeActions).toBeCalledTimes(1); vi.mocked(api.getConditionsManager().evaluateConditions).mockReturnValue(true); automationsManager.execute(); - expect(frigateCardHandleAction).toBeCalledTimes(2); + expect(api.getActionsManager().executeActions).toBeCalledTimes(2); }); it('should execute actions_not', () => { - const config = createConfig({ - automations: [ - { - conditions: conditions, - actions_not: actions, - }, - ], - }); - const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); - vi.mocked(api.getConfigManager().getNonOverriddenConfig).mockReturnValue(config); vi.mocked(api.getConditionsManager().evaluateConditions).mockReturnValue(false); const automationsManager = new AutomationsManager(api); - automationsManager.setAutomationsFromConfig(); + automationsManager.addAutomations([not_automation]); automationsManager.execute(); - expect(frigateCardHandleAction).toBeCalled(); + expect(api.getActionsManager().executeActions).toBeCalled(); }); it('should prevent automation loops', () => { - const config = createConfig({ - automations: [ - { - conditions: [{ condition: 'fullscreen' as const, fullscreen: true }], - actions: actions, - }, - { - conditions: [{ condition: 'fullscreen' as const, fullscreen: true }], - actions_not: actions, - }, - ], - }); - const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); - vi.mocked(api.getConfigManager().getNonOverriddenConfig).mockReturnValue(config); const automationsManager = new AutomationsManager(api); - automationsManager.setAutomationsFromConfig(); + automationsManager.addAutomations([automation, not_automation]); // Create a setup where one automation action causes another... let evaluation = true; - vi.mocked(frigateCardHandleAction).mockImplementation(() => { - evaluation = !evaluation; - vi.mocked(api.getConditionsManager().evaluateConditions).mockReturnValue( - evaluation, - ); - automationsManager.execute(); - }); + + vi.mocked(api.getActionsManager().executeActions).mockImplementation( + async ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _action: ActionType | ActionType[], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _config?: AuxillaryActionConfig, + ): Promise => { + evaluation = !evaluation; + vi.mocked(api.getConditionsManager().evaluateConditions).mockReturnValue( + evaluation, + ); + automationsManager.execute(); + }, + ); vi.mocked(api.getConditionsManager().evaluateConditions).mockReturnValue(evaluation); @@ -143,6 +125,24 @@ describe('AutomationsManager', () => { }), ); - expect(frigateCardHandleAction).toBeCalledTimes(10); + expect(api.getActionsManager().executeActions).toBeCalledTimes(10); + }); + + it('should delete automations', () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + + const automationsManager = new AutomationsManager(api); + automationsManager.addAutomations([automation]); + + vi.mocked(api.getConditionsManager().evaluateConditions).mockReturnValue(true); + + automationsManager.execute(); + expect(api.getActionsManager().executeActions).toBeCalledTimes(1); + + automationsManager.deleteAutomations(); + + automationsManager.execute(); + expect(api.getActionsManager().executeActions).toBeCalledTimes(1); }); }); diff --git a/tests/card-controller/card-element-manager.test.ts b/tests/card-controller/card-element-manager.test.ts index 664c1faa..12fced9c 100644 --- a/tests/card-controller/card-element-manager.test.ts +++ b/tests/card-controller/card-element-manager.test.ts @@ -119,7 +119,7 @@ describe('CardElementManager', () => { ); expect(addEventListener).toBeCalledWith( 'll-custom', - api.getActionsManager().handleActionEvent, + api.getActionsManager().handleCustomActionEvent, ); expect(addEventListener).toBeCalledWith( 'action', @@ -169,7 +169,7 @@ describe('CardElementManager', () => { ); expect(removeEventListener).toBeCalledWith( 'll-custom', - api.getActionsManager().handleActionEvent, + api.getActionsManager().handleCustomActionEvent, ); expect(removeEventListener).toBeCalledWith( 'action', @@ -185,6 +185,10 @@ describe('CardElementManager', () => { ); expect(windowRemoveEventListener).toBeCalledWith('popstate', expect.anything()); + expect(api.getMediaLoadedInfoManager().clear).toBeCalled(); + expect(api.getFullscreenManager().disconnect).toBeCalled(); + expect(api.getKeyboardStateManager().uninitialize).toBeCalled(); + expect(api.getActionsManager().uninitialize).toBeCalled(); expect(api.getInitializationManager().uninitialize).toBeCalledWith('cameras'); }); }); diff --git a/tests/card-controller/conditions-manager.test.ts b/tests/card-controller/conditions-manager.test.ts index 5c02be57..e0327403 100644 --- a/tests/card-controller/conditions-manager.test.ts +++ b/tests/card-controller/conditions-manager.test.ts @@ -930,5 +930,60 @@ describe('ConditionsManager', () => { expect(manager.evaluateConditions(conditions)).toBeTruthy(); }); }); + + describe('with key condition', () => { + it('simple keypress', () => { + const manager = new ConditionsManager(createCardAPI()); + const conditions = [{ condition: 'key' as const, key: 'a' }]; + expect(manager.evaluateConditions(conditions)).toBeFalsy(); + manager.setState({ + keys: { + a: { state: 'down', ctrl: false, shift: false, alt: false, meta: false }, + }, + }); + expect(manager.evaluateConditions(conditions)).toBeTruthy(); + manager.setState({ + keys: { + a: { state: 'up', ctrl: false, shift: false, alt: false, meta: false }, + }, + }); + + expect(manager.evaluateConditions(conditions)).toBeFalsy(); + }); + + it('keypress with modifiers', () => { + const manager = new ConditionsManager(createCardAPI()); + const conditions = [ + { + condition: 'key' as const, + key: 'a', + state: 'down' as const, + ctrl: true, + shift: true, + alt: true, + meta: true, + }, + ]; + expect(manager.evaluateConditions(conditions)).toBeFalsy(); + manager.setState({ + keys: { + a: { state: 'down', ctrl: false, shift: false, alt: false, meta: false }, + }, + }); + expect(manager.evaluateConditions(conditions)).toBeFalsy(); + manager.setState({ + keys: { + a: { state: 'down', ctrl: true, shift: true, alt: true, meta: false }, + }, + }); + expect(manager.evaluateConditions(conditions)).toBeFalsy(); + manager.setState({ + keys: { + a: { state: 'down', ctrl: true, shift: true, alt: true, meta: true }, + }, + }); + expect(manager.evaluateConditions(conditions)).toBeTruthy(); + }); + }); }); }); diff --git a/tests/card-controller/config-manager.test.ts b/tests/card-controller/config/config-manager.test.ts similarity index 94% rename from tests/card-controller/config-manager.test.ts rename to tests/card-controller/config/config-manager.test.ts index 7bbb2f2b..de06f683 100644 --- a/tests/card-controller/config-manager.test.ts +++ b/tests/card-controller/config/config-manager.test.ts @@ -1,12 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ZodError } from 'zod'; -import { frigateCardConfigSchema } from '../../src/config/types'; -import { getOverriddenConfig } from '../../src/card-controller/conditions-manager'; -import { ConfigManager } from '../../src/card-controller/config-manager'; -import { InitializationAspect } from '../../src/card-controller/initialization-manager'; -import { createCardAPI, createConfig } from '../test-utils'; +import { frigateCardConfigSchema } from '../../../src/config/types'; +import { getOverriddenConfig } from '../../../src/card-controller/conditions-manager'; +import { ConfigManager } from '../../../src/card-controller/config/config-manager'; +import { InitializationAspect } from '../../../src/card-controller/initialization-manager'; +import { createCardAPI, createConfig } from '../../test-utils'; -vi.mock('../../src/card-controller/conditions-manager.js'); +vi.mock('../../../src/card-controller/conditions-manager.js'); describe('ConfigManager', () => { beforeEach(() => { @@ -97,7 +97,7 @@ describe('ConfigManager', () => { expect(api.getMediaLoadedInfoManager().clear).toBeCalled(); expect(api.getViewManager().reset).toBeCalled(); expect(api.getMessageManager().reset).toBeCalled(); - expect(api.getAutomationsManager().setAutomationsFromConfig).toBeCalled(); + expect(api.getAutomationsManager().addAutomations).toBeCalled(); expect(api.getStyleManager().setPerformance).toBeCalled(); expect(api.getCardElementManager().update).toBeCalled(); }); diff --git a/tests/card-controller/config/load-automations.test.ts b/tests/card-controller/config/load-automations.test.ts new file mode 100644 index 00000000..e246499d --- /dev/null +++ b/tests/card-controller/config/load-automations.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createCardAPI, createConfig } from '../../test-utils'; +import { setAutomationsFromConfig } from '../../../src/card-controller/config/load-automations'; + +describe('setAutomationsFromConfig', () => { + it('without config', () => { + const api = createCardAPI(); + setAutomationsFromConfig(api); + + expect(api.getAutomationsManager().deleteAutomations).toBeCalled(); + expect(api.getAutomationsManager().addAutomations).toBeCalledWith([]); + }); + + it('with config', () => { + const automations = [ + { + actions: [ + { + action: 'fire-dom-event' as const, + frigate_card_action: 'clips', + }, + ], + conditions: [{ condition: 'fullscreen' as const, fullscreen: true }], + }, + ]; + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getNonOverriddenConfig).mockReturnValue( + createConfig({ + automations: automations, + }), + ); + + setAutomationsFromConfig(api); + + expect(api.getAutomationsManager().deleteAutomations).toBeCalled(); + expect(api.getAutomationsManager().addAutomations).toBeCalledWith(automations); + }); +}); diff --git a/tests/card-controller/config/load-keyboard-shortcuts.test.ts b/tests/card-controller/config/load-keyboard-shortcuts.test.ts new file mode 100644 index 00000000..282fd433 --- /dev/null +++ b/tests/card-controller/config/load-keyboard-shortcuts.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createCardAPI, createConfig } from '../../test-utils'; +import { setKeyboardShortcutsFromConfig } from '../../../src/card-controller/config/load-keyboard-shortcuts'; +import { PTZKeyboardShortcutName } from '../../../src/config/keyboard-shortcuts'; +import { PTZAction } from '../../../src/config/ptz'; + +describe('setKeyboardShortcutsFromConfig', () => { + it('without shortcuts', () => { + const api = createCardAPI(); + setKeyboardShortcutsFromConfig(api, 'tag'); + + expect(api.getAutomationsManager().deleteAutomations).toBeCalledWith('tag'); + expect(api.getAutomationsManager().addAutomations).not.toBeCalled(); + }); + + it('with shortcuts disabled', () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + keyboard_shortcuts: { + enabled: false, + }, + }, + }), + ); + setKeyboardShortcutsFromConfig(api, 'tag'); + + expect(api.getAutomationsManager().deleteAutomations).toBeCalledWith('tag'); + expect(api.getAutomationsManager().addAutomations).not.toBeCalled(); + }); + + describe('PTZ shortcuts', () => { + describe('actions', () => { + it.each([ + ['ptz_left' as const, 'left' as const], + ['ptz_right' as const, 'right' as const], + ['ptz_up' as const, 'up' as const], + ['ptz_down' as const, 'down' as const], + ['ptz_zoom_in' as const, 'zoom_in' as const], + ['ptz_zoom_out' as const, 'zoom_out' as const], + ])('%s', (name: PTZKeyboardShortcutName, ptzAction: PTZAction) => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + keyboard_shortcuts: { + enabled: true, + ptz_home: null, + ptz_left: null, + ptz_right: null, + ptz_up: null, + ptz_down: null, + ptz_zoom_in: null, + ptz_zoom_out: null, + [name]: { key: 'z' }, + }, + }, + }), + ); + + setKeyboardShortcutsFromConfig(api, 'tag'); + + expect(api.getAutomationsManager().deleteAutomations).toBeCalledWith('tag'); + expect(api.getAutomationsManager().addAutomations).toBeCalledWith([ + { + actions: [ + { + action: 'fire-dom-event', + frigate_card_action: 'ptz_multi', + ptz_action: ptzAction, + ptz_phase: 'start', + }, + ], + conditions: [ + { + alt: undefined, + condition: 'key', + ctrl: undefined, + key: 'z', + meta: undefined, + shift: undefined, + state: 'down', + }, + ], + tag: 'tag', + }, + { + actions: [ + { + action: 'fire-dom-event', + frigate_card_action: 'ptz_multi', + ptz_action: ptzAction, + ptz_phase: 'stop', + }, + ], + conditions: [ + { + condition: 'key', + key: 'z', + state: 'up', + }, + ], + tag: 'tag', + }, + ]); + }); + + it('ptz_home', () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + + setKeyboardShortcutsFromConfig(api, 'tag'); + + expect(api.getAutomationsManager().deleteAutomations).toBeCalledWith('tag'); + expect(api.getAutomationsManager().addAutomations).toBeCalledWith( + expect.arrayContaining([ + { + actions: [ + { + action: 'fire-dom-event', + frigate_card_action: 'ptz_multi', + }, + ], + conditions: [ + { + alt: undefined, + condition: 'key', + ctrl: undefined, + key: 'h', + meta: undefined, + shift: undefined, + state: 'down', + }, + ], + tag: 'tag', + }, + ]), + ); + }); + }); + }); +}); diff --git a/tests/card-controller/controller.test.ts b/tests/card-controller/controller.test.ts index b8e6b87b..ca4f6d7b 100644 --- a/tests/card-controller/controller.test.ts +++ b/tests/card-controller/controller.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CameraManager } from '../../src/camera-manager/manager'; -import { FrigateCardEditor } from '../../src/editor'; -import { ActionsManager } from '../../src/card-controller/actions-manager'; +import { ActionsManager } from '../../src/card-controller/actions/actions-manager'; import { AutoUpdateManager } from '../../src/card-controller/auto-update-manager'; import { AutomationsManager } from '../../src/card-controller/automations-manager'; import { CameraURLManager } from '../../src/card-controller/camera-url-manager'; @@ -10,7 +9,7 @@ import { CardHTMLElement, } from '../../src/card-controller/card-element-manager'; import { ConditionsManager } from '../../src/card-controller/conditions-manager'; -import { ConfigManager } from '../../src/card-controller/config-manager'; +import { ConfigManager } from '../../src/card-controller/config/config-manager'; import { CardController } from '../../src/card-controller/controller'; import { DownloadManager } from '../../src/card-controller/download-manager'; import { ExpandManager } from '../../src/card-controller/expand-manager'; @@ -18,6 +17,7 @@ import { FullscreenManager } from '../../src/card-controller/fullscreen-manager' import { HASSManager } from '../../src/card-controller/hass-manager'; import { InitializationManager } from '../../src/card-controller/initialization-manager'; import { InteractionManager } from '../../src/card-controller/interaction-manager'; +import { KeyboardStateManager } from '../../src/card-controller/keyboard-state-manager'; import { MediaLoadedInfoManager } from '../../src/card-controller/media-info-manager'; import { MediaPlayerManager } from '../../src/card-controller/media-player-manager'; import { MessageManager } from '../../src/card-controller/message-manager'; @@ -26,23 +26,25 @@ import { QueryStringManager } from '../../src/card-controller/query-string-manag import { StyleManager } from '../../src/card-controller/style-manager'; import { TriggersManager } from '../../src/card-controller/triggers-manager'; import { ViewManager } from '../../src/card-controller/view-manager'; +import { FrigateCardEditor } from '../../src/editor'; import { EntityRegistryManager } from '../../src/utils/ha/entity-registry'; import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media'; vi.mock('../../src/camera-manager/manager'); -vi.mock('../../src/card-controller/actions-manager'); +vi.mock('../../src/card-controller/actions/actions-manager'); vi.mock('../../src/card-controller/auto-update-manager'); vi.mock('../../src/card-controller/automations-manager'); vi.mock('../../src/card-controller/camera-url-manager'); vi.mock('../../src/card-controller/card-element-manager'); vi.mock('../../src/card-controller/conditions-manager'); -vi.mock('../../src/card-controller/config-manager'); +vi.mock('../../src/card-controller/config/config-manager'); vi.mock('../../src/card-controller/download-manager'); vi.mock('../../src/card-controller/expand-manager'); vi.mock('../../src/card-controller/fullscreen-manager'); vi.mock('../../src/card-controller/hass-manager'); vi.mock('../../src/card-controller/initialization-manager'); vi.mock('../../src/card-controller/interaction-manager'); +vi.mock('../../src/card-controller/keyboard-state-manager'); vi.mock('../../src/card-controller/media-info-manager'); vi.mock('../../src/card-controller/media-player-manager'); vi.mock('../../src/card-controller/message-manager'); @@ -187,6 +189,12 @@ describe('CardController', () => { ); }); + it('getKeyboardStateManager', () => { + expect(createController().getKeyboardStateManager()).toBe( + vi.mocked(KeyboardStateManager).mock.instances[0], + ); + }); + it('getMediaLoadedInfoManager', () => { expect(createController().getMediaLoadedInfoManager()).toBe( vi.mocked(MediaLoadedInfoManager).mock.instances[0], diff --git a/tests/card-controller/keyboard-state-manager.test.ts b/tests/card-controller/keyboard-state-manager.test.ts new file mode 100644 index 00000000..36f8e1ff --- /dev/null +++ b/tests/card-controller/keyboard-state-manager.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest'; +import { KeyboardStateManager } from '../../src/card-controller/keyboard-state-manager'; +import { createCardAPI, createLitElement } from '../test-utils'; + +// @vitest-environment jsdom +describe('KeyboardStateManager', () => { + it('should construct', () => { + expect(new KeyboardStateManager(createCardAPI())).toBeTruthy(); + }); + + it('should set state on keydown', () => { + const api = createCardAPI(); + const element = createLitElement(); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + const manager = new KeyboardStateManager(api); + manager.initialize(); + + element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); + + expect(api.getConditionsManager().setState).toHaveBeenCalledWith({ + keys: { + a: { state: 'down', ctrl: false, alt: false, meta: false, shift: false }, + }, + }); + + element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); + + // Duplicate keydown should not re-set the state. + expect(api.getConditionsManager().setState).toBeCalledTimes(1); + }); + + it('should set state on keyup', () => { + const api = createCardAPI(); + const element = createLitElement(); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + const manager = new KeyboardStateManager(api); + manager.initialize(); + + element.dispatchEvent(new KeyboardEvent('keyup', { key: 'a' })); + + // Key not held down in the first place should not update the state. + expect(api.getConditionsManager().setState).not.toBeCalled(); + + element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); + element.dispatchEvent(new KeyboardEvent('keyup', { key: 'a' })); + + expect(api.getConditionsManager().setState).toBeCalledTimes(2); + expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith({ + keys: { + a: { state: 'up', ctrl: false, alt: false, meta: false, shift: false }, + }, + }); + }); + + it('should set state on focus loss', () => { + const api = createCardAPI(); + const element = createLitElement(); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + const manager = new KeyboardStateManager(api); + manager.initialize(); + + element.dispatchEvent(new FocusEvent('blur')); + expect(api.getConditionsManager().setState).not.toBeCalled(); + + element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); + element.dispatchEvent(new FocusEvent('blur')); + + expect(api.getConditionsManager().setState).toBeCalledTimes(2); + expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith({ + keys: {}, + }); + }); + + it('should not act after uninitialization', () => { + const api = createCardAPI(); + const element = createLitElement(); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + const manager = new KeyboardStateManager(api); + manager.initialize(); + manager.uninitialize(); + + element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); + + expect(api.getConditionsManager().setState).not.toBeCalled(); + }); +}); diff --git a/tests/card-controller/query-string-manager.test.ts b/tests/card-controller/query-string-manager.test.ts index eb9ee80c..7d1c6427 100644 --- a/tests/card-controller/query-string-manager.test.ts +++ b/tests/card-controller/query-string-manager.test.ts @@ -1,10 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { QueryStringManager } from '../../src/card-controller/query-string-manager'; -import { - FrigateCardGeneralAction, - FrigateCardUserSpecifiedView, -} from '../../src/config/types'; import { createCardAPI } from '../test-utils'; const setQueryString = (qs: string): void => { @@ -28,7 +24,7 @@ describe('QueryStringManager', () => { manager.executeAll(); expect(manager.hasViewRelatedActions()).toBeFalsy(); - expect(api.getActionsManager().executeFrigateAction).not.toBeCalled(); + expect(api.getActionsManager().executeActions).not.toBeCalled(); expect(api.getViewManager().setViewByParameters).not.toBeCalled(); }); @@ -76,11 +72,13 @@ describe('QueryStringManager', () => { manager.executeAll(); expect(manager.hasViewRelatedActions()).toBeFalsy(); - expect(api.getActionsManager().executeFrigateAction).toBeCalledWith({ - action: 'fire-dom-event', - card_id: 'id', - frigate_card_action: action, - }); + expect(api.getActionsManager().executeActions).toBeCalledWith([ + { + action: 'fire-dom-event', + card_id: 'id', + frigate_card_action: action, + }, + ]); }); }); @@ -97,7 +95,7 @@ describe('QueryStringManager', () => { expect(api.getViewManager().setViewDefault).toBeCalled(); expect(manager.hasViewRelatedActions()).toBeTruthy(); - expect(api.getActionsManager().executeFrigateAction).not.toBeCalled(); + expect(api.getActionsManager().executeActions).not.toBeCalled(); expect(api.getViewManager().setViewByParameters).not.toBeCalled(); }); @@ -114,7 +112,7 @@ describe('QueryStringManager', () => { }); expect(manager.hasViewRelatedActions()).toBeTruthy(); - expect(api.getActionsManager().executeFrigateAction).not.toBeCalled(); + expect(api.getActionsManager().executeActions).not.toBeCalled(); expect(api.getViewManager().setViewDefault).not.toBeCalled(); }); @@ -131,7 +129,7 @@ describe('QueryStringManager', () => { }); expect(manager.hasViewRelatedActions()).toBeTruthy(); - expect(api.getActionsManager().executeFrigateAction).not.toBeCalled(); + expect(api.getActionsManager().executeActions).not.toBeCalled(); expect(api.getViewManager().setViewDefault).not.toBeCalled(); }); @@ -147,7 +145,7 @@ describe('QueryStringManager', () => { manager.executeAll(); expect(manager.hasViewRelatedActions()).toBeFalsy(); - expect(api.getActionsManager().executeFrigateAction).not.toBeCalled(); + expect(api.getActionsManager().executeActions).not.toBeCalled(); expect(api.getViewManager().setViewDefault).not.toBeCalled(); expect(api.getViewManager().setViewByParameters).not.toBeCalled(); }, @@ -165,7 +163,7 @@ describe('QueryStringManager', () => { manager.executeAll(); expect(manager.hasViewRelatedActions()).toBeFalsy(); - expect(api.getActionsManager().executeFrigateAction).not.toBeCalled(); + expect(api.getActionsManager().executeActions).not.toBeCalled(); expect(api.getViewManager().setViewDefault).not.toBeCalled(); expect(api.getViewManager().setViewByParameters).not.toBeCalled(); expect(consoleSpy).toBeCalled(); @@ -212,7 +210,7 @@ describe('QueryStringManager', () => { manager.executeAll(); - expect(api.getActionsManager().executeFrigateAction).not.toBeCalled(); + expect(api.getActionsManager().executeActions).not.toBeCalled(); expect(api.getViewManager().setViewDefault).not.toBeCalled(); expect(api.getViewManager().setViewByParameters).not.toBeCalled(); }); @@ -296,7 +294,7 @@ describe('QueryStringManager', () => { manager.executeViewRelated(); - expect(api.getActionsManager().executeFrigateAction).not.toBeCalled(); + expect(api.getActionsManager().executeActions).not.toBeCalled(); }); }); }); diff --git a/tests/components-lib/key-assigner-controller.test.ts b/tests/components-lib/key-assigner-controller.test.ts new file mode 100644 index 00000000..9e1c1530 --- /dev/null +++ b/tests/components-lib/key-assigner-controller.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from 'vitest'; +import { KeyAssignerController } from '../../src/components-lib/key-assigner-controller'; +import { createLitElement } from '../test-utils'; + +// @vitest-environment jsdom +describe('KeyAssignerController', () => { + it('should be creatable', () => { + const controller = new KeyAssignerController(createLitElement()); + expect(controller).toBeTruthy(); + }); + + describe('should manage value', () => { + it('should have no value to start', () => { + const controller = new KeyAssignerController(createLitElement()); + expect(controller.hasValue()).toBeFalsy(); + }); + + it('should set value', () => { + const element = createLitElement(); + const valueChangeHandler = vi.fn(); + element.addEventListener('value-changed', valueChangeHandler); + const controller = new KeyAssignerController(element); + + controller.setValue({ key: 'ArrowLeft' }); + + expect(controller.hasValue()).toBeTruthy(); + expect(controller.getValue()).toEqual({ key: 'ArrowLeft' }); + expect(element.requestUpdate).toBeCalled(); + expect(valueChangeHandler).toBeCalledWith( + expect.objectContaining({ + detail: { value: { key: 'ArrowLeft' } }, + }), + ); + + // Set again with the same value. + controller.setValue({ key: 'ArrowLeft' }); + expect(element.requestUpdate).toBeCalledTimes(1); + expect(valueChangeHandler).toBeCalledTimes(1); + }); + }); + + describe('should manage assignment state', () => { + it('should not be assigned to start', () => { + const element = createLitElement(); + const controller = new KeyAssignerController(element); + expect(controller.isAssigning()).toBeFalsy(); + expect(element.getAttribute('assigning')).toBeNull(); + }); + + it('should toggle assigning', () => { + const element = createLitElement(); + const controller = new KeyAssignerController(element); + controller.toggleAssigning(); + expect(element.requestUpdate).toBeCalled(); + expect(controller.isAssigning()).toBeTruthy(); + expect(element.getAttribute('assigning')).toBe(''); + + expect(controller.hasValue()).toBeFalsy(); + element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); + + expect(controller.hasValue()).toBeTruthy(); + expect(controller.isAssigning()).toBeFalsy(); + + controller.setValue(null); + + // A key sent when not assigning will do nothing. + element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); + expect(controller.hasValue()).toBeFalsy(); + }); + + it('should not assign when focus lost', () => { + const element = createLitElement(); + const controller = new KeyAssignerController(element); + + controller.hostConnected(); + + controller.toggleAssigning(); + expect(controller.isAssigning()).toBeTruthy(); + + element.dispatchEvent(new FocusEvent('blur')); + expect(controller.isAssigning()).toBeFalsy(); + + controller.hostDisconnected(); + }); + }); + + describe('should manage key down', () => { + it('should reject modifiers only', () => { + const element = createLitElement(); + const controller = new KeyAssignerController(element); + controller.toggleAssigning(); + + element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Control' })); + expect(controller.hasValue()).toBeFalsy(); + }); + + it('should reject empty key', () => { + const element = createLitElement(); + const controller = new KeyAssignerController(element); + controller.toggleAssigning(); + + element.dispatchEvent(new KeyboardEvent('keydown', { key: '' })); + expect(controller.hasValue()).toBeFalsy(); + }); + + it('should accept valid key', () => { + const element = createLitElement(); + const controller = new KeyAssignerController(element); + controller.toggleAssigning(); + + element.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowLeft', + ctrlKey: true, + shiftKey: true, + altKey: true, + metaKey: true, + }), + ); + expect(controller.hasValue()).toBeTruthy(); + expect(controller.getValue()).toEqual({ + key: 'ArrowLeft', + ctrl: true, + shift: true, + alt: true, + meta: true, + }); + expect(controller.isAssigning()).toBeFalsy(); + }); + }); +}); diff --git a/tests/components-lib/menu-button-controller.test.ts b/tests/components-lib/menu-button-controller.test.ts index f7bdc4e7..ed9821b7 100644 --- a/tests/components-lib/menu-button-controller.test.ts +++ b/tests/components-lib/menu-button-controller.test.ts @@ -18,7 +18,7 @@ import { ViewDisplayMode, } from '../../src/config/types'; import { FrigateCardMediaPlayer } from '../../src/types'; -import { createFrigateCardSimpleAction } from '../../src/utils/action'; +import { createGeneralAction } from '../../src/utils/action'; import { ViewMedia } from '../../src/view/media'; import { MediaQueriesResults } from '../../src/view/media-queries-results'; import { View } from '../../src/view/view'; @@ -36,6 +36,7 @@ import { createView, TestViewMedia, } from '../test-utils'; +import { Capabilities } from '../../src/camera-manager/capabilities'; vi.mock('../../src/utils/media-player-controller.js'); vi.mock('../../src/card-controller/microphone-manager.js'); @@ -88,8 +89,8 @@ describe('MenuButtonController', () => { priority: 50, type: 'custom:frigate-card-menu-icon', title: 'Frigate menu / Default view', - tap_action: createFrigateCardSimpleAction('menu_toggle'), - hold_action: createFrigateCardSimpleAction('diagnostics'), + tap_action: createGeneralAction('menu_toggle'), + hold_action: createGeneralAction('diagnostics'), }); }); @@ -104,8 +105,8 @@ describe('MenuButtonController', () => { priority: 50, type: 'custom:frigate-card-menu-icon', title: 'Frigate menu / Default view', - tap_action: createFrigateCardSimpleAction('default'), - hold_action: createFrigateCardSimpleAction('diagnostics'), + tap_action: createGeneralAction('default'), + hold_action: createGeneralAction('diagnostics'), }); }); }); @@ -1355,13 +1356,14 @@ describe('MenuButtonController', () => { describe('should have show ptz button', () => { it('when the selected camera is not PTZ enabled', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue( - createCapabilities(), - ); + const store = createStore([ + { + cameraID: 'camera-1', + }, + ]); const buttons = calculateButtons(controller, { - cameraManager: cameraManager, + cameraManager: createCameraManager(store), view: createView({ view: 'live' }), }); @@ -1373,13 +1375,15 @@ describe('MenuButtonController', () => { }); it('when not in live view', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue( - createCapabilities({ ptz: { panTilt: ['relative'] } }), - ); + const store = createStore([ + { + cameraID: 'camera-1', + capabilities: new Capabilities({ ptz: { left: ['relative'] } }), + }, + ]); const buttons = calculateButtons(controller, { - cameraManager: cameraManager, + cameraManager: createCameraManager(store), view: createView({ view: 'clips' }), }); @@ -1391,11 +1395,16 @@ describe('MenuButtonController', () => { }); it('when the selected camera is PTZ enabled', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue( - createCapabilities({ ptz: { panTilt: ['relative'] } }), - ); - const buttons = calculateButtons(controller, { cameraManager: cameraManager }); + const store = createStore([ + { + cameraID: 'camera-1', + capabilities: new Capabilities({ ptz: { left: ['relative'] } }), + }, + ]); + + const buttons = calculateButtons(controller, { + cameraManager: createCameraManager(store), + }); expect(buttons).toContainEqual({ enabled: false, @@ -1406,25 +1415,28 @@ describe('MenuButtonController', () => { }, tap_action: { action: 'fire-dom-event', - frigate_card_action: 'show_ptz', - show_ptz: false, + frigate_card_action: 'ptz_controls', + enabled: false, }, title: 'Show PTZ controls', type: 'custom:frigate-card-menu-icon', }); }); - it('when the context has PTZ visiblity turned off', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue( - createCapabilities({ ptz: { panTilt: ['relative'] } }), - ); + it('when the context has PTZ disabled', () => { + const store = createStore([ + { + cameraID: 'camera-1', + capabilities: new Capabilities({ ptz: { left: ['relative'] } }), + }, + ]); + const view = createView({ camera: 'camera-1', - context: { live: { ptzVisible: false } }, + context: { ptzControls: { enabled: false } }, }); const buttons = calculateButtons(controller, { - cameraManager: cameraManager, + cameraManager: createCameraManager(store), view: view, }); @@ -1435,8 +1447,8 @@ describe('MenuButtonController', () => { style: {}, tap_action: { action: 'fire-dom-event', - frigate_card_action: 'show_ptz', - show_ptz: true, + frigate_card_action: 'ptz_controls', + enabled: true, }, title: 'Show PTZ controls', type: 'custom:frigate-card-menu-icon', @@ -1444,24 +1456,16 @@ describe('MenuButtonController', () => { }); it('when a substream is PTZ enabled', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera-1', - config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }), - }, - { cameraID: 'camera-2' }, - ]), - ); - vi.mocked(cameraManager.getCameraCapabilities).mockImplementation( - (cameraID: string) => { - if (cameraID === 'camera-2') { - return createCapabilities({ ptz: { panTilt: ['relative'] } }); - } - return createCapabilities(); + const store = createStore([ + { + cameraID: 'camera-1', + config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }), }, - ); + { + cameraID: 'camera-2', + capabilities: new Capabilities({ ptz: { left: ['relative'] } }), + }, + ]); const view = createView({ camera: 'camera-1', context: { @@ -1471,7 +1475,7 @@ describe('MenuButtonController', () => { }, }); const buttons = calculateButtons(controller, { - cameraManager: cameraManager, + cameraManager: createCameraManager(store), view: view, }); @@ -1484,8 +1488,8 @@ describe('MenuButtonController', () => { }, tap_action: { action: 'fire-dom-event', - frigate_card_action: 'show_ptz', - show_ptz: false, + frigate_card_action: 'ptz_controls', + enabled: false, }, title: 'Show PTZ controls', type: 'custom:frigate-card-menu-icon', @@ -1493,7 +1497,7 @@ describe('MenuButtonController', () => { }); }); - describe('should have change zoom button', () => { + describe('should have ptz home button', () => { it.each([ ['live' as const, true, false], ['live' as const, undefined, false], @@ -1521,13 +1525,23 @@ describe('MenuButtonController', () => { results: [new TestViewMedia({ id: 'media-1' })], selectedIndex: 0, }), - context: { - zoom: { - [targetID]: { - isDefault: isDefault, + ...(isDefault !== undefined && { + context: { + zoom: { + [targetID]: { + observed: { + isDefault: isDefault, + unzoomed: false, + zoom: 1, + pan: { + x: 50, + y: 50, + }, + }, + }, }, }, - }, + }), }); const buttons = calculateButtons(controller, { cameraManager: cameraManager, @@ -1537,19 +1551,19 @@ describe('MenuButtonController', () => { if (expectedResult) { expect(buttons).toContainEqual({ enabled: true, - icon: 'mdi:magnify-close', + icon: 'mdi:home', priority: 50, tap_action: { action: 'fire-dom-event', - frigate_card_action: 'change_zoom', + frigate_card_action: 'ptz_multi', target_id: targetID, }, - title: 'Zoom to default', + title: 'PTZ Home', type: 'custom:frigate-card-menu-icon', }); } else { expect(buttons).not.toContainEqual( - expect.objectContaining({ title: 'Set zoom to default' }), + expect.objectContaining({ title: 'Digital zoom to default' }), ); } }, diff --git a/tests/components-lib/menu-controller.test.ts b/tests/components-lib/menu-controller.test.ts index 1467ea47..58d26cde 100644 --- a/tests/components-lib/menu-controller.test.ts +++ b/tests/components-lib/menu-controller.test.ts @@ -362,53 +362,60 @@ describe('MenuController', () => { describe('should handle actions', () => { it('should bail without config', () => { const controller = new MenuController(createLitElement()); - controller.actionHandler(createHASS(), createEvent('tap')); + controller.actionHandler(createEvent('tap')); expect(vi.mocked(handleActionConfig)).not.toBeCalled(); }); it('should execute simple action in non-hidden menu', () => { const host = createLitElement(); - const hass = createHASS(); + const handler = vi.fn(); + host.addEventListener('frigate-card:action:execution-request', handler); + const controller = new MenuController(host); - controller.actionHandler(hass, createEvent('tap'), tapActionConfig); - expect(vi.mocked(handleActionConfig)).toBeCalledWith( - host, - hass, - tapActionConfig, - action, + controller.actionHandler(createEvent('tap'), tapActionConfig); + expect(handler).toBeCalledWith( + expect.objectContaining({ + detail: { action: [action], config: tapActionConfig }, + }), ); - expect(controller.isExpanded()).toBeFalsy(); }); it('should execute simple action in with config in event', () => { const host = createLitElement(); - const hass = createHASS(); + const handler = vi.fn(); + host.addEventListener('frigate-card:action:execution-request', handler); + const controller = new MenuController(host); - controller.actionHandler(hass, createEvent('tap', tapActionConfig)); - expect(vi.mocked(handleActionConfig)).toBeCalledWith( - host, - hass, - tapActionConfig, - action, + controller.actionHandler(createEvent('tap', tapActionConfig)); + expect(handler).toBeCalledWith( + expect.objectContaining({ + detail: { action: [action], config: tapActionConfig }, + }), ); }); it('should execute simple array of actions in non-hidden menu', () => { const host = createLitElement(); - const hass = createHASS(); + const handler = vi.fn(); + host.addEventListener('frigate-card:action:execution-request', handler); + const controller = new MenuController(host); - controller.actionHandler(hass, createEvent('tap'), tapActionConfigMulti); - expect(vi.mocked(handleActionConfig)).toBeCalledTimes(3); + controller.actionHandler(createEvent('tap'), tapActionConfigMulti); + + expect(handler).toBeCalledWith( + expect.objectContaining({ + detail: { action: [action, action, action], config: tapActionConfigMulti }, + }), + ); }); describe('should close menu', () => { it('tap', () => { const host = createLitElement(); - const hass = createHASS(); const controller = new MenuController(host); controller.setMenuConfig( createMenuConfig({ @@ -419,13 +426,12 @@ describe('MenuController', () => { controller.setExpanded(true); expect(controller.isExpanded()).toBeTruthy(); - controller.actionHandler(hass, createEvent('tap'), tapActionConfig); + controller.actionHandler(createEvent('tap'), tapActionConfig); expect(controller.isExpanded()).toBeFalsy(); }); it('end_tap', () => { const host = createLitElement(); - const hass = createHASS(); const controller = new MenuController(host); controller.setMenuConfig( createMenuConfig({ @@ -436,7 +442,7 @@ describe('MenuController', () => { controller.setExpanded(true); expect(controller.isExpanded()).toBeTruthy(); - controller.actionHandler(hass, createEvent('end_tap'), { + controller.actionHandler(createEvent('end_tap'), { end_tap_action: action, }); expect(controller.isExpanded()).toBeFalsy(); @@ -446,7 +452,6 @@ describe('MenuController', () => { describe('should not close menu', () => { it('start_tap with later action', () => { const host = createLitElement(); - const hass = createHASS(); const controller = new MenuController(host); controller.setMenuConfig( createMenuConfig({ @@ -457,7 +462,7 @@ describe('MenuController', () => { controller.setExpanded(true); expect(controller.isExpanded()).toBeTruthy(); - controller.actionHandler(hass, createEvent('start_tap'), { + controller.actionHandler(createEvent('start_tap'), { start_tap_action: action, end_tap_action: action, }); @@ -466,7 +471,6 @@ describe('MenuController', () => { it('with a menu toggle action', () => { const host = createLitElement(); - const hass = createHASS(); const controller = new MenuController(host); controller.setMenuConfig( createMenuConfig({ @@ -477,7 +481,7 @@ describe('MenuController', () => { controller.setExpanded(false); expect(controller.isExpanded()).toBeFalsy(); - controller.actionHandler(hass, createEvent('tap'), { + controller.actionHandler(createEvent('tap'), { camera_entity: 'foo', tap_action: menuToggleAction, }); @@ -497,7 +501,7 @@ describe('MenuController', () => { controller.setExpanded(true); expect(controller.isExpanded()).toBeTruthy(); - controller.actionHandler(hass, createEvent('end_tap'), tapActionConfig); + controller.actionHandler(createEvent('end_tap'), tapActionConfig); expect(controller.isExpanded()).toBeTruthy(); }); }); diff --git a/tests/components-lib/ptz-controller.test.ts b/tests/components-lib/ptz-controller.test.ts deleted file mode 100644 index c64b792f..00000000 --- a/tests/components-lib/ptz-controller.test.ts +++ /dev/null @@ -1,316 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { PTZController } from '../../src/components-lib/ptz-controller'; -import { - FrigateCardPTZConfig, - frigateCardPTZSchema, - PTZControlAction, -} from '../../src/config/types'; -import { - frigateCardHandleActionConfig, - getActionConfigGivenAction, -} from '../../src/utils/action.js'; -import { - createCapabilities, - createCameraManager, - createHASS, -} from '../test-utils'; - -vi.mock('../../src/utils/action.js'); - -const createConfig = (config?: Partial): FrigateCardPTZConfig => { - return frigateCardPTZSchema.parse({ - ...config, - }); -}; - -// @vitest-environment jsdom -describe('PTZController', () => { - beforeEach(() => { - vi.resetAllMocks(); - }); - - it('should be creatable', () => { - const controller = new PTZController(document.createElement('div')); - expect(controller).toBeTruthy(); - }); - - it('should get config creatable', () => { - const controller = new PTZController(document.createElement('div')); - const config = createConfig(); - controller.setConfig(config); - expect(controller.getConfig()).toBe(config); - }); - - describe('should set element attributes', () => { - describe('orientation', () => { - describe('with config', () => { - it.each([['horizontal' as const], ['vertical' as const]])( - '%s', - (orientation: 'horizontal' | 'vertical') => { - const element = document.createElement('div'); - const controller = new PTZController(element); - controller.setConfig(createConfig({ orientation: orientation })); - expect(element.getAttribute('data-orientation')).toBe(orientation); - }, - ); - }); - it('without config', () => { - const element = document.createElement('div'); - const controller = new PTZController(element); - controller.setConfig(); - expect(element.getAttribute('data-orientation')).toBe('horizontal'); - }); - }); - describe('position', () => { - describe('with config', () => { - it.each([ - ['top-left' as const], - ['top-right' as const], - ['bottom-left' as const], - ['bottom-right' as const], - ])( - '%s', - (position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right') => { - const element = document.createElement('div'); - const controller = new PTZController(element); - controller.setConfig(createConfig({ position: position })); - expect(element.getAttribute('data-position')).toBe(position); - }, - ); - }); - it('without config', () => { - const element = document.createElement('div'); - const controller = new PTZController(element); - controller.setConfig(); - expect(element.getAttribute('data-position')).toBe('bottom-right'); - }); - }); - it('with style in config', () => { - const element = document.createElement('div'); - const controller = new PTZController(element); - controller.setConfig(createConfig({ style: { transform: 'none', left: '50%' } })); - expect(element.getAttribute('style')).toBe('transform:none;left:50%'); - }); - }); - - describe('should respect mode', () => { - it('off', () => { - const controller = new PTZController(document.createElement('div')); - expect(controller.shouldDisplay()).toBeFalsy(); - }); - it('off but forced on', () => { - const controller = new PTZController(document.createElement('div')); - controller.setForceVisibility(true); - // No actions, no rendering, no matter what. - expect(controller.shouldDisplay()).toBeFalsy(); - }); - it('on without actions', () => { - const controller = new PTZController(document.createElement('div')); - controller.setConfig(createConfig({ mode: 'on' })); - expect(controller.shouldDisplay()).toBeFalsy(); - }); - it('on with actions', () => { - const controller = new PTZController(document.createElement('div')); - controller.setConfig(createConfig({ mode: 'on', actions_left: {} })); - controller.setCamera(createCameraManager(), 'camera.office'); - expect(controller.shouldDisplay()).toBeTruthy(); - }); - it('on with actions but forced off', () => { - const controller = new PTZController(document.createElement('div')); - controller.setConfig(createConfig({ mode: 'on', actions_left: {} })); - controller.setCamera(createCameraManager(), 'camera.office'); - controller.setForceVisibility(false); - expect(controller.shouldDisplay()).toBeFalsy(); - }); - }); - - describe('should get PTZ actions', () => { - it('without config', () => { - const controller = new PTZController(document.createElement('div')); - expect(controller.getPTZActions('left')).toBeNull(); - }); - - describe('using defaults', () => { - describe('with continous PTZ', () => { - it.each([ - ['left' as const], - ['right' as const], - ['up' as const], - ['down' as const], - ['zoom_in' as const], - ['zoom_out' as const], - ])('%s', (actionName: PTZControlAction) => { - const controller = new PTZController(document.createElement('div')); - controller.setConfig(createConfig()); - - const cameraManager = createCameraManager(); - vi.mocked(cameraManager).getCameraCapabilities.mockReturnValue( - createCapabilities({ - ptz: { - panTilt: ['continuous'], - zoom: ['continuous'], - }, - }), - ); - controller.setCamera(cameraManager, 'camera.office'); - - expect(controller.getPTZActions(actionName)).toEqual({ - start_tap_action: { - action: 'fire-dom-event', - frigate_card_action: 'ptz', - ptz_action: actionName, - ptz_phase: 'start', - }, - end_tap_action: { - action: 'fire-dom-event', - frigate_card_action: 'ptz', - ptz_action: actionName, - ptz_phase: 'stop', - }, - }); - }); - }); - - describe('with relative PTZ', () => { - it.each([ - ['left' as const], - ['right' as const], - ['up' as const], - ['down' as const], - ['zoom_in' as const], - ['zoom_out' as const], - ])('%s', (actionName: PTZControlAction) => { - const controller = new PTZController(document.createElement('div')); - controller.setConfig(createConfig()); - - const cameraManager = createCameraManager(); - vi.mocked(cameraManager).getCameraCapabilities.mockReturnValue( - createCapabilities({ - ptz: { - panTilt: ['relative'], - zoom: ['relative'], - }, - }), - ); - controller.setCamera(cameraManager, 'camera.office'); - - expect(controller.getPTZActions(actionName)).toEqual({ - tap_action: { - action: 'fire-dom-event', - frigate_card_action: 'ptz', - ptz_action: actionName, - }, - }); - }); - }); - - it('home', () => { - const controller = new PTZController(document.createElement('div')); - controller.setConfig(createConfig()); - - const cameraManager = createCameraManager(); - vi.mocked(cameraManager).getCameraCapabilities.mockReturnValue( - createCapabilities({ - ptz: { - presets: ['preset-foo'], - }, - }), - ); - controller.setCamera(cameraManager, 'camera.office'); - - expect(controller.getPTZActions('home')).toEqual({ - tap_action: { - action: 'fire-dom-event', - frigate_card_action: 'ptz', - ptz_action: 'preset', - ptz_preset: 'preset-foo', - }, - }); - }); - }); - - describe('using config', () => { - it.each([ - ['left' as const], - ['right' as const], - ['up' as const], - ['down' as const], - ['zoom_in' as const], - ['zoom_out' as const], - ])('configured %s', (actionName: PTZControlAction) => { - const controller = new PTZController(document.createElement('div')); - controller.setConfig( - createConfig({ - [`actions_${actionName}`]: { - argument: actionName, - }, - }), - ); - controller.setCamera(createCameraManager(), 'camera.office'); - - expect(controller.getPTZActions(actionName)).toEqual({ - argument: actionName, - }); - }); - - it('configured home', () => { - const controller = new PTZController(document.createElement('div')); - controller.setConfig( - createConfig({ - actions_home: { - argument: 'home', - }, - }), - ); - controller.setCamera(createCameraManager(), 'camera.office'); - - expect(controller.getPTZActions('home')).toEqual({ - argument: 'home', - }); - }); - }); - }); - - describe('should handle action', () => { - it('without hass or action', () => { - const controller = new PTZController(document.createElement('div')); - controller.setHASS(); - controller.setCamera(); - controller.handleAction( - new CustomEvent<{ action: string }>('@action', { detail: { action: 'tap' } }), - ); - expect(frigateCardHandleActionConfig).not.toBeCalled(); - }); - - it('with action', () => { - const element = document.createElement('div'); - const controller = new PTZController(element); - const hass = createHASS(); - controller.setHASS(hass); - controller.setConfig( - createConfig({ - [`actions_left`]: {}, - }), - ); - const tapAction = { - action: 'none' as const, - }; - const actionsConfig = { - tap_action: tapAction, - }; - vi.mocked(getActionConfigGivenAction).mockReturnValue(tapAction); - - controller.handleAction( - new CustomEvent<{ action: string }>('@action', { detail: { action: 'tap' } }), - actionsConfig, - ); - expect(frigateCardHandleActionConfig).toBeCalledWith( - element, - hass, - actionsConfig, - 'tap', - actionsConfig.tap_action, - ); - }); - }); -}); diff --git a/tests/components-lib/ptz/ptz-controller.test.ts b/tests/components-lib/ptz/ptz-controller.test.ts new file mode 100644 index 00000000..5d5f9519 --- /dev/null +++ b/tests/components-lib/ptz/ptz-controller.test.ts @@ -0,0 +1,378 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Capabilities } from '../../../src/camera-manager/capabilities'; +import { PTZController } from '../../../src/components-lib/ptz/ptz-controller'; +import { PTZControlAction } from '../../../src/config/ptz'; +import { PTZControlsConfig, ptzControlsConfigSchema } from '../../../src/config/types'; +import { createCameraManager, createCapabilities, createStore } from '../../test-utils'; + +const createConfig = (config?: Partial): PTZControlsConfig => { + return ptzControlsConfigSchema.parse({ + ...config, + }); +}; + +// @vitest-environment jsdom +describe('PTZController', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should be creatable', () => { + const controller = new PTZController(document.createElement('div')); + expect(controller).toBeTruthy(); + }); + + it('should get config', () => { + const controller = new PTZController(document.createElement('div')); + const config = createConfig(); + controller.setConfig(config); + expect(controller.getConfig()).toBe(config); + }); + + describe('should set element attributes', () => { + describe('orientation', () => { + describe('with config', () => { + it.each([['horizontal' as const], ['vertical' as const]])( + '%s', + (orientation: 'horizontal' | 'vertical') => { + const element = document.createElement('div'); + const controller = new PTZController(element); + controller.setConfig(createConfig({ orientation: orientation })); + expect(element.getAttribute('data-orientation')).toBe(orientation); + }, + ); + }); + it('without config', () => { + const element = document.createElement('div'); + const controller = new PTZController(element); + controller.setConfig(); + expect(element.getAttribute('data-orientation')).toBe('horizontal'); + }); + }); + describe('position', () => { + describe('with config', () => { + it.each([ + ['top-left' as const], + ['top-right' as const], + ['bottom-left' as const], + ['bottom-right' as const], + ])( + '%s', + (position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right') => { + const element = document.createElement('div'); + const controller = new PTZController(element); + controller.setConfig(createConfig({ position: position })); + expect(element.getAttribute('data-position')).toBe(position); + }, + ); + }); + it('without config', () => { + const element = document.createElement('div'); + const controller = new PTZController(element); + controller.setConfig(); + expect(element.getAttribute('data-position')).toBe('bottom-right'); + }); + }); + it('with style in config', () => { + const element = document.createElement('div'); + const controller = new PTZController(element); + controller.setConfig(createConfig({ style: { transform: 'none', left: '50%' } })); + expect(element.getAttribute('style')).toBe('transform:none;left:50%'); + }); + }); + + describe('should respect mode', () => { + it('off', () => { + const controller = new PTZController(document.createElement('div')); + expect(controller.shouldDisplay()).toBeFalsy(); + }); + it('forced on', () => { + const controller = new PTZController(document.createElement('div')); + controller.setForceVisibility(true); + + expect(controller.shouldDisplay()).toBeTruthy(); + }); + it('forced off', () => { + const controller = new PTZController(document.createElement('div')); + controller.setForceVisibility(false); + expect(controller.shouldDisplay()).toBeFalsy(); + }); + + it('configured on', () => { + const controller = new PTZController(document.createElement('div')); + controller.setConfig(createConfig({ mode: 'on' })); + expect(controller.shouldDisplay()).toBeTruthy(); + }); + it('configured off', () => { + const controller = new PTZController(document.createElement('div')); + controller.setConfig(createConfig({ mode: 'off' })); + expect(controller.shouldDisplay()).toBeFalsy(); + }); + + it('auto without capability', () => { + const controller = new PTZController(document.createElement('div')); + controller.setConfig(createConfig({ mode: 'auto' })); + controller.setCamera(createCameraManager(), 'camera.office'); + expect(controller.shouldDisplay()).toBeFalsy(); + }); + it('auto with capability', () => { + const store = createStore([ + { + cameraID: 'camera.office', + capabilities: new Capabilities({ ptz: { left: ['relative'] } }), + }, + ]); + const cameraManager = createCameraManager(store); + vi.mocked(cameraManager).getCameraCapabilities.mockReturnValue( + createCapabilities({ + ptz: { + left: ['relative'], + }, + }), + ); + + const controller = new PTZController(document.createElement('div')); + controller.setConfig(createConfig({ mode: 'auto' })); + controller.setCamera(cameraManager, 'camera.office'); + expect(controller.shouldDisplay()).toBeTruthy(); + }); + }); + + describe('should get PTZ actions', () => { + it.each([ + ['left' as const], + ['right' as const], + ['up' as const], + ['down' as const], + ['zoom_in' as const], + ['zoom_out' as const], + ])('%s', (actionName: PTZControlAction) => { + const controller = new PTZController(document.createElement('div')); + controller.setConfig(createConfig()); + + const store = createStore([ + { + cameraID: 'camera.office', + capabilities: new Capabilities({ + ptz: { + left: ['relative'], + right: ['relative'], + up: ['relative'], + down: ['relative'], + zoomIn: ['relative'], + zoomOut: ['relative'], + }, + }), + }, + ]); + + const cameraManager = createCameraManager(store); + controller.setCamera(cameraManager, 'camera.office'); + + expect(controller.getPTZActions()?.[actionName]).toEqual({ + start_tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'ptz_multi', + ptz_action: actionName, + ptz_phase: 'start', + }, + end_tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'ptz_multi', + ptz_action: actionName, + ptz_phase: 'stop', + }, + }); + }); + + it('home', () => { + const controller = new PTZController(document.createElement('div')); + controller.setConfig(createConfig()); + + const store = createStore([ + { + cameraID: 'camera.office', + capabilities: new Capabilities({ + ptz: { + left: ['relative'], + right: ['relative'], + up: ['relative'], + down: ['relative'], + zoomIn: ['relative'], + zoomOut: ['relative'], + }, + }), + }, + ]); + + const cameraManager = createCameraManager(store); + controller.setCamera(cameraManager, 'camera.office'); + + expect(controller.getPTZActions()['home']).toEqual({ + tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'ptz_multi', + }, + }); + }); + }); + + describe('should handle action', () => { + it('successfully', () => { + const action = { + action: 'more-info' as const, + }; + const config = { + tap_action: action, + camera_entity: 'camera.office', + }; + + const element = document.createElement('div'); + const handler = vi.fn(); + element.addEventListener('frigate-card:action:execution-request', handler); + + const controller = new PTZController(element); + controller.setCamera(); + controller.handleAction( + new CustomEvent<{ action: string }>('@action', { detail: { action: 'tap' } }), + config, + ); + + expect(handler).toBeCalledWith( + expect.objectContaining({ + detail: { + action: action, + config: config, + }, + }), + ); + }); + + it('should not call action without actions config', () => { + const element = document.createElement('div'); + const handler = vi.fn(); + element.addEventListener('frigate-card:action:execution-request', handler); + + const controller = new PTZController(element); + controller.setCamera(); + controller.handleAction( + new CustomEvent<{ action: string }>('@action', { detail: { action: 'tap' } }), + ); + + expect(handler).not.toBeCalled(); + }); + + it('should not call action without hass', () => { + const element = document.createElement('div'); + const handler = vi.fn(); + element.addEventListener('frigate-card:action:execution-request', handler); + + const controller = new PTZController(element); + controller.setCamera(); + controller.handleAction( + new CustomEvent<{ action: string }>('@action', { detail: { action: 'tap' } }), + ); + + expect(handler).not.toBeCalled(); + }); + }); + + describe('should identify useful actions', () => { + it('without a camera', () => { + const controller = new PTZController(document.createElement('div')); + expect(controller.hasUsefulAction()).toEqual({ + pt: true, + z: true, + home: true, + }); + }); + + it('without camera PTZ capabilities', () => { + const controller = new PTZController(document.createElement('div')); + + const store = createStore([ + { + cameraID: 'camera.office', + capabilities: createCapabilities({ + ptz: {}, + }), + }, + ]); + const cameraManager = createCameraManager(store); + controller.setCamera(cameraManager, 'camera.office'); + + expect(controller.hasUsefulAction()).toEqual({ + pt: true, + z: true, + home: true, + }); + }); + + it('with camera pan and tilt capabilities', () => { + const controller = new PTZController(document.createElement('div')); + const store = createStore([ + { + cameraID: 'camera.office', + capabilities: createCapabilities({ + ptz: { + left: ['relative'], + right: ['relative'], + up: ['relative'], + down: ['relative'], + }, + }), + }, + ]); + controller.setCamera(createCameraManager(store), 'camera.office'); + + expect(controller.hasUsefulAction()).toEqual({ + pt: true, + z: false, + home: false, + }); + }); + + it('with camera zoom capabilities', () => { + const controller = new PTZController(document.createElement('div')); + const store = createStore([ + { + cameraID: 'camera.office', + capabilities: createCapabilities({ + ptz: { + zoomIn: ['relative'], + zoomOut: ['relative'], + }, + }), + }, + ]); + controller.setCamera(createCameraManager(store), 'camera.office'); + + expect(controller.hasUsefulAction()).toEqual({ + pt: false, + z: true, + home: false, + }); + }); + + it('with camera presets', () => { + const controller = new PTZController(document.createElement('div')); + const store = createStore([ + { + cameraID: 'camera.office', + capabilities: createCapabilities({ + ptz: { + presets: ['door'], + }, + }), + }, + ]); + controller.setCamera(createCameraManager(store), 'camera.office'); + + expect(controller.hasUsefulAction()).toEqual({ + pt: false, + z: false, + home: true, + }); + }); + }); +}); diff --git a/tests/components-lib/zoom/zoom-controller.test.ts b/tests/components-lib/zoom/zoom-controller.test.ts index 46fa22e2..ed4a3689 100644 --- a/tests/components-lib/zoom/zoom-controller.test.ts +++ b/tests/components-lib/zoom/zoom-controller.test.ts @@ -5,7 +5,7 @@ import { ZoomController } from '../../../src/components-lib/zoom/zoom-controller import { ResizeObserverMock, requestAnimationFrameMock } from '../../test-utils'; vi.mock('@dermotduffy/panzoom'); -vi.mock('lodash-es/debounce', () => ({ +vi.mock('lodash-es/throttle', () => ({ default: vi.fn((fn) => fn), })); @@ -311,8 +311,8 @@ describe('ZoomController', () => { const element = document.createElement('div'); setElementToDefaultCardSize(element); - const defaultFunc = vi.fn(); - element.addEventListener('frigate-card:zoom:default', defaultFunc); + const changeFunc = vi.fn(); + element.addEventListener('frigate-card:zoom:change', changeFunc); vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom()); createAndRegisterZoom(element); @@ -327,8 +327,11 @@ describe('ZoomController', () => { }, }); element.dispatchEvent(ev_1); - expect(defaultFunc).toBeCalledWith( - expect.objectContaining({ detail: { isDefault: false } }), + + expect(changeFunc).toHaveBeenLastCalledWith( + expect.objectContaining({ + detail: expect.objectContaining({ isDefault: false }), + }), ); const ev_2 = new CustomEvent('panzoomchange', { @@ -341,8 +344,11 @@ describe('ZoomController', () => { }, }); element.dispatchEvent(ev_2); - expect(defaultFunc).toBeCalledWith( - expect.objectContaining({ detail: { isDefault: true } }), + + expect(changeFunc).toHaveBeenLastCalledWith( + expect.objectContaining({ + detail: expect.objectContaining({ isDefault: true }), + }), ); }); @@ -350,12 +356,12 @@ describe('ZoomController', () => { const element = document.createElement('div'); setElementToDefaultCardSize(element); - const defaultFunc = vi.fn(); - element.addEventListener('frigate-card:zoom:default', defaultFunc); + const changeFunc = vi.fn(); + element.addEventListener('frigate-card:zoom:change', changeFunc); vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom()); const controller = createAndRegisterZoom(element); - controller.setDefaultConfig({ zoom: 2, pan: { x: 3, y: 4 } }); + controller.setDefaultSettings({ zoom: 2, pan: { x: 3, y: 4 } }); const ev_1 = new CustomEvent('panzoomchange', { detail: { @@ -367,8 +373,11 @@ describe('ZoomController', () => { }, }); element.dispatchEvent(ev_1); - expect(defaultFunc).toHaveBeenLastCalledWith( - expect.objectContaining({ detail: { isDefault: false } }), + + expect(changeFunc).toHaveBeenLastCalledWith( + expect.objectContaining({ + detail: expect.objectContaining({ isDefault: false }), + }), ); const ev_2 = new CustomEvent('panzoomchange', { @@ -381,8 +390,11 @@ describe('ZoomController', () => { }, }); element.dispatchEvent(ev_2); - expect(defaultFunc).toHaveBeenLastCalledWith( - expect.objectContaining({ detail: { isDefault: true } }), + + expect(changeFunc).toHaveBeenLastCalledWith( + expect.objectContaining({ + detail: expect.objectContaining({ isDefault: true }), + }), ); }); @@ -390,8 +402,8 @@ describe('ZoomController', () => { const element = document.createElement('div'); setElementToDefaultCardSize(element); - const defaultFunc = vi.fn(); - element.addEventListener('frigate-card:zoom:default', defaultFunc); + const changeFunc = vi.fn(); + element.addEventListener('frigate-card:zoom:change', changeFunc); vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom()); const controller = createAndRegisterZoom(element); @@ -406,11 +418,14 @@ describe('ZoomController', () => { }, }); element.dispatchEvent(ev_1); - expect(defaultFunc).toHaveBeenLastCalledWith( - expect.objectContaining({ detail: { isDefault: false } }), + + expect(changeFunc).toHaveBeenLastCalledWith( + expect.objectContaining({ + detail: expect.objectContaining({ isDefault: false }), + }), ); - controller.setDefaultConfig({}); + controller.setDefaultSettings({}); const ev_2 = new CustomEvent('panzoomchange', { detail: { @@ -422,8 +437,11 @@ describe('ZoomController', () => { }, }); element.dispatchEvent(ev_2); - expect(defaultFunc).toHaveBeenLastCalledWith( - expect.objectContaining({ detail: { isDefault: true } }), + + expect(changeFunc).toHaveBeenLastCalledWith( + expect.objectContaining({ + detail: expect.objectContaining({ isDefault: true }), + }), ); }); }); @@ -438,7 +456,7 @@ describe('ZoomController', () => { setElementToDefaultCardSize(element); const controller = new ZoomController(element); - controller.setDefaultConfig({ zoom: 2, pan: { x: 3, y: 4 } }); + controller.setDefaultSettings({ zoom: 2, pan: { x: 3, y: 4 } }); // Controller was not activated, config setting will not update pan/zoom. expect(panzoom.zoom).not.toBeCalled(); @@ -448,16 +466,16 @@ describe('ZoomController', () => { expect(Panzoom).toBeCalledWith( expect.anything(), expect.objectContaining({ - contain: "outside", + contain: 'outside', cursor: undefined, maxScale: 10, minScale: 1, noBind: true, - touchAction: "", + touchAction: '', startScale: 2, startX: 115.62, startY: 63.6525, - }) + }), ); }); @@ -469,12 +487,15 @@ describe('ZoomController', () => { setElementToDefaultCardSize(element); const controller = createAndRegisterZoom(element); - controller.setDefaultConfig({ zoom: 2, pan: { x: 3, y: 4 } }); + controller.setDefaultSettings({ zoom: 2, pan: { x: 3, y: 4 } }); triggerResizeObserver(); expect(panzoom.zoom).toBeCalledWith(2, { animate: false }); - expect(panzoom.pan).toBeCalledWith(115.62, 63.6525, { animate: false }); + expect(panzoom.pan).toBeCalledWith(115.62, 63.6525, { + animate: true, + duration: 100, + }); }); it('with set of config when a default is already set', () => { @@ -487,23 +508,25 @@ describe('ZoomController', () => { const controller = createAndRegisterZoom(element); // This call will do nothing since this is what zoom/pan already are. - controller.setDefaultConfig({ zoom: 1, pan: { x: 0, y: 0 } }); + controller.setDefaultSettings({ zoom: 1, pan: { x: 0, y: 0 } }); expect(panzoom.zoom).not.toBeCalled(); expect(panzoom.pan).not.toBeCalled(); - - controller.setDefaultConfig({ zoom: 2, pan: { x: 3, y: 4 } }); + + controller.setDefaultSettings({ zoom: 2, pan: { x: 3, y: 4 } }); expect(panzoom.zoom).toHaveBeenNthCalledWith(1, 2, { animate: false }); expect(panzoom.pan).toHaveBeenNthCalledWith(1, 115.62, 63.6525, { - animate: false, + animate: true, + duration: 100, }); - controller.setConfig({ zoom: 3, pan: { x: 5, y: 6 } }); + controller.setSettings({ zoom: 3, pan: { x: 5, y: 6 } }); expect(panzoom.zoom).toHaveBeenNthCalledWith(2, 3, { animate: false }); expect(panzoom.pan).toHaveBeenNthCalledWith(2, 147.6, 81.18, { - animate: false, + animate: true, + duration: 100, }); }); @@ -516,30 +539,31 @@ describe('ZoomController', () => { const controller = createAndRegisterZoom(element); - controller.setConfig({ zoom: 1 }); + controller.setSettings({ zoom: 1 }); expect(panzoom.zoom).not.toHaveBeenCalled(); - controller.setConfig({ pan: { x: 50, y: 50 } }); + controller.setSettings({ pan: { x: 50, y: 50 } }); expect(panzoom.zoom).not.toHaveBeenCalled(); - controller.setConfig({ zoom: 1, pan: { x: 50, y: 50 } }); + controller.setSettings({ zoom: 1, pan: { x: 50, y: 50 } }); expect(panzoom.zoom).not.toHaveBeenCalled(); - controller.setConfig({}); + controller.setSettings({}); expect(panzoom.zoom).not.toHaveBeenCalled(); - controller.setConfig({ zoom: 2 }); + controller.setSettings({ zoom: 2 }); expect(panzoom.zoom).toBeCalledTimes(1); expect(panzoom.pan).toBeCalledTimes(1); expect(panzoom.zoom).toHaveBeenNthCalledWith(1, 2, { animate: false }); expect(panzoom.pan).toHaveBeenNthCalledWith(1, 0, 0, { - animate: false, + animate: true, + duration: 100, }); vi.mocked(panzoom.getScale).mockReturnValue(2); vi.mocked(panzoom.getPan).mockReturnValue({ x: 0, y: 0 }); - controller.setConfig({ zoom: 2 }); + controller.setSettings({ zoom: 2 }); expect(panzoom.zoom).toBeCalledTimes(1); expect(panzoom.pan).toBeCalledTimes(1); @@ -553,15 +577,16 @@ describe('ZoomController', () => { setElementToDefaultCardSize(element); const controller = createAndRegisterZoom(element); - controller.setDefaultConfig({ zoom: 2, pan: { x: 3, y: 4 } }); + controller.setDefaultSettings({ zoom: 2, pan: { x: 3, y: 4 } }); mockClear(panzoom); - controller.setConfig({}); + controller.setSettings({}); // Should fall back to default. expect(panzoom.zoom).toBeCalledWith(2, { animate: false }); expect(panzoom.pan).toBeCalledWith(115.62, 63.6525, { - animate: false, + animate: true, + duration: 100, }); }); @@ -573,11 +598,12 @@ describe('ZoomController', () => { setElementToDefaultCardSize(element); const controller = createAndRegisterZoom(element); - controller.setConfig({ zoom: 2, pan: { x: 3, y: 4 } }); + controller.setSettings({ zoom: 2, pan: { x: 3, y: 4 } }); expect(panzoom.zoom).toHaveBeenNthCalledWith(1, 2, { animate: false }); expect(panzoom.pan).toHaveBeenNthCalledWith(1, 115.62, 63.6525, { - animate: false, + animate: true, + duration: 100, }); vi.mocked(panzoom.getScale).mockReturnValue(2); @@ -588,7 +614,8 @@ describe('ZoomController', () => { expect(panzoom.zoom).toHaveBeenNthCalledWith(2, 2, { animate: false }); expect(panzoom.pan).toHaveBeenNthCalledWith(2, 57.81, 31.82625, { - animate: false, + animate: true, + duration: 100, }); }); diff --git a/tests/components-lib/zoom/zoom-view-context.test.ts b/tests/components-lib/zoom/zoom-view-context.test.ts index 818cc569..25508b5a 100644 --- a/tests/components-lib/zoom/zoom-view-context.test.ts +++ b/tests/components-lib/zoom/zoom-view-context.test.ts @@ -1,14 +1,39 @@ import { describe, expect, it, vi } from 'vitest'; import { - generateViewContextForZoomChange, - handleZoomDefaultEvent, + generateViewContextForZoom, + handleZoomSettingsObservedEvent, } from '../../../src/components-lib/zoom/zoom-view-context'; -describe('generateViewContextForZoomChangeRequest', () => { - it('with config', () => { +describe('generateViewContextForZoom', () => { + it('with observed', () => { expect( - generateViewContextForZoomChange('target', { - zoom: { + generateViewContextForZoom('target', { + observed: { + pan: { x: 1, y: 2 }, + zoom: 3, + isDefault: true, + unzoomed: true, + }, + }), + ).toEqual({ + zoom: { + target: { + observed: { + pan: { x: 1, y: 2 }, + zoom: 3, + isDefault: true, + unzoomed: true, + }, + requested: null, + }, + }, + }); + }); + + it('with requested', () => { + expect( + generateViewContextForZoom('target', { + requested: { pan: { x: 1, y: 2 }, zoom: 3, }, @@ -16,7 +41,7 @@ describe('generateViewContextForZoomChangeRequest', () => { ).toEqual({ zoom: { target: { - zoom: { + requested: { pan: { x: 1, y: 2 }, zoom: 3, }, @@ -24,50 +49,23 @@ describe('generateViewContextForZoomChangeRequest', () => { }, }); }); - - it('without config', () => { - expect(generateViewContextForZoomChange('target')).toEqual({ - zoom: { - target: { - zoom: null, - }, - }, - }); - }); -}); - -describe('generateViewContextForZoomDefault', () => { - it('default', () => { - expect(generateViewContextForZoomChange('target', { isDefault: true })).toEqual({ - zoom: { - target: { - isDefault: true, - zoom: null, - }, - }, - }); - }); - - it('not default', () => { - expect(generateViewContextForZoomChange('target', { isDefault: false })).toEqual({ - zoom: { - target: { - isDefault: false, - zoom: null, - }, - }, - }); - }); }); // @vitest-environment jsdom -it('handleZoomDefaultEvent', () => { +it('handleZoomSettingsObservedEvent', () => { const element = document.createElement('div'); const callback = vi.fn(); element.addEventListener('frigate-card:view:change-context', callback); - handleZoomDefaultEvent( + handleZoomSettingsObservedEvent( element, - new CustomEvent('frigate-card:zoom:default', { detail: { isDefault: true } }), + new CustomEvent('frigate-card:zoom:change', { + detail: { + pan: { x: 1, y: 2 }, + zoom: 3, + isDefault: true, + unzoomed: true, + }, + }), 'target', ); expect(callback).toBeCalledWith( @@ -75,8 +73,8 @@ it('handleZoomDefaultEvent', () => { detail: { zoom: { target: { - zoom: null, - isDefault: true, + observed: { pan: { x: 1, y: 2 }, zoom: 3, isDefault: true, unzoomed: true }, + requested: null, }, }, }, diff --git a/tests/config/management.test.ts b/tests/config/management.test.ts index 9bfb248e..0b5df517 100644 --- a/tests/config/management.test.ts +++ b/tests/config/management.test.ts @@ -1,3 +1,4 @@ +import { CallServiceActionConfig } from '@dermotduffy/custom-card-helpers'; import { describe, expect, it } from 'vitest'; import { copyConfig, @@ -17,7 +18,13 @@ import { upgradeObjectRecursively, upgradeWithOverrides, } from '../../src/config/management'; -import { RawFrigateCardConfig } from '../../src/config/types'; +import { PTZControlAction } from '../../src/config/ptz'; +import { + Actions, + RawFrigateCardConfig, + frigateCardConfigSchema, +} from '../../src/config/types'; +import { getParseErrorPaths } from '../../src/utils/zod'; describe('general functions', () => { it('should set value', () => { @@ -354,6 +361,24 @@ describe('upgrade functions', () => { }); describe('should handle version specific upgrades', () => { + const postUpgradeChecks = (config: RawFrigateCardConfig): void => { + // Should be no additional upgrades. + expect(upgradeConfig(config)).toBeFalsy(); + + // Result should be parseable. + const result = frigateCardConfigSchema.safeParse(config); + if (!result.success) { + expect( + result.success, + 'Post-upgrade parse error.\n\n' + + 'Problem paths: ' + + [...getParseErrorPaths(result.error)] + + '\n\n' + + JSON.stringify(config, null, 2), + ).toBeTruthy(); + } + }; + describe('v4.1.0', () => { describe('should rename mediaLoaded to media_loaded', () => { it('elements', () => { @@ -362,11 +387,13 @@ describe('should handle version specific upgrades', () => { cameras: [{ camera_entity: 'camera.office' }], elements: [ { + type: 'custom:random', conditions: { mediaLoaded: true, }, }, { + type: 'custom:random2', conditions: 'not an object', }, ], @@ -377,6 +404,7 @@ describe('should handle version specific upgrades', () => { cameras: [{ camera_entity: 'camera.office' }], elements: [ { + type: 'custom:random', conditions: [ { condition: 'media_loaded' as const, @@ -385,10 +413,12 @@ describe('should handle version specific upgrades', () => { ], }, { + type: 'custom:random2', conditions: 'not an object', }, ], }); + postUpgradeChecks(config); }); it('overrides', () => { @@ -428,6 +458,7 @@ describe('should handle version specific upgrades', () => { }, ], }); + postUpgradeChecks(config); }); }); @@ -447,6 +478,7 @@ describe('should handle version specific upgrades', () => { foo: 'bar', }, }); + postUpgradeChecks(config); }); it('should rename menu.buttons.frigate_ui to menu.buttons.camera_ui', () => { @@ -462,7 +494,12 @@ describe('should handle version specific upgrades', () => { }, overrides: [ { - conditions: {}, + conditions: [ + { + condition: 'view', + views: ['clips'], + }, + ], overrides: { menu: { buttons: { @@ -488,7 +525,12 @@ describe('should handle version specific upgrades', () => { }, overrides: [ { - conditions: {}, + conditions: [ + { + condition: 'view', + views: ['clips'], + }, + ], merge: { menu: { buttons: { @@ -501,6 +543,7 @@ describe('should handle version specific upgrades', () => { }, ], }); + postUpgradeChecks(config); }); it('should rename frigate ui actions', () => { @@ -549,6 +592,7 @@ describe('should handle version specific upgrades', () => { }, }, }); + postUpgradeChecks(config); }); describe('should rename frigate-jsmpeg provider to jsmpeg', () => { @@ -562,6 +606,7 @@ describe('should handle version specific upgrades', () => { type: 'custom:frigate-card', cameras: [{ live_provider: 'ha' }, { live_provider: 'jsmpeg' }], }); + postUpgradeChecks(config); }); }); @@ -571,6 +616,7 @@ describe('should handle version specific upgrades', () => { (objName: string) => { const config = { type: 'custom:frigate-card', + cameras: [{}], live: { [objName]: { foo: 'bar', @@ -580,6 +626,7 @@ describe('should handle version specific upgrades', () => { expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ type: 'custom:frigate-card', + cameras: [{}], cameras_global: { [objName]: { foo: 'bar', @@ -587,6 +634,7 @@ describe('should handle version specific upgrades', () => { }, live: {}, }); + postUpgradeChecks(config); }, ); }); @@ -608,6 +656,7 @@ describe('should handle version specific upgrades', () => { { live_provider: 'jsmpeg' }, ], }); + postUpgradeChecks(config); }); }); @@ -627,6 +676,7 @@ describe('should handle version specific upgrades', () => { { live_provider: 'jsmpeg' }, ], }); + postUpgradeChecks(config); }); }); @@ -732,6 +782,7 @@ describe('should handle version specific upgrades', () => { }, }, }); + postUpgradeChecks(config); }); it('negative case', () => { const config = { @@ -749,6 +800,7 @@ describe('should handle version specific upgrades', () => { default: 'live', }, }); + postUpgradeChecks(config); }); }); @@ -781,18 +833,20 @@ describe('should handle version specific upgrades', () => { expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ cameras: [{ camera_entity: 'camera.office' }], + cameras_global: { + ptz: { + actions_up: { + action: 'call-service', + data: { + message: 'Hello 1', + }, + service: 'notify.persistent_notification', + }, + }, + }, live: { controls: { ptz: { - actions_up: { - tap_action: { - action: 'call-service', - data: { - message: 'Hello 1', - }, - service: 'notify.persistent_notification', - }, - }, orientation: 'vertical', style: { color: 'white', @@ -804,6 +858,7 @@ describe('should handle version specific upgrades', () => { }, type: 'custom:frigate-card', }); + postUpgradeChecks(config); }); it('case with >1 element', () => { @@ -842,6 +897,17 @@ describe('should handle version specific upgrades', () => { expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ cameras: [{ camera_entity: 'camera.office' }], + cameras_global: { + ptz: { + actions_up: { + action: 'call-service', + data: { + message: 'Hello 1', + }, + service: 'notify.persistent_notification', + }, + }, + }, elements: [ { service: 'service', @@ -855,15 +921,6 @@ describe('should handle version specific upgrades', () => { live: { controls: { ptz: { - actions_up: { - tap_action: { - action: 'call-service', - data: { - message: 'Hello 1', - }, - service: 'notify.persistent_notification', - }, - }, orientation: 'vertical', style: { color: 'white', @@ -875,6 +932,7 @@ describe('should handle version specific upgrades', () => { }, type: 'custom:frigate-card', }); + postUpgradeChecks(config); }); it('case with custom conditional element with 2 PTZ but nothing else', () => { @@ -932,18 +990,20 @@ describe('should handle version specific upgrades', () => { expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ cameras: [{ camera_entity: 'camera.office' }], + cameras_global: { + ptz: { + actions_up: { + action: 'call-service', + data: { + message: 'Hello 1', + }, + service: 'notify.persistent_notification', + }, + }, + }, live: { controls: { ptz: { - actions_up: { - tap_action: { - action: 'call-service', - data: { - message: 'Hello 1', - }, - service: 'notify.persistent_notification', - }, - }, orientation: 'vertical', style: { color: 'white', @@ -955,6 +1015,7 @@ describe('should handle version specific upgrades', () => { }, type: 'custom:frigate-card', }); + postUpgradeChecks(config); }); it('case with custom conditional element with 1 PTZ and another element', () => { @@ -1002,18 +1063,20 @@ describe('should handle version specific upgrades', () => { expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ cameras: [{ camera_entity: 'camera.office' }], + cameras_global: { + ptz: { + actions_up: { + action: 'call-service', + data: { + message: 'Hello 1', + }, + service: 'notify.persistent_notification', + }, + }, + }, live: { controls: { ptz: { - actions_up: { - tap_action: { - action: 'call-service', - data: { - message: 'Hello 1', - }, - service: 'notify.persistent_notification', - }, - }, orientation: 'vertical', style: { color: 'white', @@ -1050,6 +1113,7 @@ describe('should handle version specific upgrades', () => { ], type: 'custom:frigate-card', }); + postUpgradeChecks(config); }); it('case with stock conditional element with 1 PTZ', () => { @@ -1086,18 +1150,20 @@ describe('should handle version specific upgrades', () => { expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ cameras: [{ camera_entity: 'camera.office' }], + cameras_global: { + ptz: { + actions_up: { + action: 'call-service', + data: { + message: 'Hello 1', + }, + service: 'notify.persistent_notification', + }, + }, + }, live: { controls: { ptz: { - actions_up: { - tap_action: { - action: 'call-service', - data: { - message: 'Hello 1', - }, - service: 'notify.persistent_notification', - }, - }, orientation: 'vertical', style: { color: 'white', @@ -1109,6 +1175,7 @@ describe('should handle version specific upgrades', () => { }, type: 'custom:frigate-card', }); + postUpgradeChecks(config); }); it('case when live.controls.ptz already exists', () => { @@ -1160,18 +1227,20 @@ describe('should handle version specific upgrades', () => { expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ cameras: [{ camera_entity: 'camera.office' }], + cameras_global: { + ptz: { + actions_up: { + action: 'call-service', + data: { + message: 'Original', + }, + service: 'notify.persistent_notification', + }, + }, + }, live: { controls: { ptz: { - actions_up: { - tap_action: { - action: 'call-service', - data: { - message: 'Original', - }, - service: 'notify.persistent_notification', - }, - }, orientation: 'vertical', style: { color: 'white', @@ -1183,6 +1252,7 @@ describe('should handle version specific upgrades', () => { }, type: 'custom:frigate-card', }); + postUpgradeChecks(config); }); }); @@ -1202,6 +1272,7 @@ describe('should handle version specific upgrades', () => { interaction_seconds: 200, }, }); + postUpgradeChecks(config); }); describe('should handle all and never action conditions', () => { @@ -1209,197 +1280,272 @@ describe('should handle version specific upgrades', () => { describe('lazy_unload', () => { it('all', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], live: { lazy_unload: 'all', }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], live: { lazy_unload: ['unselected', 'hidden'], }, }); + postUpgradeChecks(config); }); it('never', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], live: { lazy_unload: 'never', }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], live: {}, }); + postUpgradeChecks(config); }); it('other value', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], live: { lazy_unload: 'unselected', }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], live: { lazy_unload: ['unselected'], }, }); + postUpgradeChecks(config); }); }); describe('auto_play', () => { it('all', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], live: { auto_play: 'all', }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], live: {}, }); + postUpgradeChecks(config); }); it('never', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], live: { auto_play: 'never', }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], live: { auto_play: [], }, }); + postUpgradeChecks(config); }); it('other value', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], live: { auto_play: 'selected', }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], live: { auto_play: ['selected'], }, }); + postUpgradeChecks(config); }); }); describe('auto_pause', () => { it('all', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], live: { auto_pause: 'all', }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], live: { auto_pause: ['unselected', 'hidden'], }, }); + postUpgradeChecks(config); }); it('never', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], live: { auto_pause: 'never', }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], live: {}, }); + postUpgradeChecks(config); }); it('other value', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], live: { auto_pause: 'unselected', }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], live: { auto_pause: ['unselected'], }, }); + postUpgradeChecks(config); }); }); describe('auto_mute', () => { it('all', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], live: { auto_mute: 'all', }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], live: {}, }); + postUpgradeChecks(config); }); it('never', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], live: { auto_mute: 'never', }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], live: { auto_mute: [], }, }); + postUpgradeChecks(config); }); it('other value', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], live: { auto_mute: 'unselected', }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], live: { auto_mute: ['unselected'], }, }); + postUpgradeChecks(config); }); }); describe('auto_unmute', () => { it('all', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], live: { auto_unmute: 'all', }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], live: { auto_unmute: ['selected', 'visible', 'microphone'], }, }); + postUpgradeChecks(config); }); it('never', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], live: { auto_unmute: 'never', }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], live: {}, }); + postUpgradeChecks(config); }); it('other value', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], live: { auto_unmute: 'selected', }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], live: { auto_unmute: ['selected'], }, }); + postUpgradeChecks(config); }); }); }); @@ -1408,157 +1554,217 @@ describe('should handle version specific upgrades', () => { describe('auto_play', () => { it('all', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], media_viewer: { auto_play: 'all', }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], media_viewer: {}, }); + postUpgradeChecks(config); }); it('never', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], media_viewer: { auto_play: 'never', }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], media_viewer: { auto_play: [], }, }); + postUpgradeChecks(config); }); it('other value', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], media_viewer: { auto_play: 'selected', }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], media_viewer: { auto_play: ['selected'], }, }); + postUpgradeChecks(config); }); }); describe('auto_pause', () => { it('all', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], media_viewer: { auto_pause: 'all', }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], media_viewer: {}, }); + postUpgradeChecks(config); }); it('never', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], media_viewer: { auto_pause: 'never', }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], media_viewer: { auto_pause: [], }, }); + postUpgradeChecks(config); }); it('other value', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], media_viewer: { auto_pause: 'unselected', }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], media_viewer: { auto_pause: ['unselected'], }, }); + postUpgradeChecks(config); }); }); describe('auto_mute', () => { it('all', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], media_viewer: { auto_mute: 'all', }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], media_viewer: {}, }); + postUpgradeChecks(config); }); it('never', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], media_viewer: { auto_mute: 'never', }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], media_viewer: { auto_mute: [], }, }); + postUpgradeChecks(config); }); it('other value', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], media_viewer: { auto_mute: 'unselected', }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], media_viewer: { auto_mute: ['unselected'], }, }); + postUpgradeChecks(config); }); }); describe('auto_unmute', () => { it('all', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], media_viewer: { auto_unmute: 'all', }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], media_viewer: { auto_unmute: ['selected', 'visible'], }, }); + postUpgradeChecks(config); }); it('never', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], media_viewer: { auto_unmute: 'never', }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], media_viewer: {}, }); + postUpgradeChecks(config); }); it('other value', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], media_viewer: { auto_unmute: 'selected', }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], media_viewer: { auto_unmute: ['selected'], }, }); + postUpgradeChecks(config); }); }); }); @@ -1569,6 +1775,8 @@ describe('should handle version specific upgrades', () => { '%s', (mediaEventType: string) => { const config = { + type: 'custom:frigate-card', + cameras: [{}], live: { controls: { thumbnails: { @@ -1579,6 +1787,8 @@ describe('should handle version specific upgrades', () => { }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], live: { controls: { thumbnails: { @@ -1587,6 +1797,7 @@ describe('should handle version specific upgrades', () => { }, }, }); + postUpgradeChecks(config); }, ); }); @@ -1596,16 +1807,21 @@ describe('should handle version specific upgrades', () => { '%s', (mediaEventType: string) => { const config = { + type: 'custom:frigate-card', + cameras: [{}], timeline: { media: mediaEventType, }, }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], timeline: { events_media_type: mediaEventType, }, }); + postUpgradeChecks(config); }, ); }); @@ -1615,6 +1831,8 @@ describe('should handle version specific upgrades', () => { '%s', (mediaEventType: string) => { const config = { + type: 'custom:frigate-card', + cameras: [{}], live: { controls: { timeline: { @@ -1625,6 +1843,8 @@ describe('should handle version specific upgrades', () => { }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], live: { controls: { timeline: { @@ -1633,6 +1853,7 @@ describe('should handle version specific upgrades', () => { }, }, }); + postUpgradeChecks(config); }, ); }); @@ -1642,6 +1863,8 @@ describe('should handle version specific upgrades', () => { '%s', (mediaEventType: string) => { const config = { + type: 'custom:frigate-card', + cameras: [{}], media_viewer: { controls: { timeline: { @@ -1652,6 +1875,8 @@ describe('should handle version specific upgrades', () => { }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], media_viewer: { controls: { timeline: { @@ -1660,6 +1885,7 @@ describe('should handle version specific upgrades', () => { }, }, }); + postUpgradeChecks(config); }, ); }); @@ -1688,6 +1914,7 @@ describe('should handle version specific upgrades', () => { }, }, }); + postUpgradeChecks(config); }); it('when false', () => { @@ -1708,12 +1935,15 @@ describe('should handle version specific upgrades', () => { triggers: {}, }, }); + postUpgradeChecks(config); }); }); describe('should rename view.scan.enabled to a trigger action', () => { it('when true', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], view: { scan: { enabled: true, @@ -1722,6 +1952,8 @@ describe('should handle version specific upgrades', () => { }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], view: { triggers: { filter_selected_camera: false, @@ -1731,10 +1963,13 @@ describe('should handle version specific upgrades', () => { }, }, }); + postUpgradeChecks(config); }); it('when false', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], view: { scan: { enabled: false, @@ -1743,10 +1978,13 @@ describe('should handle version specific upgrades', () => { }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], view: { triggers: {}, }, }); + postUpgradeChecks(config); }); }); }); @@ -1754,6 +1992,8 @@ describe('should handle version specific upgrades', () => { describe('should handle media layout changes', () => { it('from live.layout to camera_global.dimensions', () => { const config = { + type: 'custom:frigate-card', + cameras: [{}], live: { layout: { fit: 'cover', @@ -1766,6 +2006,8 @@ describe('should handle version specific upgrades', () => { }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], live: {}, cameras_global: { dimensions: { @@ -1779,6 +2021,7 @@ describe('should handle version specific upgrades', () => { }, }, }); + postUpgradeChecks(config); }); describe('from delete old media layouts', () => { @@ -1786,6 +2029,8 @@ describe('should handle version specific upgrades', () => { '%s', (section: string) => { const config = { + type: 'custom:frigate-card', + cameras: [{}], [section]: { layout: { fit: 'cover', @@ -1798,8 +2043,11 @@ describe('should handle version specific upgrades', () => { }; expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], [section]: {}, }); + postUpgradeChecks(config); }, ); }); @@ -1813,11 +2061,13 @@ describe('should handle version specific upgrades', () => { cameras: [{ camera_entity: 'camera.office' }], elements: [ { + type: 'custom:random', conditions: { view: ['clips', 'snapshots'], }, }, { + type: 'custom:random2', conditions: 'not an object', }, ], @@ -1828,6 +2078,7 @@ describe('should handle version specific upgrades', () => { cameras: [{ camera_entity: 'camera.office' }], elements: [ { + type: 'custom:random', conditions: [ { condition: 'view' as const, @@ -1836,10 +2087,12 @@ describe('should handle version specific upgrades', () => { ], }, { + type: 'custom:random2', conditions: 'not an object', }, ], }); + postUpgradeChecks(config); }); it('overrides', () => { @@ -1880,6 +2133,7 @@ describe('should handle version specific upgrades', () => { }, ], }); + postUpgradeChecks(config); }); it('automations', () => { @@ -1891,10 +2145,12 @@ describe('should handle version specific upgrades', () => { conditions: { view: ['clips', 'snapshots'], }, - actions: { - action: 'custom:frigate-card-action' as const, - frigate_card_action: 'live_substream_on' as const, - }, + actions: [ + { + action: 'custom:frigate-card-action' as const, + frigate_card_action: 'live_substream_on' as const, + }, + ], }, ], }; @@ -1911,13 +2167,16 @@ describe('should handle version specific upgrades', () => { views: ['clips', 'snapshots'], }, ], - actions: { - action: 'custom:frigate-card-action' as const, - frigate_card_action: 'live_substream_on' as const, - }, + actions: [ + { + action: 'custom:frigate-card-action' as const, + frigate_card_action: 'live_substream_on' as const, + }, + ], }, ], }); + postUpgradeChecks(config); }); }); @@ -1928,11 +2187,13 @@ describe('should handle version specific upgrades', () => { cameras: [{ camera_entity: 'camera.office' }], elements: [ { + type: 'custom:random', conditions: { camera: ['camera_1', 'camera_2'], }, }, { + type: 'custom:random2', conditions: 'not an object', }, ], @@ -1943,6 +2204,7 @@ describe('should handle version specific upgrades', () => { cameras: [{ camera_entity: 'camera.office' }], elements: [ { + type: 'custom:random', conditions: [ { condition: 'camera' as const, @@ -1951,10 +2213,12 @@ describe('should handle version specific upgrades', () => { ], }, { + type: 'custom:random2', conditions: 'not an object', }, ], }); + postUpgradeChecks(config); }); it('overrides', () => { @@ -1995,6 +2259,7 @@ describe('should handle version specific upgrades', () => { }, ], }); + postUpgradeChecks(config); }); it('automations', () => { @@ -2006,10 +2271,12 @@ describe('should handle version specific upgrades', () => { conditions: { camera: ['camera_1', 'camera_2'], }, - actions: { - action: 'custom:frigate-card-action' as const, - frigate_card_action: 'live_substream_on' as const, - }, + actions: [ + { + action: 'custom:frigate-card-action' as const, + frigate_card_action: 'live_substream_on' as const, + }, + ], }, ], }; @@ -2026,13 +2293,16 @@ describe('should handle version specific upgrades', () => { cameras: ['camera_1', 'camera_2'], }, ], - actions: { - action: 'custom:frigate-card-action' as const, - frigate_card_action: 'live_substream_on' as const, - }, + actions: [ + { + action: 'custom:frigate-card-action' as const, + frigate_card_action: 'live_substream_on' as const, + }, + ], }, ], }); + postUpgradeChecks(config); }); }); @@ -2048,11 +2318,13 @@ describe('should handle version specific upgrades', () => { cameras: [{ camera_entity: 'camera.office' }], elements: [ { + type: 'custom:random', conditions: { [condition]: true, }, }, { + type: 'custom:random2', conditions: 'not an object', }, ], @@ -2063,6 +2335,7 @@ describe('should handle version specific upgrades', () => { cameras: [{ camera_entity: 'camera.office' }], elements: [ { + type: 'custom:random', conditions: [ { condition: condition, @@ -2071,10 +2344,12 @@ describe('should handle version specific upgrades', () => { ], }, { + type: 'custom:random2', conditions: 'not an object', }, ], }); + postUpgradeChecks(config); }); it('overrides', () => { @@ -2115,6 +2390,7 @@ describe('should handle version specific upgrades', () => { }, ], }); + postUpgradeChecks(config); }); it('automations', () => { @@ -2126,10 +2402,12 @@ describe('should handle version specific upgrades', () => { conditions: { [condition]: true, }, - actions: { - action: 'custom:frigate-card-action' as const, - frigate_card_action: 'live_substream_on' as const, - }, + actions: [ + { + action: 'custom:frigate-card-action' as const, + frigate_card_action: 'live_substream_on' as const, + }, + ], }, ], }; @@ -2146,13 +2424,16 @@ describe('should handle version specific upgrades', () => { [condition]: true, }, ], - actions: { - action: 'custom:frigate-card-action' as const, - frigate_card_action: 'live_substream_on' as const, - }, + actions: [ + { + action: 'custom:frigate-card-action' as const, + frigate_card_action: 'live_substream_on' as const, + }, + ], }, ], }); + postUpgradeChecks(config); }); }); }); @@ -2164,6 +2445,7 @@ describe('should handle version specific upgrades', () => { cameras: [{ camera_entity: 'camera.office' }], elements: [ { + type: 'custom:random', conditions: { state: [ { @@ -2179,6 +2461,7 @@ describe('should handle version specific upgrades', () => { }, }, { + type: 'custom:random2', conditions: 'not an object', }, ], @@ -2189,6 +2472,7 @@ describe('should handle version specific upgrades', () => { cameras: [{ camera_entity: 'camera.office' }], elements: [ { + type: 'custom:random', conditions: [ { condition: 'state' as const, @@ -2203,10 +2487,12 @@ describe('should handle version specific upgrades', () => { ], }, { + type: 'custom:random2', conditions: 'not an object', }, ], }); + postUpgradeChecks(config); }); it('overrides', () => { @@ -2263,6 +2549,7 @@ describe('should handle version specific upgrades', () => { }, ], }); + postUpgradeChecks(config); }); it('automations', () => { @@ -2284,11 +2571,12 @@ describe('should handle version specific upgrades', () => { {}, ], }, - - actions: { - action: 'custom:frigate-card-action' as const, - frigate_card_action: 'live_substream_on' as const, - }, + actions: [ + { + action: 'custom:frigate-card-action' as const, + frigate_card_action: 'live_substream_on' as const, + }, + ], }, ], }; @@ -2311,13 +2599,16 @@ describe('should handle version specific upgrades', () => { state_not: 'off', }, ], - actions: { - action: 'custom:frigate-card-action' as const, - frigate_card_action: 'live_substream_on' as const, - }, + actions: [ + { + action: 'custom:frigate-card-action' as const, + frigate_card_action: 'live_substream_on' as const, + }, + ], }, ], }); + postUpgradeChecks(config); }); }); @@ -2328,11 +2619,13 @@ describe('should handle version specific upgrades', () => { cameras: [{ camera_entity: 'camera.office' }], elements: [ { + type: 'custom:random', conditions: { media_query: 'query', }, }, { + type: 'custom:random2', conditions: 'not an object', }, ], @@ -2343,6 +2636,7 @@ describe('should handle version specific upgrades', () => { cameras: [{ camera_entity: 'camera.office' }], elements: [ { + type: 'custom:random', conditions: [ { condition: 'screen' as const, @@ -2351,10 +2645,12 @@ describe('should handle version specific upgrades', () => { ], }, { + type: 'custom:random2', conditions: 'not an object', }, ], }); + postUpgradeChecks(config); }); it('overrides', () => { @@ -2395,6 +2691,7 @@ describe('should handle version specific upgrades', () => { }, ], }); + postUpgradeChecks(config); }); it('automations', () => { @@ -2406,10 +2703,12 @@ describe('should handle version specific upgrades', () => { conditions: { media_query: 'query', }, - actions: { - action: 'custom:frigate-card-action' as const, - frigate_card_action: 'live_substream_on' as const, - }, + actions: [ + { + action: 'custom:frigate-card-action' as const, + frigate_card_action: 'live_substream_on' as const, + }, + ], }, ], }; @@ -2426,13 +2725,16 @@ describe('should handle version specific upgrades', () => { media_query: 'query', }, ], - actions: { - action: 'custom:frigate-card-action' as const, - frigate_card_action: 'live_substream_on' as const, - }, + actions: [ + { + action: 'custom:frigate-card-action' as const, + frigate_card_action: 'live_substream_on' as const, + }, + ], }, ], }); + postUpgradeChecks(config); }); }); }); @@ -2453,18 +2755,19 @@ describe('should handle version specific upgrades', () => { cameras: [ { camera_entity: 'camera.office', - capabilities: { disable_except: 'substream' }, + capabilities: { disable_except: ['substream'] }, }, { camera_entity: 'camera.sitting_room' }, ], }); + postUpgradeChecks(config); }); describe('from performance profile to generic profile', () => { it('low performance', () => { const config = { type: 'custom:frigate-card', - cameras: [], + cameras: [{}], performance: { profile: 'low', }, @@ -2473,16 +2776,17 @@ describe('should handle version specific upgrades', () => { expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ type: 'custom:frigate-card', - cameras: [], + cameras: [{}], profiles: ['low-performance'], performance: {}, }); + postUpgradeChecks(config); }); it('high performance', () => { const config = { type: 'custom:frigate-card', - cameras: [], + cameras: [{}], performance: { profile: 'high', }, @@ -2491,9 +2795,10 @@ describe('should handle version specific upgrades', () => { expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ type: 'custom:frigate-card', - cameras: [], + cameras: [{}], performance: {}, }); + postUpgradeChecks(config); }); }); }); @@ -2501,13 +2806,13 @@ describe('should handle version specific upgrades', () => { it('from overrides to merge', () => { const config = { type: 'custom:frigate-card', - cameras: [], + cameras: [{}], overrides: [ { conditions: [ { condition: 'view', - view: ['clips'], + views: ['clips'], }, ], overrides: { @@ -2522,13 +2827,13 @@ describe('should handle version specific upgrades', () => { expect(upgradeConfig(config)).toBeTruthy(); expect(config).toEqual({ type: 'custom:frigate-card', - cameras: [], + cameras: [{}], overrides: [ { conditions: [ { condition: 'view', - view: ['clips'], + views: ['clips'], }, ], merge: { @@ -2539,6 +2844,261 @@ describe('should handle version specific upgrades', () => { }, ], }); + postUpgradeChecks(config); + }); + + describe('split live.ptz', () => { + const getAction = (action: PTZControlAction): CallServiceActionConfig => ({ + action: 'call-service', + service: 'service', + data: { + arg: action, + }, + }); + + const getTapAction = (action: PTZControlAction): Actions => ({ + tap_action: getAction(action), + }); + + it('with action_ format', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{}], + live: { + controls: { + ptz: { + actions_up: getTapAction('up'), + actions_down: getTapAction('down'), + actions_left: getTapAction('left'), + actions_right: getTapAction('right'), + actions_zoom_in: getTapAction('zoom_in'), + actions_zoom_out: getTapAction('zoom_out'), + actions_home: { + ...getTapAction('home'), + double_tap_action: getAction('up'), + }, + + mode: 'auto' as const, + position: 'bottom-right' as const, + orientation: 'horizontal' as const, + hide_pan_tilt: false, + hide_zoom: false, + hide_home: false, + style: { + color: 'red', + }, + + something_not_related: 'very_unrelated', + }, + }, + }, + }; + + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], + cameras_global: { + ptz: { + actions_left: getAction('left'), + actions_right: getAction('right'), + actions_up: getAction('up'), + actions_down: getAction('down'), + actions_zoom_in: getAction('zoom_in'), + actions_zoom_out: getAction('zoom_out'), + presets: { + home: getAction('home'), + }, + }, + }, + live: { + controls: { + ptz: { + mode: 'auto' as const, + position: 'bottom-right' as const, + orientation: 'horizontal' as const, + hide_pan_tilt: false, + hide_zoom: false, + hide_home: false, + style: { + color: 'red', + }, + }, + }, + }, + }); + postUpgradeChecks(config); + }); + + it('with data_ format', () => { + const getDataAction = (action: PTZControlAction): Actions => ({ + action: { + device: '048123', + cmd: action, + }, + }); + + const config = { + type: 'custom:frigate-card', + cameras: [{}], + live: { + controls: { + ptz: { + service: 'service', + data_up: getDataAction('up'), + data_down: getDataAction('down'), + data_left: getDataAction('left'), + data_right: getDataAction('right'), + data_zoom_in: getDataAction('zoom_in'), + data_zoom_out: getDataAction('zoom_out'), + data_home: getDataAction('home'), + + mode: 'auto' as const, + position: 'bottom-right' as const, + orientation: 'horizontal' as const, + hide_pan_tilt: false, + hide_zoom: false, + hide_home: false, + style: { + color: 'red', + }, + + something_not_related: 'very_unrelated', + }, + }, + }, + }; + + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{}], + cameras_global: { + ptz: { + service: 'service', + data_left: getDataAction('left'), + data_right: getDataAction('right'), + data_up: getDataAction('up'), + data_down: getDataAction('down'), + data_zoom_in: getDataAction('zoom_in'), + data_zoom_out: getDataAction('zoom_out'), + presets: { + service: 'service', + data_home: getDataAction('home'), + }, + }, + }, + live: { + controls: { + ptz: { + mode: 'auto' as const, + position: 'bottom-right' as const, + orientation: 'horizontal' as const, + hide_pan_tilt: false, + hide_zoom: false, + hide_home: false, + style: { + color: 'red', + }, + }, + }, + }, + }); + postUpgradeChecks(config); + }); + + it('with invalid ptz type', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{}], + live: { + controls: { + ptz: 3, // Not an object. + }, + }, + }; + + expect(upgradeConfig(config)).toBeFalsy(); + }); + + it('with nothing to transform', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{}], + live: { + controls: { + ptz: { + mode: 'auto', + }, + }, + }, + }; + + expect(upgradeConfig(config)).toBeFalsy(); + }); + + it('without tap_action', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{}], + live: { + controls: { + ptz: { + actions_up: { + double_tap_action: getAction('up'), + }, + actions_down: getTapAction('down'), + }, + }, + }, + }; + + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual( + expect.objectContaining({ + cameras_global: { + ptz: { + actions_down: getAction('down'), + }, + }, + }), + ); + postUpgradeChecks(config); + }); + + it('without pre-existing presets', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{}], + live: { + controls: { + ptz: { + presets: { + home: getAction('left'), + other: getAction('right'), + }, + actions_home: getTapAction('home'), + }, + }, + }, + }; + + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual( + expect.objectContaining({ + cameras_global: { + ptz: { + presets: { + other: getAction('right'), + home: getAction('home'), + }, + }, + }, + }), + ); + postUpgradeChecks(config); + }); + }); }); }); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 05342c9d..93b8b56c 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest'; import { + cameraConfigSchema, conditionalSchema, customSchema, dimensionsConfigSchema, frigateCardCustomActionsBaseSchema, - frigateCardPTZSchema, } from '../../src/config/types'; import { createConfig } from '../test-utils'; @@ -36,6 +36,10 @@ describe('config defaults', () => { file_pattern: '%H-%M-%S', }, }, + ptz: { + c2r_delay_between_calls_seconds: 0.2, + r2c_delay_between_calls_seconds: 0.5, + }, triggers: { events: ['events', 'clips', 'snapshots'], entities: [], @@ -55,7 +59,6 @@ describe('config defaults', () => { image: { mode: 'url', refresh_seconds: 1, - zoomable: true, }, live: { auto_mute: ['unselected', 'hidden', 'microphone'], @@ -72,7 +75,7 @@ describe('config defaults', () => { hide_home: false, hide_pan_tilt: false, hide_zoom: false, - mode: 'on', + mode: 'auto', orientation: 'horizontal', position: 'bottom-right', }, @@ -135,6 +138,14 @@ describe('config defaults', () => { size: 48, style: 'thumbnails', }, + ptz: { + hide_home: false, + hide_pan_tilt: false, + hide_zoom: false, + mode: 'off', + orientation: 'horizontal', + position: 'bottom-right', + }, thumbnails: { mode: 'right', show_details: true, @@ -175,10 +186,6 @@ describe('config defaults', () => { enabled: true, priority: 50, }, - default_zoom: { - enabled: true, - priority: 50, - }, display_mode: { enabled: true, priority: 50, @@ -224,10 +231,14 @@ describe('config defaults', () => { enabled: false, priority: 50, }, - ptz: { + ptz_controls: { enabled: false, priority: 50, }, + ptz_home: { + enabled: true, + priority: 50, + }, recordings: { enabled: false, priority: 50, @@ -285,6 +296,30 @@ describe('config defaults', () => { camera_select: 'current', dark_mode: 'off', default: 'live', + keyboard_shortcuts: { + enabled: true, + ptz_down: { + key: 'ArrowDown', + }, + ptz_home: { + key: 'h', + }, + ptz_left: { + key: 'ArrowLeft', + }, + ptz_right: { + key: 'ArrowRight', + }, + ptz_up: { + key: 'ArrowUp', + }, + ptz_zoom_in: { + key: '+', + }, + ptz_zoom_out: { + key: '-', + }, + }, triggers: { show_trigger_status: false, untrigger_seconds: 0, @@ -336,46 +371,136 @@ it('should transform action', () => { }); describe('should convert webrtc card PTZ to Frigate card PTZ', () => { - it.each([ - ['left' as const], - ['right' as const], - ['up' as const], - ['down' as const], - ['zoom_in' as const], - ['zoom_out' as const], - ['home' as const], - ])('%s', (action: string) => { - expect( - frigateCardPTZSchema.parse({ - type: 'custom:frigate-card-ptz', - service: 'foo', - [`data_${action}`]: { - tap_action: { - action: 'none', + describe('relative actions', () => { + it.each([ + ['left' as const], + ['right' as const], + ['up' as const], + ['down' as const], + ['zoom_in' as const], + ['zoom_out' as const], + ])('%s', (action: string) => { + expect( + cameraConfigSchema.parse({ + ptz: { + service: 'foo', + [`data_${action}`]: { + device: '048123', + cmd: action, + }, }, - }, - }), - ).toEqual({ - [`actions_${action}`]: { - tap_action: { - action: 'call-service', - service: 'foo', - data: { - tap_action: { - action: 'none', + }), + ).toEqual( + expect.objectContaining({ + ptz: expect.objectContaining({ + [`actions_${action}`]: { + action: 'call-service', + service: 'foo', + data: { + device: '048123', + cmd: action, + }, + }, + }), + }), + ); + }); + }); + + describe('continuous actions', () => { + it.each([ + ['left' as const], + ['right' as const], + ['up' as const], + ['down' as const], + ['zoom_in' as const], + ['zoom_out' as const], + ])('%s', (action: string) => { + expect( + cameraConfigSchema.parse({ + ptz: { + service: 'foo', + [`data_${action}_start`]: { + device: '048123', + cmd: action, + phase: 'start', + }, + [`data_${action}_stop`]: { + device: '048123', + cmd: action, + phase: 'stop', + }, + }, + }), + ).toEqual( + expect.objectContaining({ + ptz: expect.objectContaining({ + [`actions_${action}_start`]: { + action: 'call-service', + service: 'foo', + data: { + device: '048123', + cmd: action, + phase: 'start', + }, + }, + [`actions_${action}_stop`]: { + action: 'call-service', + service: 'foo', + data: { + device: '048123', + cmd: action, + phase: 'stop', + }, + }, + }), + }), + ); + }); + }); + + it('presets', () => { + expect( + cameraConfigSchema.parse({ + ptz: { + service: 'service_outer', + presets: { + service: 'service_inner', + data_home: { + device: '048123', + cmd: 'home', + }, + data_another: { + device: '048123', + cmd: 'another', }, }, }, - }, - service: 'foo', - - hide_home: false, - hide_pan_tilt: false, - hide_zoom: false, - mode: 'on', - orientation: 'horizontal', - position: 'bottom-right', - }); + }), + ).toEqual( + expect.objectContaining({ + ptz: expect.objectContaining({ + presets: { + home: { + action: 'call-service', + service: 'service_inner', + data: { + device: '048123', + cmd: 'home', + }, + }, + another: { + action: 'call-service', + service: 'service_inner', + data: { + device: '048123', + cmd: 'another', + }, + }, + }, + }), + }), + ); }); }); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 3b1cf6e2..6b97f48e 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -14,13 +14,13 @@ import { CameraEventCallback, CameraManagerMediaCapabilities, } from '../src/camera-manager/types'; -import { ActionsManager } from '../src/card-controller/actions-manager'; +import { ActionsManager } from '../src/card-controller/actions/actions-manager'; import { AutoUpdateManager } from '../src/card-controller/auto-update-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 { ConditionsManager } from '../src/card-controller/conditions-manager'; -import { ConfigManager } from '../src/card-controller/config-manager'; +import { ConfigManager } from '../src/card-controller/config/config-manager'; import { CardController } from '../src/card-controller/controller'; import { DownloadManager } from '../src/card-controller/download-manager'; import { ExpandManager } from '../src/card-controller/expand-manager'; @@ -40,11 +40,13 @@ import { CameraConfig, FrigateCardCondition, FrigateCardConfig, + FrigateCardCustomAction, PerformanceConfig, RawFrigateCardConfig, cameraConfigSchema, frigateCardConditionSchema, frigateCardConfigSchema, + frigateCardCustomActionSchema, performanceConfigSchema, } from '../src/config/types'; import { CapabilitiesRaw, ExtendedHomeAssistant, MediaLoadedInfo } from '../src/types'; @@ -53,6 +55,17 @@ import { Entity } from '../src/utils/ha/entity-registry/types'; import { ViewMedia, ViewMediaType } from '../src/view/media'; import { MediaQueriesResults } from '../src/view/media-queries-results'; import { View, ViewParameters } from '../src/view/view'; +import { KeyboardStateManager } from '../src/card-controller/keyboard-state-manager'; + +export const createAction = ( + action: Record, +): FrigateCardCustomAction | null => { + const result = frigateCardCustomActionSchema.safeParse({ + action: 'custom:frigate-card-action', + ...action, + }); + return result.success ? result.data : null; +}; export const createCameraConfig = (config?: unknown): CameraConfig => { return cameraConfigSchema.parse(config ?? {}); @@ -209,9 +222,16 @@ export const createStore = ( return store; }; -export const createCameraManager = (): CameraManager => { +export const createCameraManager = (store?: CameraManagerStore): CameraManager => { + const cameraStore = store ?? createStore(); const cameraManager = mock(); - vi.mocked(cameraManager.getStore).mockReturnValue(createStore()); + vi.mocked(cameraManager.getStore).mockReturnValue(cameraStore); + vi.mocked(cameraManager.getCameraCapabilities).mockImplementation( + (cameraID: string): Capabilities | null => { + return cameraStore.getCamera(cameraID)?.getCapabilities() ?? null; + }, + ); + return cameraManager; }; @@ -404,6 +424,7 @@ export const createParent = (options?: { children?: HTMLElement[] }): HTMLElemen export const createLitElement = (): LitElement => { const element = document.createElement('div') as unknown as LitElement; + element.addController = vi.fn(); element.requestUpdate = vi.fn(); return element; }; @@ -426,6 +447,7 @@ export const createCardAPI = (): CardController => { api.getHASSManager.mockReturnValue(mock()); api.getInitializationManager.mockReturnValue(mock()); api.getInteractionManager.mockReturnValue(mock()); + api.getKeyboardStateManager.mockReturnValue(mock()); api.getMediaLoadedInfoManager.mockReturnValue(mock()); api.getMediaPlayerManager.mockReturnValue(mock()); api.getMessageManager.mockReturnValue(mock()); diff --git a/tests/utils/action.test.ts b/tests/utils/action.test.ts index 1591b68b..b0b7eba7 100644 --- a/tests/utils/action.test.ts +++ b/tests/utils/action.test.ts @@ -1,33 +1,33 @@ -import { handleActionConfig, hasAction } from '@dermotduffy/custom-card-helpers'; +import { hasAction } 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 { - convertActionToFrigateCardCustomAction, - createFrigateCardCameraAction, - createFrigateCardChangeZoomAction, - createFrigateCardDisplayModeAction, - createFrigateCardMediaPlayerAction, - createFrigateCardShowPTZAction, - createFrigateCardSimpleAction, - frigateCardHandleAction, - frigateCardHandleActionConfig, + convertActionToCardCustomAction, + createCameraAction, + createDisplayModeAction, + createGeneralAction, + createLogAction, + createMediaPlayerAction, + createPTZDigitalAction, + createPTZMultiAction, + createPTZControlsAction, frigateCardHasAction, getActionConfigGivenAction, stopEventFromActivatingCardWideActions, + createPTZAction, } from '../../src/utils/action'; -import { createHASS } from '../test-utils'; vi.mock('@dermotduffy/custom-card-helpers'); describe('convertActionToFrigateCardCustomAction', () => { it('should skip null action', () => { - expect(convertActionToFrigateCardCustomAction(null)).toBeFalsy(); + expect(convertActionToCardCustomAction(null)).toBeFalsy(); }); it('should parse valid', () => { expect( - convertActionToFrigateCardCustomAction({ + convertActionToCardCustomAction({ action: 'custom:frigate-card-action', frigate_card_action: 'download', }), @@ -38,14 +38,14 @@ describe('convertActionToFrigateCardCustomAction', () => { }); it('should not parse invalid', () => { - expect(convertActionToFrigateCardCustomAction('this is garbage')).toBeNull(); + expect(convertActionToCardCustomAction('this is garbage')).toBeNull(); }); }); -describe('createFrigateCardSimpleAction', () => { +describe('createGeneralAction', () => { it('should create general action', () => { expect( - createFrigateCardSimpleAction('clips', { + createGeneralAction('clips', { cardID: 'card_id', }), ).toEqual({ @@ -56,23 +56,23 @@ describe('createFrigateCardSimpleAction', () => { }); }); -describe('createFrigateCardCameraAction', () => { +describe('createCameraAction', () => { it('should create camera_select', () => { - expect( - createFrigateCardCameraAction('camera_select', 'camera', { cardID: 'card_id' }), - ).toEqual({ - action: 'fire-dom-event', - camera: 'camera', - frigate_card_action: 'camera_select', - card_id: 'card_id', - }); + expect(createCameraAction('camera_select', 'camera', { cardID: 'card_id' })).toEqual( + { + action: 'fire-dom-event', + camera: 'camera', + frigate_card_action: 'camera_select', + card_id: 'card_id', + }, + ); }); }); -describe('createFrigateCardMediaPlayerAction', () => { +describe('createMediaPlayerAction', () => { it('should create media_player', () => { expect( - createFrigateCardMediaPlayerAction('device', 'play', { + createMediaPlayerAction('device', 'play', { cardID: 'card_id', }), ).toEqual({ @@ -85,10 +85,10 @@ describe('createFrigateCardMediaPlayerAction', () => { }); }); -describe('createFrigateCardDisplayModeAction', () => { +describe('createDisplayModeAction', () => { it('should create display mode action', () => { expect( - createFrigateCardDisplayModeAction('grid', { + createDisplayModeAction('grid', { cardID: 'card_id', }), ).toEqual({ @@ -100,49 +100,143 @@ describe('createFrigateCardDisplayModeAction', () => { }); }); -describe('createFrigateCardShowPTZAction', () => { - it('should create show PTZ action', () => { +describe('createPTZControlsAction', () => { + it('should create PTZ controls action', () => { expect( - createFrigateCardShowPTZAction(true, { + createPTZControlsAction(true, { cardID: 'card_id', }), ).toEqual({ action: 'fire-dom-event', - frigate_card_action: 'show_ptz', - show_ptz: true, + frigate_card_action: 'ptz_controls', + enabled: true, card_id: 'card_id', }); }); }); -describe('createFrigateCardChangeZoomAction', () => { - it('should create change zoom default action', () => { +describe('createPTZAction', () => { + it('should create ptz action without parameters', () => { expect( - createFrigateCardChangeZoomAction('target_id', { + createPTZAction({ cardID: 'card_id', }), ).toEqual({ action: 'fire-dom-event', - frigate_card_action: 'change_zoom', + frigate_card_action: 'ptz', card_id: 'card_id', - target_id: 'target_id', }); }); - it('should create change zoom specific action', () => { + it('should create ptz action with parameters', () => { expect( - createFrigateCardChangeZoomAction('target_id', { + createPTZAction({ cardID: 'card_id', - pan: { x: 1, y: 2 }, - zoom: 3, + ptzAction: 'right', + ptzPhase: 'start', + ptzPreset: 'preset', + cameraID: 'camera_id', }), ).toEqual({ action: 'fire-dom-event', - frigate_card_action: 'change_zoom', + frigate_card_action: 'ptz', + card_id: 'card_id', + camera: 'camera_id', + ptz_action: 'right', + ptz_phase: 'start', + ptz_preset: 'preset', + }); + }); +}); + +describe('createPTZDigitalAction', () => { + it('should create ptz digital without parameters', () => { + expect( + createPTZDigitalAction({ + cardID: 'card_id', + }), + ).toEqual({ + action: 'fire-dom-event', + frigate_card_action: 'ptz_digital', + card_id: 'card_id', + }); + }); + + it('should create ptz digital with parameters', () => { + expect( + createPTZDigitalAction({ + cardID: 'card_id', + targetID: 'target_id', + absolute: { + pan: { x: 1, y: 2 }, + zoom: 3, + }, + ptzAction: 'right', + ptzPhase: 'start', + }), + ).toEqual({ + action: 'fire-dom-event', + frigate_card_action: 'ptz_digital', card_id: 'card_id', target_id: 'target_id', - pan: { x: 1, y: 2 }, - zoom: 3, + absolute: { + pan: { x: 1, y: 2 }, + zoom: 3, + }, + ptz_action: 'right', + ptz_phase: 'start', + }); + }); +}); + +describe('createPTZMultiAction', () => { + it('should create ptz multi with parameters', () => { + expect( + createPTZMultiAction({ + cardID: 'card_id', + ptzAction: 'right', + ptzPreset: 'preset', + }), + ).toEqual({ + action: 'fire-dom-event', + frigate_card_action: 'ptz_multi', + card_id: 'card_id', + ptz_action: 'right', + ptz_preset: 'preset', + }); + }); + + it('should create ptz multi without parameters', () => { + expect( + createPTZMultiAction({ + cardID: 'card_id', + ptzAction: 'right', + ptzPhase: 'start', + targetID: 'target_id', + }), + ).toEqual({ + action: 'fire-dom-event', + frigate_card_action: 'ptz_multi', + card_id: 'card_id', + ptz_action: 'right', + ptz_phase: 'start', + target_id: 'target_id', + }); + }); +}); + +describe('createLogAction', () => { + it('should create log action', () => { + expect( + createLogAction('Hello, world!', { + cardID: 'card_id', + }), + ).toEqual({ + action: 'fire-dom-event', + frigate_card_action: 'log', + message: 'Hello, world!', + card_id: 'card_id', + level: 'info', }); }); }); @@ -190,61 +284,6 @@ describe('getActionConfigGivenAction', () => { }); }); -// @vitest-environment jsdom -describe('frigateCardHandleActionConfig', () => { - const element = document.createElement('div'); - const action = actionSchema.parse({ - action: 'none', - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it('should not handle missing arguments', () => { - expect( - frigateCardHandleActionConfig(element, createHASS(), {}, 'triple_poke'), - ).toBeFalsy(); - }); - - it('should handle simple case', () => { - const hass = createHASS(); - frigateCardHandleActionConfig(element, hass, {}, 'tap', action); - expect(handleActionConfig).toBeCalled(); - expect(handleActionConfig).toBeCalledWith(element, hass, {}, action); - }); - - it('should handle array case', () => { - const hass = createHASS(); - frigateCardHandleActionConfig(element, hass, {}, 'tap', [action, action, action]); - expect(handleActionConfig).toBeCalledTimes(3); - expect(handleActionConfig).toBeCalledWith(element, hass, {}, action); - }); - - it('should handle null case', () => { - const hass = createHASS(); - frigateCardHandleActionConfig(element, hass, {}, 'tap', null); - expect(handleActionConfig).toBeCalledWith(element, hass, {}, undefined); - }); -}); - -// @vitest-environment jsdom -describe('frigateCardHandleAction', () => { - const element = document.createElement('div'); - const action = actionSchema.parse({ - action: 'none', - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it('should call action handler', () => { - frigateCardHandleAction(element, createHASS(), {}, action); - expect(handleActionConfig).toBeCalled(); - }); -}); - describe('frigateCardHasAction', () => { const action = actionSchema.parse({ action: 'toggle', diff --git a/tests/utils/basic.test.ts b/tests/utils/basic.test.ts index 50fc0e60..79188997 100644 --- a/tests/utils/basic.test.ts +++ b/tests/utils/basic.test.ts @@ -22,6 +22,7 @@ import { isTruthy, isValidDate, prettifyTitle, + recursivelyMergeObjectsConcatenatingArraysUniquely, recursivelyMergeObjectsNotArrays, runWhenIdleIfSupported, setify, @@ -345,6 +346,53 @@ describe('recursivelyMergeObjectsNotArrays', () => { }); }); +describe('recursivelyMergeObjectsConcatenatingArraysUniquely', () => { + it('should recursively merge objects but uniquely concat arrays', () => { + expect( + recursivelyMergeObjectsConcatenatingArraysUniquely( + {}, + { + a: { + b: { + c: 3, + d: { + e: 4, + }, + array: [5, 1, 2, 3, 4], + }, + other: { + field: 7, + }, + }, + }, + { + a: { + b: { + array: [4, 4, 5], + d: { + e: 5, + }, + }, + }, + }, + ), + ).toEqual({ + a: { + b: { + c: 3, + array: [5, 1, 2, 3, 4], + d: { + e: 5, + }, + }, + other: { + field: 7, + }, + }, + }); + }); +}); + describe('aspectRatioToStyle', () => { it('default', () => { expect(aspectRatioToStyle()).toEqual({ 'aspect-ratio': 'auto' }); diff --git a/tests/utils/ptz.test.ts b/tests/utils/ptz.test.ts index 272882e5..de085886 100644 --- a/tests/utils/ptz.test.ts +++ b/tests/utils/ptz.test.ts @@ -1,38 +1,154 @@ -import { describe, expect, it } from 'vitest'; -import { FrigateCardPTZConfig, frigateCardPTZSchema } from '../../src/config/types'; -import { hasUsablePTZ } from '../../src/utils/ptz'; -import { createCapabilities } from '../test-utils'; +import { describe, expect, it, vi } from 'vitest'; +import { Capabilities } from '../../src/camera-manager/capabilities'; +import { + getPTZTarget, + hasCameraTruePTZ, + ptzActionToCapabilityKey, +} from '../../src/utils/ptz'; +import { + TestViewMedia, + createCameraManager, + createStore, + createView, +} from '../test-utils'; +import { MediaQueriesResults } from '../../src/view/media-queries-results'; +import { FrigateCardView } from '../../src/config/types'; -const createPTZConfig = ( - config?: Partial, -): FrigateCardPTZConfig => { - return frigateCardPTZSchema.parse(config ?? {}); -}; +describe('getPTZTarget', () => { + describe('in a viewer view', () => { + it('with media', () => { + const media = [new TestViewMedia({ id: 'media-id' })]; + const view = createView({ + view: 'media', + queryResults: new MediaQueriesResults({ results: media, selectedIndex: 0 }), + }); + expect(getPTZTarget(view, { cameraManager: createCameraManager() })).toEqual({ + targetID: 'media-id', + type: 'digital', + }); + }); -describe('hasUsablePTZ', () => { - it('should return true with manual actions', () => { - expect( - hasUsablePTZ( - createCapabilities(), - createPTZConfig({ - actions_left: {}, - }), - ), - ).toBeTruthy(); + it('without media', () => { + const view = createView({ + view: 'media', + }); + expect(getPTZTarget(view, { cameraManager: createCameraManager() })).toBeNull(); + }); + + it('with true PTZ restriction', () => { + const media = [new TestViewMedia({ id: 'media-id' })]; + const view = createView({ + view: 'media', + queryResults: new MediaQueriesResults({ results: media, selectedIndex: 0 }), + }); + expect( + getPTZTarget(view, { type: 'ptz', cameraManager: createCameraManager() }), + ).toBeNull(); + }); }); - it('should return true with capabilities', () => { - expect( - hasUsablePTZ( - createCapabilities({ - ptz: { - panTilt: ['continuous'], - }, + + describe('in live view', () => { + it('without restriction with true PTZ capability', () => { + const view = createView({ + view: 'live', + camera: 'camera-1', + }); + const store = createStore([ + { + cameraID: 'camera-1', + capabilities: new Capabilities({ ptz: { left: ['relative'] } }), + }, + ]); + + expect(getPTZTarget(view, { cameraManager: createCameraManager(store) })).toEqual({ + targetID: 'camera-1', + type: 'ptz', + }); + }); + + it('without restriction without true PTZ capability', () => { + const view = createView({ + view: 'live', + camera: 'camera-1', + }); + + expect(getPTZTarget(view, { cameraManager: createCameraManager() })).toEqual({ + targetID: 'camera-1', + type: 'digital', + }); + }); + + it('with truePTZ restriction without true PTZ capability', () => { + const view = createView({ + view: 'live', + camera: 'camera-1', + }); + + expect( + getPTZTarget(view, { type: 'ptz', cameraManager: createCameraManager() }), + ).toBeNull(); + }); + + it('with digitalPTZ restriction with true PTZ capability', () => { + const view = createView({ + view: 'live', + camera: 'camera-1', + }); + const store = createStore([ + { + cameraID: 'camera-1', + capabilities: new Capabilities({ ptz: { left: ['relative'] } }), + }, + ]); + + expect( + getPTZTarget(view, { + type: 'digital', + cameraManager: createCameraManager(store), }), - createPTZConfig(), - ), - ).toBeTruthy(); + ).toEqual({ + targetID: 'camera-1', + type: 'digital', + }); + }); }); - it('should return false with manual actions or capabilities', () => { - expect(hasUsablePTZ(createCapabilities(), createPTZConfig())).toBeFalsy(); + + describe('in non-media views', () => { + it.each([['timeline' as const], ['diagnostics' as const]])( + '%s', + (viewName: FrigateCardView) => { + const view = createView({ + view: viewName, + }); + expect(getPTZTarget(view, { cameraManager: createCameraManager() })).toBeNull(); + }, + ); }); }); + +describe('hasCameraTruePTZ', () => { + it('with true PTZ', () => { + const store = createStore([ + { + cameraID: 'camera-1', + capabilities: new Capabilities({ ptz: { left: ['relative'] } }), + }, + ]); + + expect(hasCameraTruePTZ(createCameraManager(store), 'camera-1')).toBeTruthy(); + }); + + it('without true PTZ', () => { + expect(hasCameraTruePTZ(createCameraManager(createStore()), 'camera-1')).toBeFalsy(); + }); +}); + +it('ptzActionToCapabilityKey', () => { + expect(ptzActionToCapabilityKey('left')).toBe('left'); + expect(ptzActionToCapabilityKey('right')).toBe('right'); + expect(ptzActionToCapabilityKey('up')).toBe('up'); + expect(ptzActionToCapabilityKey('down')).toBe('down'); + expect(ptzActionToCapabilityKey('zoom_in')).toBe('zoomIn'); + expect(ptzActionToCapabilityKey('zoom_out')).toBe('zoomOut'); + expect(ptzActionToCapabilityKey('preset')).toBeNull(); +}); diff --git a/tests/utils/substream.test.ts b/tests/utils/substream.test.ts index 873dfe1b..b36acfe4 100644 --- a/tests/utils/substream.test.ts +++ b/tests/utils/substream.test.ts @@ -37,4 +37,17 @@ describe('hasSubstream/getStreamCameraID', () => { expect(hasSubstream(view)).toBeFalsy(); expect(getStreamCameraID(view)).toBe('camera'); }); + it('should respect cameraID override', () => { + const view = new View({ + view: 'live', + camera: 'camera', + context: { + live: { + overrides: new Map([['camera', 'camera2'], ['camera3', 'camera4']]), + }, + }, + }); + expect(hasSubstream(view)).toBeTruthy(); + expect(getStreamCameraID(view, 'camera3')).toBe('camera4'); + }); }); diff --git a/tsconfig.json b/tsconfig.json index 0b7a905d..9218fb69 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "moduleResolution": "node", "lib": ["es2021", "dom", "dom.iterable"], "noEmit": true, + "noErrorTruncation": true, "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, @@ -22,12 +23,13 @@ // These tags are provided by the HA frontend and [almost all] cannot be // imported by a custom card directly. "globalTags": [ - "ha-card", - "ha-combo-box", - "ha-icon", - "ha-icon-button", "ha-button-menu", + "ha-button", + "ha-card", "ha-circular-progress", + "ha-combo-box", + "ha-icon-button", + "ha-icon", "ha-selector", "ha-svg-icon", "mwc-button", diff --git a/vite.config.ts b/vite.config.ts index 25a3f0be..74dd7e43 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,13 +11,14 @@ const FULL_COVERAGE_FILES_RELATIVE = [ 'camera-manager/generic/*.ts', 'camera-manager/motioneye/icon.ts', 'camera-manager/utils/*.ts', - 'card-controller/*.ts', + 'card-controller/**/*.ts', 'components-lib/cached-value-controller.ts', + 'components-lib/key-assigner-controller.ts', 'components-lib/live/live-controller.ts', 'components-lib/media-filter-controller.ts', 'components-lib/menu-button-controller.ts', 'components-lib/menu-controller.ts', - 'components-lib/ptz-controller.ts', + 'components-lib/ptz/*.ts', 'components-lib/zoom/*.ts', 'config/**/*.ts', 'const.ts', diff --git a/yarn.lock b/yarn.lock index 2be981ac..e6cd31e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1477,9 +1477,9 @@ __metadata: languageName: node linkType: hard -"@vitest/coverage-istanbul@npm:^1.5.0": - version: 1.5.0 - resolution: "@vitest/coverage-istanbul@npm:1.5.0" +"@vitest/coverage-istanbul@npm:^1.6.0": + version: 1.6.0 + resolution: "@vitest/coverage-istanbul@npm:1.6.0" dependencies: debug: "npm:^4.3.4" istanbul-lib-coverage: "npm:^3.2.2" @@ -1491,8 +1491,8 @@ __metadata: picocolors: "npm:^1.0.0" test-exclude: "npm:^6.0.0" peerDependencies: - vitest: 1.5.0 - checksum: 10c0/12abbaf8925c797e1fafef05c8b7d554c9fc61d13eb70c3f8532ade72db305a3cb1fec9bb7642763de27471e3f3da3d684d6533aa954756c5edf2707d2fc25cd + vitest: 1.6.0 + checksum: 10c0/42524de58d24101b50da048214b8e760c46f32760e371c700704a67ace2828d9d12b6d87720139c8a7e17fbce40dfcaf47296605c85533cc78b9c9f73c50e3d2 languageName: node linkType: hard @@ -3843,7 +3843,7 @@ __metadata: "@types/masonry-layout": "npm:^4.2.5" "@typescript-eslint/eslint-plugin": "npm:^7.12.0" "@typescript-eslint/parser": "npm:^7.12.0" - "@vitest/coverage-istanbul": "npm:^1.5.0" + "@vitest/coverage-istanbul": "npm:^1.6.0" component-emitter: "npm:^1.3.0" crypto: "npm:^1.0.1" date-fns: "npm:^3.6.0" @@ -3879,6 +3879,7 @@ __metadata: side-drawer: "npm:^3.1.0" ts-prune: "npm:^0.10.3" typescript: "npm:^5.4.5" + type-fest: "npm:^4.18.0" uuid: "npm:^8.3.2" vis-data: "npm:^7.1.9" vis-timeline: "npm:^7.7.3" @@ -8178,6 +8179,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^4.18.0": + version: 4.18.0 + resolution: "type-fest@npm:4.18.0" + checksum: 10c0/d16d9be71ceeb5341cfa7e21d9081e843e74791fb60b5e2555caa26c259c9bcbffd965e1a1c8e571856efddb317b4b1802f73938723f399f1070e9db5b182f4e + languageName: node + linkType: hard + "typed-array-buffer@npm:^1.0.2": version: 1.0.2 resolution: "typed-array-buffer@npm:1.0.2"