Add initial keyboard shortcut support.

This commit is contained in:
Dermot Duffy
2024-06-05 21:44:17 -07:00
parent 904f6d8142
commit 7ab545738d
186 changed files with 9564 additions and 3658 deletions
+1 -1
View File
@@ -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. |
+16 -16
View File
@@ -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)
-553
View File
@@ -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
```
+73
View File
@@ -0,0 +1,73 @@
# `actions`
## Introduction to actions <!-- {docsify-ignore} -->
`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 <!-- {docsify-ignore} -->
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 <!-- {docsify-ignore} -->
| Option | Description |
| ------------------------------------ | ----------------------------------- |
| [Custom Actions](./custom/README.md) | Custom actions to control the card. |
| [Stock Actions](./stock/README.md) | Standard Home Assistant actions. |
+27
View File
@@ -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)
+737
View File
@@ -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
```
@@ -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)
+120
View File
@@ -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
```
@@ -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)
+3 -3
View File
@@ -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
+116 -1
View File
@@ -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
```
+18 -18
View File
@@ -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)
+27 -2
View File
@@ -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
+4 -4
View File
@@ -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')
+2 -4
View File
@@ -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:
+36 -49
View File
@@ -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 &gt;= `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
+1 -1
View File
@@ -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`
+34 -1
View File
@@ -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 &gt;= `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
+10 -10
View File
@@ -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
+37 -1
View File
@@ -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:
+47 -2
View File
@@ -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
+4 -3
View File
@@ -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)
---
+18
View File
@@ -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. |
+3 -3
View File
@@ -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: | |
+2 -1
View File
@@ -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"
},
+14 -4
View File
@@ -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);
}
}
@@ -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,
+1 -1
View File
@@ -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<void>;
+12
View File
@@ -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;
}
+3 -2
View File
@@ -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<void>;
+27 -7
View File
@@ -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<void> {
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 }),
};
}
+7 -6
View File
@@ -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<void> {
+6 -3
View File
@@ -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<void> {
+18 -8
View File
@@ -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<void> {
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);
}
}
+82
View File
@@ -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;
};
-285
View File
@@ -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<typeof interactionSchema>;
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<void> {
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}`);
}
}
}
@@ -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<typeof interactionSchema>;
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<ActionExecutionRequest>,
): Promise<void> => {
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<void> {
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);
}
}
@@ -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<T> 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<void> {
// Pass.
}
public async stop(): Promise<void> {
// Pass.
}
}
export class FrigateCardAction<
T extends FrigateCardCustomAction,
> extends BaseAction<T> {}
@@ -0,0 +1,26 @@
import { CameraSelectActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class CameraSelectAction extends FrigateCardAction<CameraSelectActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
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,
});
}
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class CameraUIAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getCameraURLManager().openURL();
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class DefaultAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getViewManager().setViewDefault();
}
}
@@ -0,0 +1,9 @@
import { DisplayModeActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class DisplayModeSelectAction extends FrigateCardAction<DisplayModeActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await api.getViewManager().setViewWithNewDisplayMode(this._action.display_mode);
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class DownloadAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await api.getDownloadManager().downloadViewerMedia();
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class ExpandAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getExpandManager().toggleExpanded();
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class FullscreenAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getFullscreenManager().toggleFullscreen();
}
}
@@ -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<ActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
const hass = api.getHASSManager().getHASS();
if (hass) {
handleActionConfig(
api.getCardElementManager().getElement(),
hass,
this._config ?? {},
this._action,
);
}
}
}
@@ -0,0 +1,10 @@
import { LogActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class LogAction extends FrigateCardAction<LogActionConfig> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async execute(_api: CardActionsAPI): Promise<void> {
console[this._action.level](this._action.message);
}
}
@@ -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<MediaPlayerActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
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);
}
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class MenuToggleAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getCardElementManager().toggleMenu();
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class MicrophoneMuteAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getMicrophoneManager().mute();
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class MicrophoneUnmuteAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await api.getMicrophoneManager().unmute();
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class MuteAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await api.getMediaLoadedInfoManager().get()?.player?.mute();
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class PauseAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await api.getMediaLoadedInfoManager().get()?.player?.pause();
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class PlayAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await api.getMediaLoadedInfoManager().get()?.player?.play();
}
}
@@ -0,0 +1,11 @@
import { PTZControlsActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class PTZControlsAction extends FrigateCardAction<PTZControlsActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getViewManager().setViewWithMergedContext({
ptzControls: { enabled: this._action.enabled },
});
}
}
@@ -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<PTZDigitialActionConfig> {
protected _timer = new Timer();
protected async _stepChange(api: CardActionsAPI, targetID: string): Promise<void> {
api.getViewManager().setViewWithMergedContext(
generateViewContextForZoom(targetID, {
requested: this._convertActionToZoomSettings(
api.getViewManager().getView()?.context?.zoom?.[targetID]?.observed,
),
}),
);
}
public async stop(): Promise<void> {
this._timer.stop();
}
public async execute(api: CardActionsAPI): Promise<void> {
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),
},
};
}
}
@@ -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<PTZMultiActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
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,
);
}
}
+121
View File
@@ -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<PTZActionConfig> {
protected _timer = new Timer();
public async stop(): Promise<void> {
this._timer.stop();
}
public async execute(api: CardActionsAPI): Promise<void> {
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<void> => {
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',
}));
});
}
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class ScreenshotAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await api.getDownloadManager().downloadScreenshot();
}
}
@@ -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<void> {
for (const action of this._actions) {
if (this._stopped) {
break;
}
await action.execute(api);
}
}
public async stop(): Promise<void> {
this._stopped = true;
}
}
@@ -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<SleepActionConfig> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async execute(_api: CardActionsAPI): Promise<void> {
await sleep(timeDeltaToSeconds(this._action.duration));
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class SubstreamOffAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getViewManager().setViewWithoutSubstream();
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class SubstreamOnAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getViewManager().setViewWithSubstream();
}
}
@@ -0,0 +1,9 @@
import { SubstreamSelectActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class SubstreamSelectAction extends FrigateCardAction<SubstreamSelectActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getViewManager().setViewWithSubstream(this._action.camera);
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class UnmuteAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await api.getMediaLoadedInfoManager().get()?.player?.unmute();
}
}
@@ -0,0 +1,16 @@
import { ViewActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class ViewAction extends FrigateCardAction<ViewActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
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,
});
}
}
+133
View File
@@ -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;
}
}
+23
View File
@@ -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<void>;
stop(): Promise<void>;
}
export interface ActionExecutionRequest {
action: ActionType[] | ActionType;
config?: AuxillaryActionConfig;
}
export interface TargetedActionContext {
[targetID: string]: {
inProgressAction?: Action;
};
}
@@ -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,
},
},
});
};
@@ -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<ActionExecutionRequest>,
) => 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<ActionExecutionRequest>,
) => void,
options?: boolean | EventListenerOptions,
): void;
removeEventListener(
type: string,
callback: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
): void;
}
@@ -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
);
};
+27 -26
View File
@@ -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<Automation, boolean> = 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<void> => {
++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);
}
}
+21 -8
View File
@@ -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,9 +63,11 @@ 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();
@@ -74,7 +77,7 @@ export class CardElementManager {
);
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',
+19 -2
View File
@@ -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)
);
}
}
@@ -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();
}
@@ -0,0 +1,8 @@
import { CardConfigLoaderAPI } from '../types';
export const setAutomationsFromConfig = (api: CardConfigLoaderAPI) => {
api.getAutomationsManager().deleteAutomations();
api
.getAutomationsManager()
.addAutomations(api.getConfigManager().getNonOverriddenConfig()?.automations ?? []);
};
@@ -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;
};
+10 -2
View File
@@ -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;
}
@@ -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 });
}
}
+11 -13
View File
@@ -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':
+47 -9
View File
@@ -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[];
+1
View File
@@ -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() : ''}
<div ${ref(this._refMain)} class="${classMap(mainClasses)}">
@@ -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);
}
}
@@ -20,7 +20,6 @@ interface LiveViewContext {
// camera to be live rather than the camera selected in the view).
overrides?: Map<string, string>;
ptzVisible?: boolean;
fetchThumbnails?: boolean;
}
+78 -89
View File
@@ -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,
};
}
+7 -5
View File
@@ -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';
}
}
-170
View File
@@ -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,
};
}
}
+145
View File
@@ -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;
}
}
+21
View File
@@ -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;
}
+24 -6
View File
@@ -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<ZoomSettingsBase>;
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
);
};
+83 -51
View File
@@ -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 = (<CustomEvent<PanzoomEventDetail>>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.
+19 -20
View File
@@ -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<ZoomDefault>,
ev: CustomEvent<ZoomSettingsObserved>,
targetID?: string,
): void => {
targetID && dispatchViewContextChangeEvent(
element,
generateViewContextForZoomChange(targetID, {
isDefault: ev.detail.isDefault,
}),
);
targetID &&
dispatchViewContextChangeEvent(
element,
generateViewContextForZoom(targetID, {
observed: ev.detail,
}),
);
};
+38 -62
View File
@@ -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` <frigate-card-zoomer
.defaultConfig=${guard([this.cameraConfig?.dimensions?.layout], () =>
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<ZoomDefault>) =>
handleZoomDefaultEvent(this, ev, targetID)}
>
${template}
</frigate-card-zoomer>`
: 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` <img
${ref(this._refImage)}
src=${live(src)}
@load=${(ev: Event) => {
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`
<img
${ref(this._refImage)}
src=${live(src)}
@load=${(ev: Event) => {
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``;
}
+93
View File
@@ -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`<div class="key">
<div class="key-inner">${key}</div>
</div>`;
};
return html`
<div class="label">${this.label}</div>
<ha-button
class="assign"
@click=${() => {
this._controller.toggleAssigning();
}}
>
<ha-icon icon="mdi:keyboard-settings"></ha-icon>
<span class="${classMap({
dotdotdot: this._controller.isAssigning(),
})}">
${
this._controller.isAssigning()
? localize('key_assigner.assigning')
: localize('key_assigner.assign')
}
</span>
</ha-button>
${
this._controller.hasValue()
? html`<ha-button
@click=${() => {
this._controller.setValue(null);
}}
>
<ha-icon icon="mdi:keyboard-off"></ha-icon>
<span> ${localize('key_assigner.unassign')} </span>
</ha-button>`
: ''
}
<div class="key-row">
${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) : ''}
</div>
</span>`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(keyAssignerStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-key-assigner': FrigateCardKeyAssigner;
}
}
+27 -23
View File
@@ -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<ZoomDefault>) =>
handleZoomDefaultEvent(this, ev, cameraID)}
.zoomSettings=${this.view?.context?.zoom?.[cameraID]?.requested}
@frigate-card:zoom:change=${(ev: CustomEvent<ZoomSettingsObserved>) =>
handleZoomSettingsObservedEvent(this, ev, cameraID)}
>
</frigate-card-live-provider>
</div>
@@ -633,11 +637,11 @@ export class FrigateCardLiveCarousel extends LitElement {
</frigate-card-next-previous-control>
</frigate-card-carousel>
<frigate-card-ptz
.hass=${this.hass}
.config=${this.overriddenLiveConfig.controls.ptz}
.cameraManager=${this.cameraManager}
.cameraID=${cameraID}
.forceVisibility=${this._mediaHasLoaded && this.view.context?.live?.ptzVisible}
.cameraID=${getStreamCameraID(this.view, cameraID)}
.forceVisibility=${this._mediaHasLoaded &&
this.view.context?.ptzControls?.enabled}
>
</frigate-card-ptz>
${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` <frigate-card-zoomer
.defaultConfig=${guard([this.cameraConfig?.dimensions?.layout], () =>
.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
</frigate-card-live-ha>`
: provider === 'go2rtc'
? html`<frigate-card-live-go2rtc
${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)}
>
</frigate-card-live-webrtc-card>`
${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)}
>
</frigate-card-live-go2rtc>`
: provider === 'webrtc-card'
? html`<frigate-card-live-webrtc-card
${ref(this._refProvider)}
@@ -995,7 +999,7 @@ export class FrigateCardLiveProvider
declare global {
interface HTMLElementTagNameMap {
FRIGATE_CARD_LIVE_PROVIDER: FrigateCardLiveProvider;
'frigate-card-live-provider': FrigateCardLiveProvider;
'frigate-card-live-carousel': FrigateCardLiveCarousel;
'frigate-card-live-grid': FrigateCardLiveGrid;
'frigate-card-live': FrigateCardLive;
+5 -10
View File
@@ -46,7 +46,7 @@ export class FrigateCardMenu extends LitElement {
return html` <frigate-card-submenu
.hass=${this.hass}
.submenu=${button}
@action=${(ev) => this.hass && this._controller.actionHandler(this.hass, ev)}
@action=${(ev) => this._controller.actionHandler(ev)}
>
</frigate-card-submenu>`;
} 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)}
>
</frigate-card-submenu-select>`;
}
@@ -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`<ha-svg-icon .path="${svgPath}"></ha-svg-icon>`
@@ -106,17 +105,13 @@ export class FrigateCardMenu extends LitElement {
return html` <div
class="matching"
style="${styleMap({
flex: String(matchingButtons.length),
})}"
style="${styleMap({ flex: String(matchingButtons.length) })}"
>
${matchingButtons.map((button) => this._renderButton(button))}
</div>
<div
class="opposing"
style="${styleMap({
flex: String(opposingButtons.length),
})}"
style="${styleMap({ flex: String(opposingButtons.length) })}"
>
${opposingButtons.map((button) => this._renderButton(button))}
</div>`;
+38 -52
View File
@@ -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`<ha-icon
class=${classMap(classes)}
icon=${icon}
.actionHandler=${actionHandler({
hasHold: frigateCardHasAction(actions?.hold_action),
hasDoubleClick: frigateCardHasAction(actions?.double_tap_action),
})}
.title=${localize(`elements.ptz.${name}`)}
@action=${(ev: HASSDomEvent<{ action: string }>) =>
this._controller.handleAction(ev, actions)}
></ha-icon>`;
return actions
? html`<ha-icon
class=${classMap(classes)}
icon=${icon}
.actionHandler=${actionHandler({
hasHold: frigateCardHasAction(actions?.hold_action),
hasDoubleClick: frigateCardHasAction(actions?.double_tap_action),
})}
.title=${localize(`elements.ptz.${name}`)}
@action=${(ev: HASSDomEvent<{ action: string }>) =>
this._controller.handleAction(ev, actions)}
></ha-icon>`
: 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` <div class="ptz">
${!config?.hide_pan_tilt
${!config?.hide_pan_tilt && this._actionPresence?.pt
? html`<div class="ptz-move">
${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)}
</div>`
: ''}
${!config?.hide_zoom && (actionsZoomIn || actionsZoomOut)
${!config?.hide_zoom && this._actionPresence?.z
? html` <div class="ptz-zoom">
${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)}
</div>`
: html``}
${!config?.hide_home && actionsHome
? html`
<div class="ptz-home">${renderIcon('home', 'mdi:home', actionsHome)}</div>
`
${!config?.hide_home && this._actionPresence?.home
? html`<div class="ptz-home">
${renderIcon('home', 'mdi:home', this._actions.home)}
</div>`
: html``}
</div>`;
}
+17 -7
View File
@@ -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 {
}}
></frigate-card-next-previous-control>
</frigate-card-carousel>
${this.view
? html` <frigate-card-ptz
.config=${this.viewerConfig?.controls.ptz}
.forceVisibility=${this.view?.context?.ptzControls?.enabled}
>
</frigate-card-ptz>`
: ''}
<div class="seek-warning">
<ha-icon title="${localize('media_viewer.unseekable')}" icon="mdi:clock-remove">
</ha-icon>
@@ -876,7 +884,7 @@ export class FrigateCardViewerProvider
return this.viewerConfig?.zoomable
? html` <frigate-card-zoomer
.defaultConfig=${guard([cameraConfig?.dimensions?.layout], () =>
.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<ZoomDefault>) =>
handleZoomDefaultEvent(this, ev, mediaID)}
@frigate-card:zoom:change=${(ev: CustomEvent<ZoomSettingsObserved>) =>
handleZoomSettingsObservedEvent(this, ev, mediaID)}
>
${template}
</frigate-card-zoomer>`
@@ -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;
}
}
+1 -1
View File
@@ -171,7 +171,7 @@ export class FrigateCardViews extends LitElement {
.view=${this.view}
.hass=${this.hass}
.cameraConfig=${cameraConfig}
.supportZoom=${true}
.cameraManager=${this.cameraManager}
>
</frigate-card-image>`
: ``}
+9 -9
View File
@@ -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();
}

Some files were not shown because too many files have changed in this diff Show More