Allow camera engines to signal events to the card.
This commit is contained in:
Vendored
+1
-1
@@ -9,6 +9,6 @@
|
||||
"davidanson.vscode-markdownlint",
|
||||
"redhat.vscode-yaml",
|
||||
"lokalise.i18n-ally",
|
||||
"zixuanchen.vitest-explorer"
|
||||
"vitest.explorer"
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+1
-3
@@ -4,7 +4,5 @@
|
||||
"i18n-ally.sortKeys": true,
|
||||
"i18n-ally.keepFulfilled": true,
|
||||
"i18n-ally.editor.preferEditor": true,
|
||||
"i18n-ally.translate.saveAsCandidates": true,
|
||||
"vitest.commandLine": "npx vitest --root .",
|
||||
"diffEditor.experimental.useVersion2": true
|
||||
"i18n-ally.translate.saveAsCandidates": true
|
||||
}
|
||||
|
||||
@@ -167,11 +167,11 @@ See the [fully expanded cameras configuration example](#config-expanded-cameras)
|
||||
|
||||
##### Engine Capabilities
|
||||
|
||||
|Engine|Live|Supports clips|Supports Snapshots|Supports Recordings|Supports Timeline|Supports PTZ out of the box|Supports manually configured PTZ|Favorite events|Favorite recordings|
|
||||
| - | - | - | - | - | - | - | - | - | - |
|
||||
|`frigate`| :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: |
|
||||
|`generic`| :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: |
|
||||
|`motioneye`| :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: |
|
||||
|Engine|Live|Supports clips|Supports Snapshots|Supports Recordings|Supports Timeline|Supports PTZ out of the box|Supports manually configured PTZ|Favorite events|Favorite recordings|Detect new events|Detect new snapshots|Detect new clips|
|
||||
| - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||
|`frigate`| :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
|`generic`| :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: |
|
||||
|`motioneye`| :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: |
|
||||
|
||||
##### Live providers supported per Engine
|
||||
|
||||
@@ -320,7 +320,7 @@ cameras:
|
||||
|
||||
#### Camera Trigger Configuration
|
||||
|
||||
The `triggers` block configures what triggers a camera. Triggering can be used to either reset to the default view / update the card, or active the camera in [scan mode](#scan-mode).
|
||||
The `triggers` block configures what triggers a camera. Triggering can be used to activate an action (e.g. view a camera in live, reset the card to the default view). See [`view.triggers`](#view.triggers) to control what happens when a camera is triggered.
|
||||
|
||||
```yaml
|
||||
cameras:
|
||||
@@ -329,8 +329,9 @@ cameras:
|
||||
|
||||
| Option | Default | Overridable | Description |
|
||||
| - | - | - | - |
|
||||
| `events` | `[events, clips, snapshots]` | :white_check_mark: | Whether to trigger the camera when `events` occur (whether or not media is available) or whenever updated `clips` or `snapshots` are detected. Detection support varies by camera engine, see [engine capabilities](#engines). |
|
||||
| `motion` | `false` | :white_check_mark: | Whether to not to trigger the camera by automatically detecting and using the motion `binary_sensor` for this camera. This autodetection only works for Frigate cameras, and only when the motion `binary_sensor` entity has been enabled in Home Assistant.|
|
||||
| `occupancy` | `true` | :white_check_mark: | Whether to not to trigger the camera by automatically detecting and using the occupancy `binary_sensor` for this camera and its configured zones and labels. This autodetection only works for Frigate cameras, and only when the occupancy `binary_sensor` entity has been enabled in Home Assistant. If this camera has configured zones, only occupancy sensors for those zones are used -- if the overall _camera_ occupancy sensor is also required, it can be manually added to `entities`. If this camera has configured labels, only occupancy sensors for those labels are used.|
|
||||
| `occupancy` | `false` | :white_check_mark: | Whether to not to trigger the camera by automatically detecting and using the occupancy `binary_sensor` for this camera and its configured zones and labels. This autodetection only works for Frigate cameras, and only when the occupancy `binary_sensor` entity has been enabled in Home Assistant. If this camera has configured zones, only occupancy sensors for those zones are used -- if the overall _camera_ occupancy sensor is also required, it can be manually added to `entities`. If this camera has configured labels, only occupancy sensors for those labels are used. |
|
||||
| `entities` | | :white_check_mark: | Whether to not to trigger the camera when the state of any Home Assistant entity becomes active (i.e. state becomes `on` or `open`). This works for Frigate or non-Frigate cameras.|
|
||||
|
||||
#### Camera Cast Configuration
|
||||
@@ -403,46 +404,49 @@ See the [fully expanded view configuration example](#config-expanded-view) for h
|
||||
| `dark_mode` | `off` | :white_check_mark: | 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%`.|
|
||||
| `interaction_seconds` | `300` | :white_check_mark: | After a mouse/touch interaction with the Frigate card, it will be considered "interacted with" until this number of seconds elapses without further interaction. May be used in conditions with the `interaction` parameter of a [Frigate card condition](#frigate-card-condition) or with `reset_after_interaction` (below). `0` means no interactions are reported / acted upon. |
|
||||
| `reset_after_interaction` | `true` | :white_check_mark: | If `true` the card will reset to the default configured view (i.e. 'screensaver' functionality) after `interaction_seconds` has elapsed after user interaction. |
|
||||
| `update_seconds` | `0` | :white_check_mark: | A number of seconds after which to automatically update/refresh the default view. See [card updates](#card-updates) below for behavior and usecases. If the default view occurs sooner (e.g. manually) the timer will start over. `0` disables this functionality.|
|
||||
| `update_force` | `false` | :white_check_mark: | Whether automated card updates/refreshes should ignore user interaction. See [card updates](#card-updates) below for behavior and usecases.|
|
||||
| `update_entities` | | :white_check_mark: | **YAML only**: A card-wide list of entities that should cause the view to reset to the default (if the entity only pertains to a particular camera use `triggers` for the selected camera instead, see [Trigger Configuration](#camera-triggers-configuration)). See [card updates](#card-updates) below for behavior and usecases.|
|
||||
| `update_seconds` | `0` | :white_check_mark: | 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.|
|
||||
| `update_force` | `false` | :white_check_mark: | Whether automated card updates should ignore user interaction. |
|
||||
| `update_entities` | | :white_check_mark: | **YAML only**: A card-wide list of entities that should cause the view to reset to the default (if the entity only pertains to a particular camera use `triggers` for the selected camera instead, see [Trigger Configuration](#camera-triggers-configuration)). |
|
||||
| `update_cycle_camera` | `false` | :white_check_mark: | When set to `true` the selected camera is cycled on each default view change. |
|
||||
| `render_entities` | | :white_check_mark: | **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)). |
|
||||
| `scan` | | :white_check_mark: | Configuration for [scan mode](#scan-mode). |
|
||||
| `triggers` | | :white_check_mark: | How to react when a camera is [triggered](#view.triggers). |
|
||||
| `actions` | | :white_check_mark: | Actions to use for all views, individual actions may be overriden by view-specific actions. See [actions](#actions) below.|
|
||||
|
||||
<a name="scan-mode"></a>
|
||||
<a name="view.triggers"></a>
|
||||
|
||||
#### View: Scan Mode configuration
|
||||
#### View: Triggers configuration
|
||||
|
||||
All configuration is under:
|
||||
|
||||
```yaml
|
||||
view:
|
||||
scan:
|
||||
triggers:
|
||||
```
|
||||
|
||||
Scan mode allows the card to automatically "follow the action". In this mode the card will automatically execute an action (defined by `trigger_action`) when a camera is 'triggered', by default selecting that camera in the `live` view . The trigger entities considered are defined by your camera configuration (see `triggers` parameters). An untrigger is defined as the state for all the configured entities returning to inactive (i.e. not `on` or `open`), with an optional number of seconds to wait prior to the untriggering (see `untrigger_seconds`).
|
||||
The `view.triggers` block controls how the card reacts when a camera is triggered (note that _what_ triggers the camera is controlled by the `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 "follow the action" (i.e. change to `live` for a camera that triggers).
|
||||
|
||||
When the camera untriggers, a different action (defined by `untrigger_action`) is automatically executed, by default returning the card to the default view and camera.
|
||||
When a camera untriggers (e.g. an entity state returning to something other than `on` or `open`), an action can also be taken, with an optional number of seconds to wait prior to the acting (see `untrigger_seconds`).
|
||||
|
||||
By default, triggering is only allowed when there is no ongoing human interaction with the card. This behavior can be controlled by the `interaction_mode` parameter.
|
||||
By default, triggering is only allowed when there is no ongoing human
|
||||
interaction with the card. This behavior can be controlled by the
|
||||
`interaction_mode` parameter.
|
||||
|
||||
Scan mode tracks Home Assistant state *changes* -- when the card is first started, it takes an active change in state to trigger (i.e. an already occupied room will not trigger it, but a newly occupied room will).
|
||||
Triggers based on Home Assistant entities require state *changes* -- when the card is first started, it takes an active change in state to trigger (i.e. an already occupied room will not trigger, but a newly occupied room will).
|
||||
|
||||
| Option | Default | Overridable | Description |
|
||||
| - | - | - | - |
|
||||
| `enabled` | `false` | :white_check_mark: | Whether to enable scan mode. |
|
||||
| `filter_selected_camera` | `false` | :white_check_mark: | If set to `true` will only trigger on the currently selected camera.|
|
||||
| `show_trigger_status` | `true` | :white_check_mark: | Whether or not the `live` view should show a visual indication that it is triggered (a pulsing border around the camera edge). |
|
||||
| `untrigger_seconds` | `0` | :white_check_mark: | The number of seconds to wait after all entities are inactive before untriggering. |
|
||||
| `actions` | | :white_check_mark: | The actions to take when scan mode triggers (see below). |
|
||||
| `show_trigger_status` | `false` | :white_check_mark: | Whether or not the `live` view should show a visual indication that it is triggered (a pulsing border around the camera edge). |
|
||||
| `untrigger_seconds` | `0` | :white_check_mark: | The number of seconds to wait after a camera untriggers before considering the card untriggered and taking the `untrigger` action. |
|
||||
| `actions` | | :white_check_mark: | The actions to take when a camera is triggered (see below). |
|
||||
|
||||
#### View: Scan Mode Actions configuration
|
||||
#### View: Triggers Action configuration
|
||||
|
||||
| `trigger` | `live` | :white_check_mark | When `live` the trigger will select the triggered camera in `live` view, when `none` will take no action. |
|
||||
| `untrigger` | `default` | :white_check_mark | When `default` the untrigger will return to the default view and camera, when `none` will take no action. |
|
||||
| `interaction_mode` | `inactive` | :white_check_mark: | Whether actions should be taken when the card is being interacted with. If `all`, actions will always be taken regardless. If `inactive` actions will only be taken if the card has *not* had human interaction recently (as defined by `view.interaction_seconds`). If `active` actions will only be taken if the card *has* had human interaction recently. This does not stop triggering itself (i.e. border will still pulse if `show_trigger_status` is true) but rather just prevents the actions being performed.|
|
||||
| Option | Default | Overridable | Description |
|
||||
| - | - | - | - |
|
||||
| `trigger` | `default` | :white_check_mark | If set to `default` the default view of the card will be reloaded. If set to `live` the triggered camera will be selected in `live` view. If set to `media` the appropriate media view (e.g. `clip` or `snapshot`) will be chosen to match a newly available media item (please note that only some camera engines support new media detection, e.g. `frigate`, see [engines](#engines)). If set to `none` no action is taken. |
|
||||
| `untrigger` | `none` | :white_check_mark | If set to `default` the the default view of the card will be reloaded. If set to `none` no action will be taken. |
|
||||
| `interaction_mode` | `inactive` | :white_check_mark: | Whether actions should be taken when the card is being interacted with. If `all`, actions will always left be taken regardless. If `inactive` actions will only be taken if the card has *not* had human interaction recently (as defined by `view.interaction_seconds`). If `active` actions will only be taken if the card *has* had human interaction recently. This does not stop triggering itself (i.e. border will still pulse if `show_trigger_status` is true) but rather just prevents the actions being performed. |
|
||||
|
||||
### Menu Options
|
||||
|
||||
@@ -1293,7 +1297,7 @@ All variables listed are under a `conditions:` section.
|
||||
| `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 condition will never be satisfied for views that do not themselves load media directly (e.g. gallery).|
|
||||
| `media_query` | Any valid [media query](https://developer.mozilla.org/en-US/docs/Web/CSS/Media_Queries/Using_media_queries) string. Media queries must start and end with parentheses. This may be used to alter card configuration based on device/media properties (e.g. viewport width, orientation). Please note that `width` and `height` refer to the entire viewport not just the card. See the [media query example](#media-query-example).|
|
||||
| `interacted` | 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. |
|
||||
| `triggered` | A list of camera IDs which, if triggered in [scan mode](#scan-mode), satisfy the condition.|
|
||||
| `triggered` | A list of camera IDs which, if [triggered](#camera-triggers-configuration), satisfy the condition.|
|
||||
| `microphone` | A object to include microphone state as part of the condition evaluation. See below.|
|
||||
|
||||
See the [example below](#frigate-card-conditional-example) for a real-world example of how these conditions can be used.
|
||||
@@ -1619,11 +1623,11 @@ Cast media from the card to a local player.
|
||||
|
||||
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/main/images/cast-your-events.gif" alt="Cast media" width="400px">
|
||||
|
||||
### Scan Mode
|
||||
### Triggered!
|
||||
|
||||
Automatically choose the camera with the action!
|
||||
|
||||
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/main/images/scan-mode.gif" alt="Cast media" width="400px">
|
||||
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/main/images/triggered.gif" alt="Triggered!" width="400px">
|
||||
|
||||
### Thumbnail Drawers
|
||||
|
||||
@@ -1935,15 +1939,14 @@ view:
|
||||
render_entities:
|
||||
- switch.render_card
|
||||
dark_mode: 'off'
|
||||
scan:
|
||||
enabled: false
|
||||
show_trigger_status: true
|
||||
filter_selected_camera: false
|
||||
triggers:
|
||||
show_trigger_status: false
|
||||
filter_selected_camera: true
|
||||
untrigger_seconds: 0
|
||||
actions:
|
||||
interaction_mode: 'inactive' as const,
|
||||
trigger: 'live' as const,
|
||||
untrigger: 'default' as const,
|
||||
interaction_mode: inactive
|
||||
trigger: default
|
||||
untrigger: none
|
||||
actions:
|
||||
entity: light.office_main_lights
|
||||
tap_action:
|
||||
@@ -3580,8 +3583,6 @@ cameras:
|
||||
```
|
||||
</details>
|
||||
|
||||
<a name="card-updates"></a>
|
||||
|
||||
### Using `card-mod` to style the card
|
||||
|
||||
This card allows the use of
|
||||
@@ -3670,28 +3671,33 @@ cameras:
|
||||
```
|
||||
</details>
|
||||
|
||||
### Using Scan Mode
|
||||
### Taking action when a camera is triggered!
|
||||
|
||||
Have your card follow the action with Scan Mode.
|
||||
Have your card follow the action when a camera is triggered.
|
||||
|
||||
<details>
|
||||
<summary>Expand: Using scan mode</summary>
|
||||
<summary>Expand: Changing to `live` when a camera is triggered</summary>
|
||||
|
||||
```yaml
|
||||
type: custom:frigate-card
|
||||
cameras:
|
||||
# This is a Frigate camera which will automatically be triggered when events occur.
|
||||
- camera_entity: camera.back_yard
|
||||
# This camera will automatically trigger by occupancy.
|
||||
# This is a Frigate camera which will only be triggered by motion entity changes or a door being opened.
|
||||
- camera_entity: camera.front_door
|
||||
triggers:
|
||||
occupancy: false
|
||||
motion: true
|
||||
entities:
|
||||
- binary_sensor.door_opened
|
||||
events: []
|
||||
view:
|
||||
scan:
|
||||
enabled: true
|
||||
trigger_show_border: true
|
||||
triggers:
|
||||
show_trigger_status: true
|
||||
filter_selected_camera: false
|
||||
actions:
|
||||
trigger: live
|
||||
untrigger: default
|
||||
```
|
||||
</details>
|
||||
|
||||
@@ -3720,9 +3726,6 @@ elements:
|
||||
conditions:
|
||||
triggered:
|
||||
- camera.office
|
||||
view:
|
||||
scan:
|
||||
enabled: true
|
||||
```
|
||||
</details>
|
||||
|
||||
@@ -4146,72 +4149,9 @@ automations:
|
||||
frigate_card_action: live_substream_off
|
||||
conditions:
|
||||
interaction: true
|
||||
view:
|
||||
scan:
|
||||
enabled: true
|
||||
```
|
||||
</details>
|
||||
|
||||
<a name="media-layout-examples"></a>
|
||||
|
||||
## Card Refreshes
|
||||
|
||||
Four sets of flags govern when the card will automatically refresh in the
|
||||
absence of user interaction.
|
||||
|
||||
The following table describes the behavior these flags have.
|
||||
|
||||
### Card Update Truth Table
|
||||
|
||||
Note that no (other) automated updates are permitted when [scan mode](#scan-mode) is being triggered.
|
||||
|
||||
In the below "Trigger Entities" refers to the combination of `view.update_entities` and the `triggers.entities` for the currently selected camera (which in turn will also include the occupancy and motion sensor entities for Frigate cameras if `triggers.occupancy` and `triggers.motion` options are enabled, see [Trigger Configuration](#camera-triggers-configuration)).
|
||||
|
||||
| `view . update_seconds` | `view . interaction_seconds` | `view . update_force` | Trigger Entities | Behavior |
|
||||
| :-: | :-: | :-: | :-: | - |
|
||||
| `0` | `0` | *(Any value)* | Unset | Card will not automatically refresh. |
|
||||
| `0` | `0` | *(Any value)* | *(Any entity)* | Card will reload default view & camera when entity state changes. |
|
||||
| `0` | `X` seconds | *(Any value)* | Unset | Card will reload default view & camera `X` seconds after user interaction stops. |
|
||||
| `0` | `X` seconds | `false` | *(Any entity)* | Card will reload default view & camera `X` seconds after user interaction stops, or when entity state changes (as long as user interaction has not occurred in the last `X` seconds). |
|
||||
| `0` | `X` seconds | `true` | *(Any entity)* | Card will reload default view & camera `X` seconds after user interaction stops or when entity state changes. |
|
||||
| `Y` seconds | `0` | *(Any value)* | Unset | Card will reload default view & camera every `Y` seconds. |
|
||||
| `Y` seconds | `0` | *(Any value)* | *(Any entity)* | Card will reload default view & camera every `Y` seconds, or whenever entity state changes. |
|
||||
| `Y` seconds | `X` seconds | `false` | Unset | Card will reload default view & camera `X` seconds after user interaction stops, and every `Y` seconds (as long as there hasn't been user interaction in the last `X` seconds). |
|
||||
| `Y` seconds | `X` seconds | `false` | *(Any entity)* | Card will reload default view & camera `X` seconds after user interaction stops, and every `Y` seconds or whenever entity state changes (in both cases -- as long as there hasn't been user interaction in the last `X` seconds). |
|
||||
| `Y` seconds | `X` seconds | `true` | Unset | Card will reload default view & camera `X` seconds after user interaction stops, and every `Y` seconds. |
|
||||
| `Y` seconds | `X` seconds | `true` | *(Any entity)* | Card will reload default view & camera `X` seconds after user interaction stops, and every `Y` seconds or whenever entity state changes. |
|
||||
|
||||
### Usecases For Automated Refreshes
|
||||
|
||||
* Refreshing the `live` thumbnails every 30 seconds.
|
||||
```yaml
|
||||
view:
|
||||
default: live
|
||||
update_seconds: 30
|
||||
```
|
||||
* Using `clip` or `snapshot` as the default view (for the most recent clip or
|
||||
snapshot respectively) and having the card automatically refresh (to fetch a
|
||||
newer clip/snapshot) on motion.
|
||||
```yaml
|
||||
cameras:
|
||||
- entity: camera.office
|
||||
triggers:
|
||||
motion: true
|
||||
```
|
||||
* Cycle the live view of the camera every 60 seconds
|
||||
```yaml
|
||||
view:
|
||||
update_cycle_camera: true
|
||||
update_seconds: 60
|
||||
```
|
||||
* Return to the most recent clip of the default camera 30 seconds after user
|
||||
interaction with the card stops.
|
||||
```yaml
|
||||
view:
|
||||
default: clip
|
||||
interaction_seconds: 30
|
||||
```
|
||||
|
||||
<a name="query-string-actions"></a>
|
||||
|
||||
### Passing the card actions from the URL
|
||||
|
||||
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
+9
-9
@@ -20,7 +20,7 @@
|
||||
"@dermotduffy/panzoom": "^4.5.1",
|
||||
"@egjs/hammerjs": "^2.0.17",
|
||||
"@graphiteds/core": "^1.9.11",
|
||||
"@lit-labs/scoped-registry-mixin": "^1.0.1",
|
||||
"@lit-labs/scoped-registry-mixin": "^1.0.3",
|
||||
"@lit-labs/task": "^1.1.3",
|
||||
"@types/bluebird": "^3.5.36",
|
||||
"component-emitter": "^1.3.0",
|
||||
@@ -31,7 +31,7 @@
|
||||
"embla-carousel-wheel-gestures": "8.0.0-rc04",
|
||||
"home-assistant-js-websocket": "^8.0.0",
|
||||
"keycharm": "^0.4.0",
|
||||
"lit": "^2.3.1",
|
||||
"lit": "^3.1.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"masonry-layout": "^4.2.2",
|
||||
"moment": "^2.29.4",
|
||||
@@ -40,8 +40,8 @@
|
||||
"screenfull": "^6.0.2",
|
||||
"side-drawer": "^3.1.0",
|
||||
"uuid": "^8.3.2",
|
||||
"vis-data": "^7.1.4",
|
||||
"vis-timeline": "^7.7.0",
|
||||
"vis-data": "^7.1.9",
|
||||
"vis-timeline": "^7.7.3",
|
||||
"vis-util": "^5.0.2",
|
||||
"web-dialog": "^0.0.11",
|
||||
"xss": "^1.0.14",
|
||||
@@ -61,7 +61,7 @@
|
||||
"@types/masonry-layout": "^4.2.5",
|
||||
"@typescript-eslint/eslint-plugin": "^5.36.2",
|
||||
"@typescript-eslint/parser": "^5.36.2",
|
||||
"@vitest/coverage-istanbul": "^0.34.3",
|
||||
"@vitest/coverage-istanbul": "^1.3.1",
|
||||
"eslint": "^8.23.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
@@ -79,8 +79,8 @@
|
||||
"sass": "^1.54.9",
|
||||
"ts-prune": "^0.10.3",
|
||||
"typescript": "^4.9.5",
|
||||
"vitest": "^0.34.3",
|
||||
"vitest-mock-extended": "^1.2.1"
|
||||
"vitest": "^1.3.1",
|
||||
"vitest-mock-extended": "^1.3.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "rollup -c --watch",
|
||||
@@ -92,7 +92,7 @@
|
||||
"coverage": "vitest run --coverage"
|
||||
},
|
||||
"volta": {
|
||||
"node": "18.14.0",
|
||||
"yarn": "3.4.1"
|
||||
"node": "18.17.1",
|
||||
"yarn": "4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
|
||||
import { Entity } from '../../utils/ha/entity-registry/types';
|
||||
import { Camera } from '../camera';
|
||||
import { CameraInitializationError } from '../error';
|
||||
|
||||
export class BrowseMediaCamera extends Camera {
|
||||
protected _entity: Entity | null = null;
|
||||
|
||||
public async initialize(
|
||||
hass: HomeAssistant,
|
||||
entityRegistryManager: EntityRegistryManager,
|
||||
): Promise<Camera> {
|
||||
const config = this.getConfig();
|
||||
const entity = config.camera_entity
|
||||
? await entityRegistryManager.getEntity(hass, config.camera_entity)
|
||||
: null;
|
||||
|
||||
if (!entity || !config.camera_entity) {
|
||||
throw new CameraInitializationError(localize('error.no_camera_entity'), config);
|
||||
}
|
||||
this._entity = entity;
|
||||
return this;
|
||||
}
|
||||
|
||||
public getEntity(): Entity | null {
|
||||
return this._entity;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import { CameraConfig } from '../../config/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { ExtendedHomeAssistant } from '../../types';
|
||||
import { canonicalizeHAURL } from '../../utils/ha';
|
||||
import { BrowseMediaManager } from '../../utils/ha/browse-media/browse-media-manager';
|
||||
@@ -11,24 +10,24 @@ import {
|
||||
RichBrowseMedia,
|
||||
} from '../../utils/ha/browse-media/types';
|
||||
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
|
||||
import { Entity } from '../../utils/ha/entity-registry/types';
|
||||
import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media';
|
||||
import { ViewMedia } from '../../view/media';
|
||||
import { RequestCache } from '../cache';
|
||||
import { Camera } from '../camera';
|
||||
import { CameraManagerEngine } from '../engine';
|
||||
import { CameraInitializationError } from '../error';
|
||||
import { GenericCameraManagerEngine } from '../generic/engine-generic';
|
||||
import { rangesOverlap } from '../range';
|
||||
import { CameraManagerReadOnlyConfigStore } from '../store';
|
||||
import {
|
||||
CameraEndpoint,
|
||||
CameraEventCallback,
|
||||
CameraManagerMediaCapabilities,
|
||||
DataQuery,
|
||||
EventQuery,
|
||||
PartialEventQuery,
|
||||
QueryType,
|
||||
} from '../types';
|
||||
import { BrowseMediaCamera } from './camera';
|
||||
import { BrowseMediaViewMediaFactory } from './media';
|
||||
import { BrowseMediaMetadata } from './types';
|
||||
|
||||
@@ -121,7 +120,6 @@ export class BrowseMediaCameraManagerEngine
|
||||
extends GenericCameraManagerEngine
|
||||
implements CameraManagerEngine
|
||||
{
|
||||
protected _cameraEntities: Map<string, Entity> = new Map();
|
||||
protected _browseMediaManager: BrowseMediaManager<BrowseMediaMetadata>;
|
||||
protected _resolvedMediaCache: ResolvedMediaCache;
|
||||
protected _requestCache: RequestCache;
|
||||
@@ -130,38 +128,32 @@ export class BrowseMediaCameraManagerEngine
|
||||
browseMediaManager: BrowseMediaManager<BrowseMediaMetadata>,
|
||||
resolvedMediaCache: ResolvedMediaCache,
|
||||
requestCache: RequestCache,
|
||||
eventCallback?: CameraEventCallback,
|
||||
) {
|
||||
super();
|
||||
super(eventCallback);
|
||||
this._browseMediaManager = browseMediaManager;
|
||||
this._resolvedMediaCache = resolvedMediaCache;
|
||||
this._requestCache = requestCache;
|
||||
}
|
||||
|
||||
public async initializeCamera(
|
||||
public async createCamera(
|
||||
hass: HomeAssistant,
|
||||
entityRegistryManager: EntityRegistryManager,
|
||||
cameraConfig: CameraConfig,
|
||||
): Promise<Camera> {
|
||||
const entity = cameraConfig.camera_entity
|
||||
? await entityRegistryManager.getEntity(hass, cameraConfig.camera_entity)
|
||||
: null;
|
||||
if (!entity || !cameraConfig.camera_entity) {
|
||||
throw new CameraInitializationError(
|
||||
localize('error.no_camera_entity'),
|
||||
cameraConfig,
|
||||
);
|
||||
}
|
||||
this._cameraEntities.set(cameraConfig.camera_entity, entity);
|
||||
|
||||
return new Camera(cameraConfig, this, {
|
||||
canFavoriteEvents: false,
|
||||
canFavoriteRecordings: false,
|
||||
canSeek: false,
|
||||
supportsClips: true,
|
||||
supportsRecordings: false,
|
||||
supportsSnapshots: true,
|
||||
supportsTimeline: true,
|
||||
const camera = new BrowseMediaCamera(cameraConfig, this, {
|
||||
capabilities: {
|
||||
canFavoriteEvents: false,
|
||||
canFavoriteRecordings: false,
|
||||
canSeek: false,
|
||||
supportsClips: true,
|
||||
supportsRecordings: false,
|
||||
supportsSnapshots: true,
|
||||
supportsTimeline: true,
|
||||
},
|
||||
eventCallback: this._eventCallback,
|
||||
});
|
||||
return await camera.initialize(hass, entityRegistryManager);
|
||||
}
|
||||
|
||||
public generateDefaultEventQuery(
|
||||
|
||||
@@ -1,22 +1,81 @@
|
||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import { CameraConfig } from '../config/types';
|
||||
import { localize } from '../localize/localize';
|
||||
import { allPromises } from '../utils/basic';
|
||||
import {
|
||||
isTriggeredState,
|
||||
parseStateChangeTrigger,
|
||||
subscribeToTrigger,
|
||||
} from '../utils/ha';
|
||||
import { EntityRegistryManager } from '../utils/ha/entity-registry';
|
||||
import { CameraManagerEngine } from './engine';
|
||||
import { CameraNoIDError } from './error';
|
||||
import { CameraManagerCameraCapabilities } from './types';
|
||||
import { CameraEventCallback, CameraManagerCameraCapabilities } from './types';
|
||||
|
||||
type DestroyCallback = () => Promise<void>;
|
||||
|
||||
export class Camera {
|
||||
protected _config: CameraConfig;
|
||||
protected _engine: CameraManagerEngine;
|
||||
protected _capabilities: CameraManagerCameraCapabilities;
|
||||
protected _capabilities?: CameraManagerCameraCapabilities;
|
||||
protected _eventCallback?: CameraEventCallback;
|
||||
protected _destroyCallbacks: DestroyCallback[] = [];
|
||||
|
||||
constructor(
|
||||
config: CameraConfig,
|
||||
engine: CameraManagerEngine,
|
||||
capabilities: CameraManagerCameraCapabilities,
|
||||
options?: {
|
||||
capabilities?: CameraManagerCameraCapabilities;
|
||||
eventCallback?: CameraEventCallback;
|
||||
},
|
||||
) {
|
||||
this._config = config;
|
||||
this._engine = engine;
|
||||
this._capabilities = capabilities;
|
||||
this._capabilities = options?.capabilities;
|
||||
this._eventCallback = options?.eventCallback;
|
||||
}
|
||||
|
||||
protected async _convertStateChangeToCameraEvent(data: unknown): Promise<void> {
|
||||
const stateChange = parseStateChangeTrigger(data);
|
||||
if (!stateChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._eventCallback?.({
|
||||
cameraID: this.getID(),
|
||||
type: isTriggeredState(stateChange.to_state.state) ? 'new' : 'end',
|
||||
});
|
||||
}
|
||||
|
||||
protected async _subscribeToTriggerEntities(hass: HomeAssistant): Promise<void> {
|
||||
if (!this._config.triggers.entities.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._destroyCallbacks.push(
|
||||
await subscribeToTrigger(
|
||||
hass,
|
||||
(data) => this._convertStateChangeToCameraEvent(data),
|
||||
{
|
||||
entityID: this._config.triggers.entities,
|
||||
platform: 'state',
|
||||
stateOnly: true,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async initialize(
|
||||
hass: HomeAssistant,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_entityRegistryManager: EntityRegistryManager,
|
||||
): Promise<Camera> {
|
||||
await this._subscribeToTriggerEntities(hass);
|
||||
return this;
|
||||
}
|
||||
|
||||
async destroy(): Promise<void> {
|
||||
await allPromises(this._destroyCallbacks, (cb) => cb());
|
||||
}
|
||||
|
||||
public getConfig(): CameraConfig {
|
||||
@@ -38,7 +97,7 @@ export class Camera {
|
||||
return this._engine;
|
||||
}
|
||||
|
||||
public getCapabilities(): CameraManagerCameraCapabilities {
|
||||
return this._capabilities;
|
||||
public getCapabilities(): CameraManagerCameraCapabilities | null {
|
||||
return this._capabilities ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ResolvedMediaCache } from '../utils/ha/resolved-media';
|
||||
import { MemoryRequestCache, RecordingSegmentsCache, RequestCache } from './cache';
|
||||
import { CameraManagerEngine } from './engine';
|
||||
import { CameraInitializationError } from './error';
|
||||
import { Engine } from './types';
|
||||
import { CameraEventCallback, Engine } from './types';
|
||||
import { getCameraEntityFromConfig } from './utils';
|
||||
|
||||
export class CameraManagerEngineFactory {
|
||||
@@ -24,18 +24,19 @@ export class CameraManagerEngineFactory {
|
||||
this._resolvedMediaCache = resolvedMediaCache;
|
||||
}
|
||||
|
||||
public async createEngine(engine: Engine): Promise<CameraManagerEngine> {
|
||||
public async createEngine(engine: Engine, eventCallback?: CameraEventCallback): Promise<CameraManagerEngine> {
|
||||
let cameraManagerEngine: CameraManagerEngine;
|
||||
switch (engine) {
|
||||
case Engine.Generic:
|
||||
const { GenericCameraManagerEngine } = await import('./generic/engine-generic');
|
||||
cameraManagerEngine = new GenericCameraManagerEngine();
|
||||
cameraManagerEngine = new GenericCameraManagerEngine(eventCallback);
|
||||
break;
|
||||
case Engine.Frigate:
|
||||
const { FrigateCameraManagerEngine } = await import('./frigate/engine-frigate');
|
||||
cameraManagerEngine = new FrigateCameraManagerEngine(
|
||||
new RecordingSegmentsCache(),
|
||||
new RequestCache(),
|
||||
eventCallback,
|
||||
);
|
||||
break;
|
||||
case Engine.MotionEye:
|
||||
@@ -46,6 +47,7 @@ export class CameraManagerEngineFactory {
|
||||
new BrowseMediaManager(new MemoryRequestCache<string, BrowseMedia>()),
|
||||
this._resolvedMediaCache,
|
||||
new RequestCache(),
|
||||
eventCallback,
|
||||
);
|
||||
}
|
||||
return cameraManagerEngine;
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import {
|
||||
CameraConfig,
|
||||
PTZAction,
|
||||
PTZPhase,
|
||||
} from '../config/types';
|
||||
import { CameraConfig, PTZAction, PTZPhase } from '../config/types';
|
||||
import { ExtendedHomeAssistant } from '../types';
|
||||
import { EntityRegistryManager } from '../utils/ha/entity-registry';
|
||||
import { ViewMedia } from '../view/media';
|
||||
@@ -37,7 +33,7 @@ export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
|
||||
export interface CameraManagerEngine {
|
||||
getEngineType(): Engine;
|
||||
|
||||
initializeCamera(
|
||||
createCamera(
|
||||
hass: HomeAssistant,
|
||||
entityRegistryManager: EntityRegistryManager,
|
||||
cameraConfig: CameraConfig,
|
||||
@@ -143,8 +139,8 @@ export interface CameraManagerEngine {
|
||||
cameraConfig: CameraConfig,
|
||||
action: PTZAction,
|
||||
options?: {
|
||||
phase?: PTZPhase,
|
||||
preset?: string,
|
||||
}
|
||||
phase?: PTZPhase;
|
||||
preset?: string;
|
||||
},
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import uniq from 'lodash-es/uniq';
|
||||
import { CameraConfig } from '../../config/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { errorToConsole } from '../../utils/basic';
|
||||
import { subscribeToTrigger } from '../../utils/ha';
|
||||
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
|
||||
import { Entity } from '../../utils/ha/entity-registry/types';
|
||||
import { Camera } from '../camera';
|
||||
import { CameraInitializationError } from '../error';
|
||||
import { PTZCapabilities, PTZMovementType } from '../types';
|
||||
import { getCameraEntityFromConfig } from '../utils.js';
|
||||
import { getPTZInfo } from './requests';
|
||||
import { PTZInfo, frigateEventChangeTriggerResponseSchema } from './types';
|
||||
|
||||
const CAMERA_BIRDSEYE = 'birdseye' as const;
|
||||
|
||||
export const isBirdseye = (cameraConfig: CameraConfig): boolean => {
|
||||
return cameraConfig.frigate.camera_name === CAMERA_BIRDSEYE;
|
||||
};
|
||||
|
||||
export class FrigateCamera extends Camera {
|
||||
public async initialize(
|
||||
hass: HomeAssistant,
|
||||
entityRegistryManager: EntityRegistryManager,
|
||||
): Promise<Camera> {
|
||||
await this._initializeConfig(hass, entityRegistryManager);
|
||||
await this._initializeCapabilities(hass);
|
||||
await this._subscribeToEvents(hass);
|
||||
return await super.initialize(hass, entityRegistryManager);
|
||||
}
|
||||
|
||||
protected async _initializeConfig(
|
||||
hass: HomeAssistant,
|
||||
entityRegistryManager: EntityRegistryManager,
|
||||
): Promise<void> {
|
||||
const config = this.getConfig();
|
||||
const hasCameraName = !!config.frigate?.camera_name;
|
||||
const hasAutoTriggers = config.triggers.motion || config.triggers.occupancy;
|
||||
|
||||
let entity: Entity | null = null;
|
||||
const cameraEntity = getCameraEntityFromConfig(config);
|
||||
|
||||
// Entity information is required if the Frigate camera name is missing, or
|
||||
// if the entity requires automatic resolution of motion/occupancy sensors.
|
||||
if (cameraEntity && (!hasCameraName || hasAutoTriggers)) {
|
||||
try {
|
||||
entity = await entityRegistryManager.getEntity(hass, cameraEntity);
|
||||
} catch (e) {
|
||||
throw new CameraInitializationError(localize('error.no_camera_entity'), config);
|
||||
}
|
||||
}
|
||||
|
||||
if (entity && !hasCameraName) {
|
||||
const resolvedName = this._getFrigateCameraNameFromEntity(entity);
|
||||
if (resolvedName) {
|
||||
this._config.frigate.camera_name = resolvedName;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasAutoTriggers) {
|
||||
// Try to find the correct entities for the motion & occupancy sensors.
|
||||
// We know they are binary_sensors, and that they'll have the same
|
||||
// config entry ID as the camera. Searching via unique_id ensures this
|
||||
// search still works if the user renames the entity_id.
|
||||
const binarySensorEntities = await entityRegistryManager.getMatchingEntities(
|
||||
hass,
|
||||
(ent) =>
|
||||
ent.config_entry_id === entity?.config_entry_id &&
|
||||
!ent.disabled_by &&
|
||||
ent.entity_id.startsWith('binary_sensor.'),
|
||||
);
|
||||
|
||||
if (config.triggers.motion) {
|
||||
const motionEntity = this._getMotionSensor(config, [
|
||||
...binarySensorEntities.values(),
|
||||
]);
|
||||
if (motionEntity) {
|
||||
config.triggers.entities.push(motionEntity);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.triggers.occupancy) {
|
||||
const occupancyEntities = this._getOccupancySensor(config, [
|
||||
...binarySensorEntities.values(),
|
||||
]);
|
||||
if (occupancyEntities) {
|
||||
config.triggers.entities.push(...occupancyEntities);
|
||||
}
|
||||
}
|
||||
|
||||
// De-duplicate triggering entities.
|
||||
config.triggers.entities = uniq(config.triggers.entities);
|
||||
}
|
||||
}
|
||||
|
||||
protected async _initializeCapabilities(hass: HomeAssistant): Promise<void> {
|
||||
const config = this.getConfig();
|
||||
const ptz = await this._getPTZCapabilities(hass, config);
|
||||
const birdseye = isBirdseye(config);
|
||||
this._capabilities = {
|
||||
canFavoriteEvents: !birdseye,
|
||||
canFavoriteRecordings: !birdseye,
|
||||
canSeek: true,
|
||||
supportsClips: !birdseye,
|
||||
supportsSnapshots: !birdseye,
|
||||
supportsRecordings: !birdseye,
|
||||
supportsTimeline: !birdseye,
|
||||
...(ptz && { ptz: ptz }),
|
||||
};
|
||||
}
|
||||
|
||||
protected _getFrigateCameraNameFromEntity(entity: Entity): string | null {
|
||||
if (
|
||||
entity.platform === 'frigate' &&
|
||||
entity.unique_id &&
|
||||
typeof entity.unique_id === 'string'
|
||||
) {
|
||||
const match = entity.unique_id.match(/:camera:(?<camera>[^:]+)$/);
|
||||
if (match && match.groups) {
|
||||
return match.groups['camera'];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected async _getPTZCapabilities(
|
||||
hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
): Promise<PTZCapabilities | null> {
|
||||
if (!cameraConfig.frigate.camera_name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let ptzInfo: PTZInfo | null = null;
|
||||
try {
|
||||
ptzInfo = await getPTZInfo(
|
||||
hass,
|
||||
cameraConfig.frigate.client_id,
|
||||
cameraConfig.frigate.camera_name,
|
||||
);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
return null;
|
||||
}
|
||||
|
||||
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 }),
|
||||
...(presets && { presets: presets }),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the motion sensor entity for a given camera.
|
||||
* @param cache The EntityCache of entity registry information.
|
||||
* @param cameraConfig The camera config in question.
|
||||
* @returns The entity id of the motion sensor or null.
|
||||
*/
|
||||
protected _getMotionSensor(
|
||||
cameraConfig: CameraConfig,
|
||||
entities: Entity[],
|
||||
): string | null {
|
||||
if (cameraConfig.frigate.camera_name) {
|
||||
return (
|
||||
entities.find(
|
||||
(entity) =>
|
||||
typeof entity.unique_id === 'string' &&
|
||||
!!entity.unique_id?.match(
|
||||
new RegExp(`:motion_sensor:${cameraConfig.frigate.camera_name}`),
|
||||
),
|
||||
)?.entity_id ?? null
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the occupancy sensor entity for a given camera.
|
||||
* @param cache The EntityCache of entity registry information.
|
||||
* @param cameraConfig The camera config in question.
|
||||
* @returns The entity id of the occupancy sensor or null.
|
||||
*/
|
||||
protected _getOccupancySensor(
|
||||
cameraConfig: CameraConfig,
|
||||
entities: Entity[],
|
||||
): string[] | null {
|
||||
const entityIDs: string[] = [];
|
||||
const addEntityIDIfFound = (cameraOrZone: string, label: string): void => {
|
||||
const entityID =
|
||||
entities.find(
|
||||
(entity) =>
|
||||
typeof entity.unique_id === 'string' &&
|
||||
!!entity.unique_id?.match(
|
||||
new RegExp(`:occupancy_sensor:${cameraOrZone}_${label}`),
|
||||
),
|
||||
)?.entity_id ?? null;
|
||||
if (entityID) {
|
||||
entityIDs.push(entityID);
|
||||
}
|
||||
};
|
||||
|
||||
if (cameraConfig.frigate.camera_name) {
|
||||
// If zone(s) are specified, the master occupancy sensor for the overall
|
||||
// camera is not used by default (but could be manually added by the
|
||||
// user).
|
||||
const camerasAndZones = cameraConfig.frigate.zones?.length
|
||||
? cameraConfig.frigate.zones
|
||||
: [cameraConfig.frigate.camera_name];
|
||||
|
||||
const labels = cameraConfig.frigate.labels?.length
|
||||
? cameraConfig.frigate.labels
|
||||
: ['all'];
|
||||
for (const cameraOrZone of camerasAndZones) {
|
||||
for (const label of labels) {
|
||||
addEntityIDIfFound(cameraOrZone, label);
|
||||
}
|
||||
}
|
||||
|
||||
if (entityIDs.length) {
|
||||
return entityIDs;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected async _subscribeToEvents(hass: HomeAssistant): Promise<void> {
|
||||
const config = this.getConfig();
|
||||
if (!config.triggers.events.length || !config.frigate.camera_name) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._destroyCallbacks.push(
|
||||
await subscribeToTrigger(hass, (ev) => this._handleEventChange(ev), {
|
||||
platform: 'mqtt',
|
||||
topic: `${config.frigate.client_id}/events`,
|
||||
|
||||
// Only trigger for events pertaining to this camera.
|
||||
payload: config.frigate.camera_name,
|
||||
valueTemplate: '{{ value_json.after.camera }}',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
protected _handleEventChange(ev: unknown): void {
|
||||
const parseResult = frigateEventChangeTriggerResponseSchema.safeParse(ev);
|
||||
if (!parseResult.success) {
|
||||
console.warn('Ignoring unparseable Frigate event', ev);
|
||||
return;
|
||||
}
|
||||
|
||||
const change = parseResult.data.variables.trigger.payload_json;
|
||||
const snapshotChange =
|
||||
(!change.before.has_snapshot && change.after.has_snapshot) ||
|
||||
change.before.snapshot?.frame_time !== change.after.snapshot?.frame_time;
|
||||
const clipChange = !change.before.has_clip && change.after.has_clip;
|
||||
|
||||
const config = this.getConfig();
|
||||
if (config.frigate.camera_name !== change.after.camera) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(config.frigate.zones?.length &&
|
||||
!config.frigate.zones.some((zone) =>
|
||||
change.after.current_zones.includes(zone),
|
||||
)) ||
|
||||
(config.frigate.labels?.length &&
|
||||
!config.frigate.labels.includes(change.after.label))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventsToTriggerOn = config.triggers.events;
|
||||
if (
|
||||
!(
|
||||
eventsToTriggerOn.includes('events') ||
|
||||
(eventsToTriggerOn.includes('snapshots') && snapshotChange) ||
|
||||
(eventsToTriggerOn.includes('clips') && clipChange)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._eventCallback?.({
|
||||
fidelity: 'high',
|
||||
cameraID: this.getID(),
|
||||
type: change.type,
|
||||
// In cases where there are both clip and snapshot media, ensure to only
|
||||
// trigger on the media type that is allowed by the configuration.
|
||||
clip: clipChange && eventsToTriggerOn.includes('clips'),
|
||||
snapshot: snapshotChange && eventsToTriggerOn.includes('snapshots'),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,30 +7,25 @@ import startOfHour from 'date-fns/startOfHour';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
import uniq from 'lodash-es/uniq';
|
||||
import uniqWith from 'lodash-es/uniqWith';
|
||||
import { CameraConfig, PTZAction, PTZPhase } from '../../config/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { ExtendedHomeAssistant } from '../../types';
|
||||
import {
|
||||
allPromises,
|
||||
errorToConsole,
|
||||
formatDate,
|
||||
prettifyTitle,
|
||||
runWhenIdleIfSupported,
|
||||
} from '../../utils/basic';
|
||||
import { getEntityTitle } from '../../utils/ha';
|
||||
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
|
||||
import { Entity } from '../../utils/ha/entity-registry/types';
|
||||
import { ViewMedia } from '../../view/media';
|
||||
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 { CameraInitializationError } from '../error';
|
||||
import { GenericCameraManagerEngine } from '../generic/engine-generic';
|
||||
import { DateRange } from '../range';
|
||||
import { CameraManagerReadOnlyConfigStore } from '../store';
|
||||
@@ -38,6 +33,7 @@ import {
|
||||
CameraEndpoint,
|
||||
CameraEndpoints,
|
||||
CameraEndpointsContext,
|
||||
CameraEventCallback,
|
||||
CameraManagerCameraMetadata,
|
||||
CameraManagerMediaCapabilities,
|
||||
DataQuery,
|
||||
@@ -52,8 +48,6 @@ import {
|
||||
PartialEventQuery,
|
||||
PartialRecordingQuery,
|
||||
PartialRecordingSegmentsQuery,
|
||||
PTZCapabilities,
|
||||
PTZMovementType,
|
||||
QueryResults,
|
||||
QueryResultsType,
|
||||
QueryReturnType,
|
||||
@@ -65,34 +59,31 @@ import {
|
||||
RecordingSegmentsQuery,
|
||||
RecordingSegmentsQueryResultsMap,
|
||||
} from '../types';
|
||||
import { getCameraEntityFromConfig, getDefaultGo2RTCEndpoint } from '../utils.js';
|
||||
import { getDefaultGo2RTCEndpoint } from '../utils.js';
|
||||
import frigateLogo from './assets/frigate-logo-dark.svg';
|
||||
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,
|
||||
getPTZInfo,
|
||||
} from './requests';
|
||||
import {
|
||||
FrigateEventQueryResults,
|
||||
FrigateRecording,
|
||||
FrigateRecordingQueryResults,
|
||||
FrigateRecordingSegmentsQueryResults,
|
||||
PTZInfo,
|
||||
} from './types';
|
||||
|
||||
const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
||||
const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
||||
const MEDIA_METADATA_REQUEST_CACHE_AGE_SECONDS = 60;
|
||||
|
||||
const CAMERA_BIRDSEYE = 'birdseye' as const;
|
||||
|
||||
class FrigateQueryResultsClassifier {
|
||||
public static isFrigateEventQueryResults(
|
||||
results: QueryResults,
|
||||
@@ -135,8 +126,9 @@ export class FrigateCameraManagerEngine
|
||||
constructor(
|
||||
recordingSegmentsCache: RecordingSegmentsCache,
|
||||
requestCache: RequestCache,
|
||||
eventCallback?: CameraEventCallback,
|
||||
) {
|
||||
super();
|
||||
super(eventCallback);
|
||||
this._recordingSegmentsCache = recordingSegmentsCache;
|
||||
this._requestCache = requestCache;
|
||||
}
|
||||
@@ -145,222 +137,15 @@ export class FrigateCameraManagerEngine
|
||||
return Engine.Frigate;
|
||||
}
|
||||
|
||||
public async initializeCamera(
|
||||
public async createCamera(
|
||||
hass: HomeAssistant,
|
||||
entityRegistryManager: EntityRegistryManager,
|
||||
cameraConfig: CameraConfig,
|
||||
): Promise<Camera> {
|
||||
const hasCameraName = !!cameraConfig.frigate?.camera_name;
|
||||
const hasAutoTriggers =
|
||||
cameraConfig.triggers.motion || cameraConfig.triggers.occupancy;
|
||||
|
||||
let entity: Entity | null = null;
|
||||
const cameraEntity = getCameraEntityFromConfig(cameraConfig);
|
||||
|
||||
// Entity information is required if the Frigate camera name is missing, or
|
||||
// if the entity requires automatic resolution of motion/occupancy sensors.
|
||||
if (cameraEntity && (!hasCameraName || hasAutoTriggers)) {
|
||||
try {
|
||||
entity = await entityRegistryManager.getEntity(hass, cameraEntity);
|
||||
} catch (e) {
|
||||
throw new CameraInitializationError(
|
||||
localize('error.no_camera_entity'),
|
||||
cameraConfig,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (entity && !hasCameraName) {
|
||||
const resolvedName = this._getFrigateCameraNameFromEntity(entity);
|
||||
if (resolvedName) {
|
||||
cameraConfig.frigate.camera_name = resolvedName;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasAutoTriggers) {
|
||||
// Try to find the correct entities for the motion & occupancy sensors.
|
||||
// We know they are binary_sensors, and that they'll have the same
|
||||
// config entry ID as the camera. Searching via unique_id ensures this
|
||||
// search still works if the user renames the entity_id.
|
||||
const binarySensorEntities = await entityRegistryManager.getMatchingEntities(
|
||||
hass,
|
||||
(ent) =>
|
||||
ent.config_entry_id === entity?.config_entry_id &&
|
||||
!ent.disabled_by &&
|
||||
ent.entity_id.startsWith('binary_sensor.'),
|
||||
);
|
||||
|
||||
if (cameraConfig.triggers.motion) {
|
||||
const motionEntity = this._getMotionSensor(cameraConfig, [
|
||||
...binarySensorEntities.values(),
|
||||
]);
|
||||
if (motionEntity) {
|
||||
cameraConfig.triggers.entities.push(motionEntity);
|
||||
}
|
||||
}
|
||||
|
||||
if (cameraConfig.triggers.occupancy) {
|
||||
const occupancyEntities = this._getOccupancySensor(cameraConfig, [
|
||||
...binarySensorEntities.values(),
|
||||
]);
|
||||
if (occupancyEntities) {
|
||||
cameraConfig.triggers.entities.push(...occupancyEntities);
|
||||
}
|
||||
}
|
||||
|
||||
// De-duplicate triggering entities.
|
||||
cameraConfig.triggers.entities = uniq(cameraConfig.triggers.entities);
|
||||
}
|
||||
|
||||
const ptz = await this._getPTZCapabilities(hass, cameraConfig);
|
||||
|
||||
const isBirdseye = this._isBirdseye(cameraConfig);
|
||||
return new Camera(cameraConfig, this, {
|
||||
canFavoriteEvents: !isBirdseye,
|
||||
canFavoriteRecordings: !isBirdseye,
|
||||
canSeek: true,
|
||||
supportsClips: !isBirdseye,
|
||||
supportsSnapshots: !isBirdseye,
|
||||
supportsRecordings: !isBirdseye,
|
||||
supportsTimeline: !isBirdseye,
|
||||
|
||||
...(ptz && { ptz: ptz }),
|
||||
const camera = new FrigateCamera(cameraConfig, this, {
|
||||
eventCallback: this._eventCallback,
|
||||
});
|
||||
}
|
||||
|
||||
protected async _getPTZCapabilities(
|
||||
hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
): Promise<PTZCapabilities | null> {
|
||||
if (!cameraConfig.frigate.camera_name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let ptzInfo: PTZInfo | null = null;
|
||||
try {
|
||||
ptzInfo = await getPTZInfo(
|
||||
hass,
|
||||
cameraConfig.frigate.client_id,
|
||||
cameraConfig.frigate.camera_name,
|
||||
);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
return null;
|
||||
}
|
||||
|
||||
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 }),
|
||||
...(presets && { presets: presets }),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected _isBirdseye(cameraConfig: CameraConfig): boolean {
|
||||
return cameraConfig.frigate.camera_name === CAMERA_BIRDSEYE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Frigate camera name from an entity.
|
||||
* @returns The Frigate camera name or null if unavailable.
|
||||
*/
|
||||
protected _getFrigateCameraNameFromEntity(entity: Entity): string | null {
|
||||
if (
|
||||
entity.platform === 'frigate' &&
|
||||
entity.unique_id &&
|
||||
typeof entity.unique_id === 'string'
|
||||
) {
|
||||
const match = entity.unique_id.match(/:camera:(?<camera>[^:]+)$/);
|
||||
if (match && match.groups) {
|
||||
return match.groups['camera'];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the motion sensor entity for a given camera.
|
||||
* @param cache The EntityCache of entity registry information.
|
||||
* @param cameraConfig The camera config in question.
|
||||
* @returns The entity id of the motion sensor or null.
|
||||
*/
|
||||
protected _getMotionSensor(
|
||||
cameraConfig: CameraConfig,
|
||||
entities: Entity[],
|
||||
): string | null {
|
||||
if (cameraConfig.frigate.camera_name) {
|
||||
return (
|
||||
entities.find(
|
||||
(entity) =>
|
||||
typeof entity.unique_id === 'string' &&
|
||||
!!entity.unique_id?.match(
|
||||
new RegExp(`:motion_sensor:${cameraConfig.frigate.camera_name}`),
|
||||
),
|
||||
)?.entity_id ?? null
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the occupancy sensor entity for a given camera.
|
||||
* @param cache The EntityCache of entity registry information.
|
||||
* @param cameraConfig The camera config in question.
|
||||
* @returns The entity id of the occupancy sensor or null.
|
||||
*/
|
||||
protected _getOccupancySensor(
|
||||
cameraConfig: CameraConfig,
|
||||
entities: Entity[],
|
||||
): string[] | null {
|
||||
const entityIDs: string[] = [];
|
||||
const addEntityIDIfFound = (cameraOrZone: string, label: string): void => {
|
||||
const entityID =
|
||||
entities.find(
|
||||
(entity) =>
|
||||
typeof entity.unique_id === 'string' &&
|
||||
!!entity.unique_id?.match(
|
||||
new RegExp(`:occupancy_sensor:${cameraOrZone}_${label}`),
|
||||
),
|
||||
)?.entity_id ?? null;
|
||||
if (entityID) {
|
||||
entityIDs.push(entityID);
|
||||
}
|
||||
};
|
||||
|
||||
if (cameraConfig.frigate.camera_name) {
|
||||
// If zone(s) are specified, the master occupancy sensor for the overall
|
||||
// camera is not used by default (but could be manually added by the
|
||||
// user).
|
||||
const camerasAndZones = cameraConfig.frigate.zones?.length
|
||||
? cameraConfig.frigate.zones
|
||||
: [cameraConfig.frigate.camera_name];
|
||||
|
||||
const labels = cameraConfig.frigate.labels?.length
|
||||
? cameraConfig.frigate.labels
|
||||
: ['all'];
|
||||
for (const cameraOrZone of camerasAndZones) {
|
||||
for (const label of labels) {
|
||||
addEntityIDIfFound(cameraOrZone, label);
|
||||
}
|
||||
}
|
||||
|
||||
if (entityIDs.length) {
|
||||
return entityIDs;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return await camera.initialize(hass, entityRegistryManager);
|
||||
}
|
||||
|
||||
public async getMediaDownloadPath(
|
||||
@@ -904,7 +689,7 @@ export class FrigateCameraManagerEngine
|
||||
cameraID: string,
|
||||
): CameraConfig | null {
|
||||
const cameraConfig = store.getCameraConfig(cameraID);
|
||||
if (!cameraConfig || this._isBirdseye(cameraConfig)) {
|
||||
if (!cameraConfig || isBirdseye(cameraConfig)) {
|
||||
return null;
|
||||
}
|
||||
return cameraConfig;
|
||||
|
||||
@@ -83,6 +83,39 @@ export const ptzInfoSchema = z.object({
|
||||
});
|
||||
export type PTZInfo = z.infer<typeof ptzInfoSchema>;
|
||||
|
||||
// Frigate events as stored in MQTT updates.
|
||||
const frigateEventChangeSchema = z.object({
|
||||
camera: z.string(),
|
||||
snapshot: z
|
||||
.object({
|
||||
frame_time: z.number(),
|
||||
})
|
||||
.nullable(),
|
||||
has_clip: z.boolean(),
|
||||
has_snapshot: z.boolean(),
|
||||
label: z.string(),
|
||||
current_zones: z.string().array(),
|
||||
});
|
||||
export type FrigateEventChange = z.infer<typeof frigateEventChangeSchema>;
|
||||
|
||||
const frigateEventChangeType = z.enum(['new', 'update', 'end']);
|
||||
export type FrigateEventChangeType = z.infer<typeof frigateEventChangeType>;
|
||||
|
||||
export const frigateEventChangeTriggerResponseSchema = z.object({
|
||||
variables: z.object({
|
||||
trigger: z.object({
|
||||
payload_json: z.object({
|
||||
before: frigateEventChangeSchema,
|
||||
after: frigateEventChangeSchema,
|
||||
type: frigateEventChangeType,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
export type FrigateEventChangeTriggerResponse = z.infer<
|
||||
typeof frigateEventChangeTriggerResponseSchema
|
||||
>;
|
||||
|
||||
// ==============================
|
||||
// Frigate concrete query results
|
||||
// ==============================
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
|
||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import {
|
||||
CameraConfig,
|
||||
PTZAction,
|
||||
PTZPhase
|
||||
} from '../../config/types';
|
||||
import { CameraConfig, PTZAction, PTZPhase } from '../../config/types';
|
||||
import { ExtendedHomeAssistant } from '../../types';
|
||||
import { getEntityIcon, getEntityTitle } from '../../utils/ha';
|
||||
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
|
||||
@@ -17,6 +13,7 @@ import {
|
||||
CameraEndpoint,
|
||||
CameraEndpoints,
|
||||
CameraEndpointsContext,
|
||||
CameraEventCallback,
|
||||
CameraManagerCameraMetadata,
|
||||
CameraManagerMediaCapabilities,
|
||||
DataQuery,
|
||||
@@ -33,29 +30,38 @@ import {
|
||||
RecordingQuery,
|
||||
RecordingQueryResultsMap,
|
||||
RecordingSegmentsQuery,
|
||||
RecordingSegmentsQueryResultsMap
|
||||
RecordingSegmentsQueryResultsMap,
|
||||
} from '../types';
|
||||
import { getCameraEntityFromConfig, getDefaultGo2RTCEndpoint } from '../utils.js';
|
||||
|
||||
export class GenericCameraManagerEngine implements CameraManagerEngine {
|
||||
protected _eventCallback?: CameraEventCallback;
|
||||
|
||||
constructor(eventCallback?: CameraEventCallback) {
|
||||
this._eventCallback = eventCallback;
|
||||
}
|
||||
|
||||
public getEngineType(): Engine {
|
||||
return Engine.Generic;
|
||||
}
|
||||
|
||||
public async initializeCamera(
|
||||
_hass: HomeAssistant,
|
||||
_entityRegistryManager: EntityRegistryManager,
|
||||
public async createCamera(
|
||||
hass: HomeAssistant,
|
||||
entityRegistryManager: EntityRegistryManager,
|
||||
cameraConfig: CameraConfig,
|
||||
): Promise<Camera> {
|
||||
return new Camera(cameraConfig, this, {
|
||||
canFavoriteEvents: false,
|
||||
canFavoriteRecordings: false,
|
||||
canSeek: false,
|
||||
supportsClips: false,
|
||||
supportsRecordings: false,
|
||||
supportsSnapshots: false,
|
||||
supportsTimeline: false,
|
||||
});
|
||||
return await new Camera(cameraConfig, this, {
|
||||
capabilities: {
|
||||
canFavoriteEvents: false,
|
||||
canFavoriteRecordings: false,
|
||||
canSeek: false,
|
||||
supportsClips: false,
|
||||
supportsRecordings: false,
|
||||
supportsSnapshots: false,
|
||||
supportsTimeline: false,
|
||||
},
|
||||
eventCallback: this._eventCallback,
|
||||
}).initialize(hass, entityRegistryManager);
|
||||
}
|
||||
|
||||
public generateDefaultEventQuery(
|
||||
|
||||
@@ -47,11 +47,11 @@ import {
|
||||
RecordingSegmentsQuery,
|
||||
RecordingSegmentsQueryResults,
|
||||
RecordingSegmentsQueryResultsMap,
|
||||
ResultsMap
|
||||
ResultsMap,
|
||||
} from './types.js';
|
||||
import { sortMedia } from './utils.js';
|
||||
|
||||
class QueryClassifier {
|
||||
export class QueryClassifier {
|
||||
public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery {
|
||||
return query.type === QueryType.Event;
|
||||
}
|
||||
@@ -72,23 +72,23 @@ class QueryClassifier {
|
||||
}
|
||||
}
|
||||
|
||||
class QueryResultClassifier {
|
||||
export class QueryResultClassifier {
|
||||
public static isEventQueryResult(
|
||||
queryResults: QueryResults,
|
||||
): queryResults is EventQueryResults {
|
||||
return queryResults.type === QueryResultsType.Event;
|
||||
}
|
||||
public static isRecordingQuery(
|
||||
public static isRecordingQueryResult(
|
||||
queryResults: QueryResults,
|
||||
): queryResults is RecordingQueryResults {
|
||||
return queryResults.type === QueryResultsType.Recording;
|
||||
}
|
||||
public static isRecordingSegmentsQuery(
|
||||
public static isRecordingSegmentsQueryResult(
|
||||
queryResults: QueryResults,
|
||||
): queryResults is RecordingSegmentsQueryResults {
|
||||
return queryResults.type === QueryResultsType.RecordingSegments;
|
||||
}
|
||||
public static isMediaMetadataQuery(
|
||||
public static isMediaMetadataQueryResult(
|
||||
queryResults: QueryResults,
|
||||
): queryResults is MediaMetadataQueryResults {
|
||||
return queryResults.type === QueryResultsType.MediaMetadata;
|
||||
@@ -105,13 +105,25 @@ export class CameraManager {
|
||||
protected _engineFactory: CameraManagerEngineFactory;
|
||||
protected _store: CameraManagerStore;
|
||||
|
||||
constructor(api: CardCameraAPI, store?: CameraManagerStore) {
|
||||
constructor(
|
||||
api: CardCameraAPI,
|
||||
options?: {
|
||||
store?: CameraManagerStore;
|
||||
factory?: CameraManagerEngineFactory;
|
||||
},
|
||||
) {
|
||||
this._api = api;
|
||||
this._engineFactory = new CameraManagerEngineFactory(
|
||||
this._api.getEntityRegistryManager(),
|
||||
this._api.getResolvedMediaCache(),
|
||||
);
|
||||
this._store = store ?? new CameraManagerStore();
|
||||
this._engineFactory =
|
||||
options?.factory ??
|
||||
new CameraManagerEngineFactory(
|
||||
this._api.getEntityRegistryManager(),
|
||||
this._api.getResolvedMediaCache(),
|
||||
);
|
||||
this._store = options?.store ?? new CameraManagerStore();
|
||||
}
|
||||
|
||||
public async reset(): Promise<void> {
|
||||
await this._store.reset();
|
||||
}
|
||||
|
||||
public async initializeCamerasFromConfig(): Promise<boolean> {
|
||||
@@ -122,7 +134,7 @@ export class CameraManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._store.reset();
|
||||
await this.reset();
|
||||
|
||||
// For each camera merge the config (which has no defaults) into the camera
|
||||
// global config (which does have defaults). The merging must happen in this
|
||||
@@ -148,6 +160,7 @@ export class CameraManager {
|
||||
const engines: Map<Engine, CameraManagerEngine> = new Map();
|
||||
const hass = this._api.getHASSManager().getHASS();
|
||||
|
||||
/* istanbul ignore if: the if path cannot be reached -- @preserve */
|
||||
if (!hass) {
|
||||
return output;
|
||||
}
|
||||
@@ -162,7 +175,10 @@ export class CameraManager {
|
||||
for (const [index, cameraConfig] of camerasConfig.entries()) {
|
||||
const engineType = engineTypes[index];
|
||||
const engine = engineType
|
||||
? engines.get(engineType) ?? (await this._engineFactory.createEngine(engineType))
|
||||
? engines.get(engineType) ??
|
||||
(await this._engineFactory.createEngine(engineType, (ev) =>
|
||||
this._api.getTriggersManager().handleCameraEvent(ev),
|
||||
))
|
||||
: null;
|
||||
if (!engine || !engineType) {
|
||||
throw new CameraInitializationError(
|
||||
@@ -182,6 +198,7 @@ export class CameraManager {
|
||||
const initializationStartTime = new Date();
|
||||
const hass = this._api.getHASSManager().getHASS();
|
||||
|
||||
/* istanbul ignore if: the if path cannot be reached -- @preserve */
|
||||
if (!hass) {
|
||||
return;
|
||||
}
|
||||
@@ -208,7 +225,7 @@ export class CameraManager {
|
||||
const cameras = await allPromises(
|
||||
engineByConfig.entries(),
|
||||
async ([cameraConfig, engine]) =>
|
||||
await engine.initializeCamera(
|
||||
await engine.createCamera(
|
||||
hass,
|
||||
this._api.getEntityRegistryManager(),
|
||||
cameraConfig,
|
||||
@@ -292,45 +309,6 @@ export class CameraManager {
|
||||
});
|
||||
}
|
||||
|
||||
public async getMediaMetadata(): Promise<MediaMetadata | null> {
|
||||
const tags: Set<string> = new Set();
|
||||
const what: Set<string> = new Set();
|
||||
const where: Set<string> = new Set();
|
||||
const days: Set<string> = new Set();
|
||||
|
||||
const query: MediaMetadataQuery = {
|
||||
type: QueryType.MediaMetadata,
|
||||
cameraIDs: this._store.getCameraIDs(),
|
||||
};
|
||||
|
||||
const results = await this._handleQuery(query);
|
||||
|
||||
for (const result of results?.values() ?? []) {
|
||||
if (result.metadata.tags) {
|
||||
result.metadata.tags.forEach(tags.add, tags);
|
||||
}
|
||||
if (result.metadata.what) {
|
||||
result.metadata.what.forEach(what.add, what);
|
||||
}
|
||||
if (result.metadata.where) {
|
||||
result.metadata.where.forEach(where.add, where);
|
||||
}
|
||||
if (result.metadata.days) {
|
||||
result.metadata.days.forEach(days.add, days);
|
||||
}
|
||||
}
|
||||
|
||||
if (!what.size && !where.size && !days.size) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...(tags.size && { tags: tags }),
|
||||
...(what.size && { what: what }),
|
||||
...(where.size && { where: where }),
|
||||
...(days.size && { days: days }),
|
||||
};
|
||||
}
|
||||
|
||||
protected _generateDefaultQueries<PQT extends PartialDataQuery>(
|
||||
cameraIDs: string | Set<string>,
|
||||
partialQuery: PQT,
|
||||
@@ -338,13 +316,13 @@ export class CameraManager {
|
||||
const concreteQueries: PartialQueryConcreteType<PQT>[] = [];
|
||||
const _cameraIDs = setify(cameraIDs);
|
||||
const engines = this._store.getEnginesForCameraIDs(_cameraIDs);
|
||||
|
||||
if (!engines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const [engine, cameraIDs] of engines) {
|
||||
let queries: DataQuery[] | null = null;
|
||||
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
||||
if (QueryClassifier.isEventQuery(partialQuery)) {
|
||||
queries = engine.generateDefaultEventQuery(this._store, cameraIDs, partialQuery);
|
||||
} else if (QueryClassifier.isRecordingQuery(partialQuery)) {
|
||||
@@ -368,6 +346,45 @@ export class CameraManager {
|
||||
return concreteQueries.length ? concreteQueries : null;
|
||||
}
|
||||
|
||||
public async getMediaMetadata(): Promise<MediaMetadata | null> {
|
||||
const tags: Set<string> = new Set();
|
||||
const what: Set<string> = new Set();
|
||||
const where: Set<string> = new Set();
|
||||
const days: Set<string> = new Set();
|
||||
|
||||
const query: MediaMetadataQuery = {
|
||||
type: QueryType.MediaMetadata,
|
||||
cameraIDs: this._store.getCameraIDs(),
|
||||
};
|
||||
|
||||
const results = await this._handleQuery(query);
|
||||
|
||||
for (const result of results.values()) {
|
||||
if (result.metadata.tags) {
|
||||
result.metadata.tags.forEach(tags.add, tags);
|
||||
}
|
||||
if (result.metadata.what) {
|
||||
result.metadata.what.forEach(what.add, what);
|
||||
}
|
||||
if (result.metadata.where) {
|
||||
result.metadata.where.forEach(where.add, where);
|
||||
}
|
||||
if (result.metadata.days) {
|
||||
result.metadata.days.forEach(days.add, days);
|
||||
}
|
||||
}
|
||||
|
||||
if (!what.size && !where.size && !days.size && !tags.size) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...(tags.size && { tags: tags }),
|
||||
...(what.size && { what: what }),
|
||||
...(where.size && { where: where }),
|
||||
...(days.size && { days: days }),
|
||||
};
|
||||
}
|
||||
|
||||
public async getEvents(
|
||||
query: EventQuery | EventQuery[],
|
||||
engineOptions?: EngineOptions,
|
||||
@@ -438,15 +455,18 @@ export class CameraManager {
|
||||
for (const query of queries) {
|
||||
const newChunkQuery = { ...query };
|
||||
|
||||
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
||||
if (direction === 'later') {
|
||||
const latestResult = getTimeFromResults('latest');
|
||||
if (latestResult) {
|
||||
newChunkQuery.start = latestResult;
|
||||
delete(newChunkQuery.end);
|
||||
}
|
||||
} else if (direction === 'earlier') {
|
||||
const earliestResult = getTimeFromResults('earliest');
|
||||
if (earliestResult) {
|
||||
newChunkQuery.end = earliestResult;
|
||||
delete(newChunkQuery.start);
|
||||
}
|
||||
}
|
||||
newChunkQuery.limit = chunkSize;
|
||||
@@ -550,13 +570,11 @@ export class CameraManager {
|
||||
public async getMediaSeekTime(media: ViewMedia, target: Date): Promise<number | null> {
|
||||
const startTime = media.getStartTime();
|
||||
const endTime = media.getEndTime();
|
||||
const cameraConfig = this._store.getCameraConfigForMedia(media);
|
||||
const engine = this._store.getEngineForMedia(media);
|
||||
const hass = this._api.getHASSManager().getHASS();
|
||||
|
||||
if (
|
||||
!hass ||
|
||||
!cameraConfig ||
|
||||
!engine ||
|
||||
!startTime ||
|
||||
!endTime ||
|
||||
@@ -584,13 +602,11 @@ export class CameraManager {
|
||||
|
||||
const processEngineQuery = async (
|
||||
engine: CameraManagerEngine,
|
||||
query?: QT,
|
||||
query: QT,
|
||||
): Promise<void> => {
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
let engineResult: Map<QT, QueryReturnType<QT>> | null = null;
|
||||
|
||||
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
||||
if (QueryClassifier.isEventQuery(query)) {
|
||||
engineResult = (await engine.getEvents(
|
||||
hass,
|
||||
@@ -676,6 +692,7 @@ export class CameraManager {
|
||||
|
||||
if (engine) {
|
||||
let media: ViewMedia[] | null = null;
|
||||
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
||||
if (
|
||||
QueryClassifier.isEventQuery(query) &&
|
||||
QueryResultClassifier.isEventQueryResult(result)
|
||||
@@ -683,7 +700,7 @@ export class CameraManager {
|
||||
media = engine.generateMediaFromEvents(hass, this._store, query, result);
|
||||
} else if (
|
||||
QueryClassifier.isRecordingQuery(query) &&
|
||||
QueryResultClassifier.isRecordingQuery(result)
|
||||
QueryResultClassifier.isRecordingQueryResult(result)
|
||||
) {
|
||||
media = engine.generateMediaFromRecordings(hass, this._store, query, result);
|
||||
}
|
||||
|
||||
@@ -8,18 +8,21 @@ import { CameraConfig } from '../../config/types';
|
||||
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
|
||||
import {
|
||||
BrowseMediaStep,
|
||||
BrowseMediaTarget
|
||||
BrowseMediaTarget,
|
||||
} from '../../utils/ha/browse-media/browse-media-manager';
|
||||
import {
|
||||
BrowseMedia, BROWSE_MEDIA_CACHE_SECONDS, MEDIA_CLASS_IMAGE,
|
||||
BrowseMedia,
|
||||
BROWSE_MEDIA_CACHE_SECONDS,
|
||||
MEDIA_CLASS_IMAGE,
|
||||
MEDIA_CLASS_VIDEO,
|
||||
RichBrowseMedia
|
||||
RichBrowseMedia,
|
||||
} from '../../utils/ha/browse-media/types';
|
||||
import { ViewMedia } from '../../view/media';
|
||||
import { BrowseMediaCamera } from '../browse-media/camera';
|
||||
import {
|
||||
BrowseMediaCameraManagerEngine,
|
||||
getViewMediaFromBrowseMediaArray,
|
||||
isMediaWithinDates
|
||||
isMediaWithinDates,
|
||||
} from '../browse-media/engine-browse-media';
|
||||
import { BrowseMediaMetadata } from '../browse-media/types';
|
||||
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
|
||||
@@ -39,7 +42,7 @@ import {
|
||||
MediaMetadataQueryResultsMap,
|
||||
QueryResults,
|
||||
QueryResultsType,
|
||||
QueryReturnType
|
||||
QueryReturnType,
|
||||
} from '../types';
|
||||
import motioneyeLogo from './assets/motioneye-logo.svg';
|
||||
import { MotionEyeEventQueryResults } from './types';
|
||||
@@ -134,13 +137,18 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
|
||||
} | null,
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<RichBrowseMedia<BrowseMediaMetadata>[] | null> {
|
||||
const cameraConfig = store.getCameraConfig(cameraID);
|
||||
const cameraEntityID = cameraConfig?.camera_entity;
|
||||
const entity = cameraEntityID ? this._cameraEntities.get(cameraEntityID) : null;
|
||||
const camera = store.getCamera(cameraID);
|
||||
const cameraConfig = camera?.getConfig();
|
||||
|
||||
if (!(camera instanceof BrowseMediaCamera) || !cameraConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entity = camera.getEntity();
|
||||
const configID = entity?.config_entry_id;
|
||||
const deviceID = entity?.device_id;
|
||||
|
||||
if (!configID || !deviceID || !cameraConfig) {
|
||||
if (!configID || !deviceID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CameraConfig } from '../config/types';
|
||||
import { allPromises } from '../utils/basic';
|
||||
import { ViewMedia } from '../view/media';
|
||||
import { Camera } from './camera';
|
||||
import { CameraManagerEngine } from './engine';
|
||||
@@ -35,7 +36,9 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
|
||||
this._cameras.set(camera.getID(), camera);
|
||||
this._enginesByType.set(camera.getEngine().getEngineType(), camera.getEngine());
|
||||
}
|
||||
public reset(): void {
|
||||
|
||||
public async reset(): Promise<void> {
|
||||
await allPromises(this._cameras.values(), (camera) => camera.destroy());
|
||||
this._cameras.clear();
|
||||
this._enginesByType.clear();
|
||||
}
|
||||
|
||||
@@ -150,6 +150,22 @@ export interface EngineOptions {
|
||||
useCache?: boolean;
|
||||
}
|
||||
|
||||
export interface CameraEvent {
|
||||
cameraID: string;
|
||||
|
||||
type: 'new' | 'update' | 'end';
|
||||
|
||||
// When fidelity is `high`, the engine is assumed to provide exact details of
|
||||
// what new media is available. Otherwise all media types are assumed to be
|
||||
// possibly newly available.
|
||||
fidelity?: 'high' | 'low';
|
||||
|
||||
// Whether a new clip/snapshot/recording may be available.
|
||||
clip?: boolean;
|
||||
snapshot?: boolean;
|
||||
}
|
||||
export type CameraEventCallback = (ev: CameraEvent) => void;
|
||||
|
||||
// ===========
|
||||
// Event Query
|
||||
// ===========
|
||||
|
||||
@@ -51,7 +51,7 @@ export const sortMedia = (mediaArray: ViewMedia[]): ViewMedia[] => {
|
||||
|
||||
// Sort all items leading oldest -> youngest (so media is loaded in this
|
||||
// order in the viewer which matches the left-to-right timeline order).
|
||||
(media) => media.getStartTime(),
|
||||
(media) => media.getStartTime() ?? media.getID(),
|
||||
'asc',
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { LitElement, ReactiveControllerHost } from 'lit';
|
||||
import { ActionEventTarget } from '../action-handler-directive';
|
||||
import { setOrRemoveAttribute } from '../utils/basic';
|
||||
import { isCardInPanel } from '../utils/ha';
|
||||
import { InitializationAspect } from './initialization-manager';
|
||||
import { CardElementAPI } from './types';
|
||||
|
||||
export type ScrollCallback = () => void;
|
||||
@@ -103,6 +104,16 @@ export class CardElementManager {
|
||||
this._api.getMediaLoadedInfoManager().clear();
|
||||
this._api.getFullscreenManager().disconnect();
|
||||
|
||||
// Reset and uninitialize cameras to cause them to reinitialize on
|
||||
// reconnection, to ensure the state subscription/unsubscription works
|
||||
// correctly for triggers.
|
||||
this._api
|
||||
.getCameraManager()
|
||||
.reset()
|
||||
.then(() =>
|
||||
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS),
|
||||
);
|
||||
|
||||
this._element.removeEventListener(
|
||||
'mousemove',
|
||||
this._api.getInteractionManager().reportInteraction,
|
||||
|
||||
@@ -192,6 +192,10 @@ export class ConditionsManager {
|
||||
};
|
||||
this._triggerChange();
|
||||
}
|
||||
|
||||
public getState(): ConditionState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
public hasHAStateConditions(): boolean {
|
||||
return this._hasHAStateConditions;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { CameraConfig } from '../config/types';
|
||||
import { localize } from '../localize/localize';
|
||||
import { ExtendedHomeAssistant } from '../types';
|
||||
import { hasHAConnectionStateChanged, isHassDifferent } from '../utils/ha';
|
||||
@@ -17,15 +16,6 @@ export class HASSManager {
|
||||
}
|
||||
|
||||
public setHASS(hass?: ExtendedHomeAssistant | null): void {
|
||||
const getSelectedCameraConfig = (): CameraConfig | null => {
|
||||
const view = this._api.getViewManager().getView();
|
||||
const cameraManager = this._api.getCameraManager();
|
||||
|
||||
return view && cameraManager
|
||||
? cameraManager?.getStore().getCameraConfig(view.camera) ?? null
|
||||
: null;
|
||||
};
|
||||
|
||||
if (hasHAConnectionStateChanged(this._hass, hass)) {
|
||||
if (!hass?.connected) {
|
||||
this._api.getMessageManager().setMessageIfHigherPriority({
|
||||
@@ -54,10 +44,11 @@ export class HASSManager {
|
||||
// Assistant update if there's been recent interaction (e.g. clicks on the
|
||||
// card) or if there is media active playing.
|
||||
this._isAutomatedViewUpdateAllowed() &&
|
||||
isHassDifferent(this._hass, oldHass, [
|
||||
...(this._api.getConfigManager().getConfig()?.view.update_entities ?? []),
|
||||
...(getSelectedCameraConfig()?.triggers.entities ?? []),
|
||||
])
|
||||
isHassDifferent(
|
||||
this._hass,
|
||||
oldHass,
|
||||
this._api.getConfigManager().getConfig()?.view.update_entities ?? [],
|
||||
)
|
||||
) {
|
||||
// If entities being monitored have changed then reset the view to the
|
||||
// default.
|
||||
@@ -74,8 +65,6 @@ export class HASSManager {
|
||||
this._api.getCardElementManager().update();
|
||||
}
|
||||
|
||||
this._api.getTriggersManager().updateTriggerHAState(oldHass);
|
||||
|
||||
if (this._api.getConditionsManager().hasHAStateConditions()) {
|
||||
this._api.getConditionsManager().setState({ state: this._hass.states });
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
import { getHassDifferences, isTriggeredState } from '../utils/ha';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
import { CameraEvent } from '../camera-manager/types';
|
||||
import { Timer } from '../utils/timer';
|
||||
import { View } from '../view/view';
|
||||
import { CardTriggersAPI } from './types';
|
||||
|
||||
export class TriggersManager {
|
||||
@@ -10,7 +10,10 @@ export class TriggersManager {
|
||||
|
||||
protected _triggeredCameras: Map<string, Date> = new Map();
|
||||
protected _triggeredCameraTimers: Map<string, Timer> = new Map();
|
||||
protected _triggeredState: Set<string> = new Set();
|
||||
|
||||
protected _throttledTriggerAction = throttle(this._triggerAction.bind(this), 1000, {
|
||||
trailing: true,
|
||||
});
|
||||
|
||||
constructor(api: CardTriggersAPI) {
|
||||
this._api = api;
|
||||
@@ -33,125 +36,112 @@ export class TriggersManager {
|
||||
return sorted.length ? sorted[0][0] : null;
|
||||
}
|
||||
|
||||
public updateTriggerHAState(oldHass?: HomeAssistant | null): void {
|
||||
const scanConfig = this._api.getConfigManager().getConfig()?.view.scan;
|
||||
if (!scanConfig || !scanConfig.enabled) {
|
||||
public handleCameraEvent(ev: CameraEvent): void {
|
||||
const triggersConfig = this._api.getConfigManager().getConfig()?.view.triggers;
|
||||
const selectedCameraID = this._api.getViewManager().getView()?.camera;
|
||||
|
||||
if (!triggersConfig || !selectedCameraID) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hass = this._api.getHASSManager().getHASS();
|
||||
let triggerChanges = false;
|
||||
|
||||
const visibleCameraIDs = this._api
|
||||
const dependentCameraIDs = this._api
|
||||
.getCameraManager()
|
||||
.getStore()
|
||||
.getVisibleCameraIDs();
|
||||
for (const [cameraID, config] of this._api
|
||||
.getCameraManager()
|
||||
.getStore()
|
||||
.getCameraConfigEntries(visibleCameraIDs)) {
|
||||
const triggerEntities = config.triggers.entities;
|
||||
const diffs = getHassDifferences(hass, oldHass, triggerEntities, {
|
||||
stateOnly: true,
|
||||
});
|
||||
const shouldTrigger = diffs.some((diff) => isTriggeredState(diff.newState));
|
||||
const shouldUntrigger = triggerEntities.every(
|
||||
(entity) => !isTriggeredState(hass?.states[entity]),
|
||||
);
|
||||
if (shouldTrigger) {
|
||||
this._triggeredState.add(cameraID);
|
||||
triggerChanges = true;
|
||||
} else if (shouldUntrigger && this._triggeredState.has(cameraID)) {
|
||||
this._triggeredState.delete(cameraID);
|
||||
triggerChanges = true;
|
||||
}
|
||||
}
|
||||
.getAllDependentCameras(selectedCameraID);
|
||||
|
||||
if (triggerChanges) {
|
||||
this._evaluateTriggers();
|
||||
}
|
||||
}
|
||||
|
||||
public updateView(oldView?: View | null): void {
|
||||
if (oldView?.camera !== this._api.getViewManager().getView()?.camera) {
|
||||
// If the view changes, a new camera may have been selected, which may
|
||||
// mean a trigger is required (in the case that `filter_selected_camera`
|
||||
// is true).
|
||||
this._evaluateTriggers();
|
||||
}
|
||||
}
|
||||
|
||||
protected _evaluateTriggers(): void {
|
||||
const scanConfig = this._api.getConfigManager().getConfig()?.view.scan;
|
||||
if (!scanConfig) {
|
||||
if (triggersConfig.filter_selected_camera && !dependentCameraIDs.has(ev.cameraID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
for (const cameraID of this._triggeredState.keys()) {
|
||||
if (
|
||||
!this._triggeredCameras.has(cameraID) &&
|
||||
(!scanConfig.filter_selected_camera ||
|
||||
cameraID === this._api.getViewManager().getView()?.camera)
|
||||
) {
|
||||
this._triggeredCameras.set(cameraID, now);
|
||||
this._setConditionState();
|
||||
this._triggerAction(cameraID);
|
||||
}
|
||||
if (ev.type === 'end') {
|
||||
this._startUntriggerTimer(ev.cameraID);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const cameraID of this._triggeredCameras.keys()) {
|
||||
if (!this._triggeredState.has(cameraID)) {
|
||||
this._startUntriggerTimer(cameraID);
|
||||
}
|
||||
}
|
||||
this._triggeredCameras.set(ev.cameraID, new Date());
|
||||
this._setConditionStateIfNecessary();
|
||||
this._throttledTriggerAction(ev);
|
||||
}
|
||||
|
||||
protected _hasAllowableInteractionStateForAction(): boolean {
|
||||
const scanConfig = this._api.getConfigManager().getConfig()?.view.scan;
|
||||
const triggersConfig = this._api.getConfigManager().getConfig()?.view.triggers;
|
||||
const hasInteraction = this._api.getInteractionManager().hasInteraction();
|
||||
|
||||
return (
|
||||
!!scanConfig &&
|
||||
(scanConfig.actions.interaction_mode === 'all' ||
|
||||
(scanConfig.actions.interaction_mode === 'active' && hasInteraction) ||
|
||||
(scanConfig.actions.interaction_mode === 'inactive' && !hasInteraction))
|
||||
!!triggersConfig &&
|
||||
(triggersConfig.actions.interaction_mode === 'all' ||
|
||||
(triggersConfig.actions.interaction_mode === 'active' && hasInteraction) ||
|
||||
(triggersConfig.actions.interaction_mode === 'inactive' && !hasInteraction))
|
||||
);
|
||||
}
|
||||
|
||||
protected _triggerAction(cameraID: string): void {
|
||||
const action = this._api.getConfigManager().getConfig()?.view.scan.actions.trigger;
|
||||
protected _triggerAction(ev: CameraEvent): void {
|
||||
const triggerAction = this._api.getConfigManager().getConfig()?.view.triggers
|
||||
.actions.trigger;
|
||||
const defaultView = this._api.getConfigManager().getConfig()?.view.default;
|
||||
|
||||
if (action === 'live' && this._hasAllowableInteractionStateForAction()) {
|
||||
this._api.getViewManager().setViewByParameters({
|
||||
viewName: 'live',
|
||||
cameraID: cameraID,
|
||||
});
|
||||
// If this is a high-fidelity event where we are certain about new media,
|
||||
// don't take action unless it's to change to live (Frigate engine may pump
|
||||
// out events where there's no new media to show).
|
||||
if (
|
||||
ev.fidelity === 'high' &&
|
||||
!ev.snapshot &&
|
||||
!ev.clip &&
|
||||
!(
|
||||
triggerAction === 'live' ||
|
||||
(triggerAction === 'default' && defaultView === 'live')
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Must update master element to add border pulsing.
|
||||
if (this._hasAllowableInteractionStateForAction()) {
|
||||
if (triggerAction === 'live') {
|
||||
this._api.getViewManager().setViewByParameters({
|
||||
viewName: 'live',
|
||||
cameraID: ev.cameraID,
|
||||
});
|
||||
} else if (triggerAction === 'default') {
|
||||
this._api.getViewManager().setViewDefault({
|
||||
cameraID: ev.cameraID,
|
||||
});
|
||||
} else if (ev.fidelity === 'high' && triggerAction === 'media') {
|
||||
this._api.getViewManager().setViewByParameters({
|
||||
viewName: ev.clip ? 'clip' : 'snapshot',
|
||||
cameraID: ev.cameraID,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Must update master element to add border pulsing to live view.
|
||||
this._api.getCardElementManager().update();
|
||||
}
|
||||
|
||||
protected _setConditionState(): void {
|
||||
this._api.getConditionsManager().setState({
|
||||
triggered: this._triggeredCameras.size
|
||||
? new Set(this._triggeredCameras.keys())
|
||||
: undefined,
|
||||
});
|
||||
protected _setConditionStateIfNecessary(): void {
|
||||
const triggeredCameraIDs = new Set(this._triggeredCameras.keys());
|
||||
const triggeredState = triggeredCameraIDs.size ? triggeredCameraIDs : undefined;
|
||||
|
||||
if (
|
||||
!isEqual(triggeredState, this._api.getConditionsManager().getState().triggered)
|
||||
) {
|
||||
this._api.getConditionsManager().setState({
|
||||
triggered: triggeredState,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected _untriggerAction(cameraID: string): void {
|
||||
const action = this._api.getConfigManager().getConfig()?.view.scan.actions.untrigger;
|
||||
const action = this._api.getConfigManager().getConfig()?.view.triggers
|
||||
.actions.untrigger;
|
||||
|
||||
if (action === 'default' && this._hasAllowableInteractionStateForAction()) {
|
||||
this._api.getViewManager().setViewDefault();
|
||||
}
|
||||
this._triggeredCameras.delete(cameraID);
|
||||
this._deleteTimer(cameraID);
|
||||
this._setConditionState();
|
||||
this._setConditionStateIfNecessary();
|
||||
|
||||
// Must update master element to remove border pulsing.
|
||||
// Must update master element to remove border pulsing from live view.
|
||||
this._api.getCardElementManager().update();
|
||||
}
|
||||
|
||||
@@ -164,7 +154,7 @@ export class TriggersManager {
|
||||
/* istanbul ignore next: the case of config being null here cannot be
|
||||
reached, as there's no way to have the untrigger call happen without
|
||||
a config. -- @preserve */
|
||||
this._api.getConfigManager().getConfig()?.view.scan.untrigger_seconds ?? 0,
|
||||
this._api.getConfigManager().getConfig()?.view.triggers.untrigger_seconds ?? 0,
|
||||
() => {
|
||||
this._untriggerAction(cameraID);
|
||||
},
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { ConditionsManager } from './conditions-manager';
|
||||
import { EntityRegistryManager } from '../utils/ha/entity-registry';
|
||||
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
|
||||
import { ActionsManager } from './actions-manager';
|
||||
import { AutoUpdateManager } from './auto-update-manager';
|
||||
import { AutomationsManager } from './automations-manager';
|
||||
import { CameraURLManager } from './camera-url-manager';
|
||||
import { CardElementManager } from './card-element-manager';
|
||||
import { ConfigManager } from './config-manager';
|
||||
import { DownloadManager } from './download-manager';
|
||||
import { ExpandManager } from './expand-manager';
|
||||
import { FullscreenManager } from './fullscreen-manager';
|
||||
import { HASSManager } from './hass-manager';
|
||||
import { InitializationManager } from './initialization-manager';
|
||||
import { InteractionManager } from './interaction-manager';
|
||||
import { MediaLoadedInfoManager } from './media-info-manager';
|
||||
import { MediaPlayerManager } from './media-player-manager';
|
||||
import { MessageManager } from './message-manager';
|
||||
import { MicrophoneManager } from './microphone-manager';
|
||||
import { StyleManager } from './style-manager';
|
||||
import { TriggersManager } from './triggers-manager';
|
||||
import { ViewManager } from './view-manager';
|
||||
import { QueryStringManager } from './query-string-manager';
|
||||
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 { 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 { DownloadManager } from './download-manager';
|
||||
import type { ExpandManager } from './expand-manager';
|
||||
import type { FullscreenManager } from './fullscreen-manager';
|
||||
import type { HASSManager } from './hass-manager';
|
||||
import type { InitializationManager } from './initialization-manager';
|
||||
import type { InteractionManager } from './interaction-manager';
|
||||
import type { MediaLoadedInfoManager } from './media-info-manager';
|
||||
import type { MediaPlayerManager } from './media-player-manager';
|
||||
import type { MessageManager } from './message-manager';
|
||||
import type { MicrophoneManager } from './microphone-manager';
|
||||
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';
|
||||
|
||||
/**
|
||||
* This defines a series of limited APIs that various manager helpers use to
|
||||
@@ -67,6 +67,7 @@ export interface CardCameraAPI {
|
||||
getHASSManager(): HASSManager;
|
||||
getMessageManager(): MessageManager;
|
||||
getResolvedMediaCache(): ResolvedMediaCache;
|
||||
getTriggersManager(): TriggersManager;
|
||||
}
|
||||
|
||||
export interface CardCameraURLAPI {
|
||||
@@ -100,8 +101,10 @@ export interface CardDownloadAPI {
|
||||
|
||||
export interface CardElementAPI {
|
||||
getActionsManager(): ActionsManager;
|
||||
getCameraManager(): CameraManager;
|
||||
getExpandManager(): ExpandManager;
|
||||
getFullscreenManager(): FullscreenManager;
|
||||
getInitializationManager(): InitializationManager;
|
||||
getInteractionManager(): InteractionManager;
|
||||
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
||||
getMicrophoneManager(): MicrophoneManager;
|
||||
@@ -205,7 +208,6 @@ export interface CardTriggersAPI {
|
||||
getConditionsManager(): ConditionsManager;
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getHASSManager(): HASSManager;
|
||||
getInteractionManager(): InteractionManager;
|
||||
getViewManager(): ViewManager;
|
||||
}
|
||||
|
||||
@@ -245,7 +245,6 @@ export class ViewManager {
|
||||
displayMode: view.displayMode ?? undefined,
|
||||
});
|
||||
|
||||
this._api.getTriggersManager().updateView(oldView);
|
||||
this._api.getCardElementManager().update();
|
||||
}
|
||||
|
||||
|
||||
+8
-1
@@ -163,6 +163,13 @@ class FrigateCard extends LitElement {
|
||||
}
|
||||
|
||||
protected shouldUpdate(): boolean {
|
||||
// Do not allow a disconnected element to update, as it may cause cameras to
|
||||
// reinitialize/subscribe for an element that is no longer part of the
|
||||
// document.
|
||||
if (!this.isConnected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Always allow messages to render, as a message may be generated during
|
||||
// initialization.
|
||||
if (this._controller.getMessageManager().hasMessage()) {
|
||||
@@ -307,7 +314,7 @@ class FrigateCard extends LitElement {
|
||||
?.getEpoch()}
|
||||
.hide=${!!this._controller.getMessageManager().hasMessage()}
|
||||
.microphoneManager=${this._controller.getMicrophoneManager()}
|
||||
.triggeredCameraIDs=${this._config?.view.scan.show_trigger_status
|
||||
.triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status
|
||||
? this._controller.getTriggersManager().getTriggeredCameraIDs()
|
||||
: undefined}
|
||||
></frigate-card-views>`}
|
||||
|
||||
@@ -29,6 +29,10 @@ export interface FrigateCardTimelineItem extends TimelineItem {
|
||||
// high-quantity recording segments).
|
||||
start: number;
|
||||
end?: number;
|
||||
|
||||
// DataSet requires string (not HTMLElement) content.
|
||||
content: string;
|
||||
|
||||
media?: ViewMedia;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ export class VideoRTC extends HTMLElement {
|
||||
pcConfig: RTCConfiguration;
|
||||
wsState: number;
|
||||
pcState: number;
|
||||
video: HTMLVideoElement;
|
||||
video: HTMLVideoElement | null;
|
||||
ws: WebSocket | null;
|
||||
wsURL: string;
|
||||
pc: RTCPeerConnection | null;
|
||||
|
||||
@@ -71,7 +71,7 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
|
||||
}
|
||||
|
||||
public isMuted(): boolean {
|
||||
return this._player?.video.muted ?? true;
|
||||
return this._player?.video?.muted ?? true;
|
||||
}
|
||||
|
||||
public async seek(seconds: number): Promise<void> {
|
||||
@@ -87,11 +87,11 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
|
||||
}
|
||||
|
||||
public isPaused(): boolean {
|
||||
return this._player?.video.paused ?? true;
|
||||
return this._player?.video?.paused ?? true;
|
||||
}
|
||||
|
||||
public async getScreenshotURL(): Promise<string | null> {
|
||||
return this._player ? screenshotMedia(this._player.video) : null;
|
||||
return this._player?.video ? screenshotMedia(this._player.video) : null;
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
|
||||
@@ -133,7 +133,7 @@ export class FrigateCardSurround extends LitElement {
|
||||
*/
|
||||
protected willUpdate(changedProperties: PropertyValues): void {
|
||||
if (this.timelineConfig?.mode && this.timelineConfig.mode !== 'none') {
|
||||
import('./timeline.js');
|
||||
import('./timeline-core.js');
|
||||
}
|
||||
|
||||
// Only reset the timeline cameraIDs when the media or display mode
|
||||
|
||||
@@ -139,7 +139,7 @@ export class FrigateCardViewer extends LitElement {
|
||||
const mediaType = this.view.getDefaultMediaType();
|
||||
if (!mediaType) {
|
||||
// Directly render an error message (instead of dispatching it upwards)
|
||||
// to preserve the mini-timeline if the user scans into an area with no
|
||||
// to preserve the mini-timeline if the user pans into an area with no
|
||||
// media.
|
||||
return renderMessage({
|
||||
type: 'info',
|
||||
@@ -157,6 +157,7 @@ export class FrigateCardViewer extends LitElement {
|
||||
{
|
||||
allCameras: this.view.isGrid(),
|
||||
targetView: 'recording',
|
||||
useCache: false,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
@@ -169,6 +170,7 @@ export class FrigateCardViewer extends LitElement {
|
||||
allCameras: this.view.isGrid(),
|
||||
targetView: 'media',
|
||||
eventsMediaType: mediaType,
|
||||
useCache: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+42
-13
@@ -18,7 +18,10 @@ import {
|
||||
CONF_OVERRIDES,
|
||||
CONF_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
CONF_VIEW_INTERACTION_SECONDS,
|
||||
CONF_VIEW_SCAN_ACTIONS_UNTRIGGER,
|
||||
CONF_VIEW_TRIGGERS,
|
||||
CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER,
|
||||
CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER,
|
||||
CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
|
||||
} from './const';
|
||||
import { arrayify } from './utils/basic';
|
||||
|
||||
@@ -213,6 +216,18 @@ export const upgradeWithOverrides = function (
|
||||
return upgradeMoveToWithOverrides(path, path, { transform: transform });
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a property in place with overrides.
|
||||
* @param path The property path.
|
||||
* @returns A function that returns `true` if the configuration was modified.
|
||||
*/
|
||||
export const deleteWithOverrides = function (
|
||||
path: string,
|
||||
): (obj: RawFrigateCardConfig) => boolean {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
return upgradeMoveToWithOverrides(path, path, { transform: (_) => null });
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a path to an array, apply an upgrade to each object in the array.
|
||||
* @param arrayPath The path to the array to upgrade.
|
||||
@@ -480,14 +495,6 @@ const UPGRADES = [
|
||||
);
|
||||
},
|
||||
upgradePTZElementsToLive(),
|
||||
upgradeMoveToWithOverrides(
|
||||
'view.scan.untrigger_reset',
|
||||
CONF_VIEW_SCAN_ACTIONS_UNTRIGGER,
|
||||
{
|
||||
// Delete the value if it's set to the default.
|
||||
transform: (val) => (val ? 'default' : null),
|
||||
},
|
||||
),
|
||||
upgradeMoveToWithOverrides('view.timeout_seconds', CONF_VIEW_INTERACTION_SECONDS),
|
||||
upgradeWithOverrides('live.lazy_unload', (data) =>
|
||||
data === 'all' ? ['unselected', 'hidden'] : data === 'never' ? null : arrayify(data),
|
||||
@@ -524,10 +531,7 @@ const UPGRADES = [
|
||||
'live.controls.thumbnails.media',
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE,
|
||||
),
|
||||
upgradeMoveToWithOverrides(
|
||||
'timeline.media',
|
||||
CONF_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
),
|
||||
upgradeMoveToWithOverrides('timeline.media', CONF_TIMELINE_EVENTS_MEDIA_TYPE),
|
||||
upgradeMoveToWithOverrides(
|
||||
'live.controls.timeline.media',
|
||||
CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
@@ -536,4 +540,29 @@ const UPGRADES = [
|
||||
'media_viewer.controls.timeline.media',
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
),
|
||||
upgradeMoveToWithOverrides('view.scan', CONF_VIEW_TRIGGERS),
|
||||
upgradeMoveToWithOverrides(
|
||||
'view.triggers.enabled',
|
||||
CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER,
|
||||
{
|
||||
transform: (data) => (data === true ? 'live' : null),
|
||||
// Keep it around, for the following transform.
|
||||
keepOriginal: true,
|
||||
},
|
||||
),
|
||||
upgradeMoveToWithOverrides(
|
||||
'view.triggers.enabled',
|
||||
CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
|
||||
{
|
||||
transform: (data) => (data === true ? false : null),
|
||||
},
|
||||
),
|
||||
upgradeMoveToWithOverrides(
|
||||
'view.triggers.untrigger_reset',
|
||||
CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER,
|
||||
{
|
||||
// Delete the value if it's set to the default.
|
||||
transform: (val) => (val ? 'default' : null),
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
+36
-20
@@ -77,6 +77,16 @@ export type PTZAction = (typeof PTZ_ACTIONS)[number];
|
||||
const PTZ_PHASES = ['start', 'stop'] as const;
|
||||
export type PTZPhase = (typeof PTZ_PHASES)[number];
|
||||
|
||||
const CAMERA_TRIGGER_EVENT_TYPES = [
|
||||
// An event whether or not it has any media yet associated with it.
|
||||
'events',
|
||||
|
||||
// Specific media availability.
|
||||
'clips',
|
||||
'snapshots',
|
||||
] as const;
|
||||
export type CameraTriggerEventType = (typeof CAMERA_TRIGGER_EVENT_TYPES)[number];
|
||||
|
||||
// *************************************************************************
|
||||
// View Display Mode
|
||||
// *************************************************************************
|
||||
@@ -326,7 +336,7 @@ const actionsSchema = z.object({
|
||||
});
|
||||
|
||||
const elementsBaseSchema = actionsBaseSchema.extend({
|
||||
style: z.record(z.string().nullable().or(z.undefined())).optional(),
|
||||
style: z.record(z.string().nullable().or(z.undefined()).or(z.number())).optional(),
|
||||
title: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
@@ -1038,7 +1048,8 @@ const cameraConfigDefault = {
|
||||
},
|
||||
triggers: {
|
||||
motion: false,
|
||||
occupancy: true,
|
||||
occupancy: false,
|
||||
events: [...CAMERA_TRIGGER_EVENT_TYPES],
|
||||
entities: [],
|
||||
},
|
||||
};
|
||||
@@ -1071,6 +1082,10 @@ export const cameraConfigSchema = z
|
||||
motion: z.boolean().default(cameraConfigDefault.triggers.motion),
|
||||
occupancy: z.boolean().default(cameraConfigDefault.triggers.occupancy),
|
||||
entities: z.string().array().default(cameraConfigDefault.triggers.entities),
|
||||
events: z
|
||||
.enum(CAMERA_TRIGGER_EVENT_TYPES)
|
||||
.array()
|
||||
.default(cameraConfigDefault.triggers.events),
|
||||
})
|
||||
.default(cameraConfigDefault.triggers),
|
||||
|
||||
@@ -1145,41 +1160,42 @@ const viewConfigDefault = {
|
||||
update_force: false,
|
||||
update_cycle_camera: false,
|
||||
dark_mode: 'off' as const,
|
||||
scan: {
|
||||
enabled: false,
|
||||
show_trigger_status: true,
|
||||
filter_selected_camera: false,
|
||||
triggers: {
|
||||
show_trigger_status: false,
|
||||
filter_selected_camera: true,
|
||||
actions: {
|
||||
interaction_mode: 'inactive' as const,
|
||||
trigger: 'live' as const,
|
||||
untrigger: 'default' as const,
|
||||
trigger: 'default' as const,
|
||||
untrigger: 'none' as const,
|
||||
},
|
||||
untrigger_seconds: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export const scanSchema = z.object({
|
||||
enabled: z.boolean().default(viewConfigDefault.scan.enabled),
|
||||
|
||||
export const triggersSchema = z.object({
|
||||
filter_selected_camera: z
|
||||
.boolean()
|
||||
.default(viewConfigDefault.scan.filter_selected_camera),
|
||||
show_trigger_status: z.boolean().default(viewConfigDefault.scan.show_trigger_status),
|
||||
.default(viewConfigDefault.triggers.filter_selected_camera),
|
||||
show_trigger_status: z
|
||||
.boolean()
|
||||
.default(viewConfigDefault.triggers.show_trigger_status),
|
||||
|
||||
actions: z
|
||||
.object({
|
||||
interaction_mode: z
|
||||
.enum(['all', 'inactive', 'active'])
|
||||
.default(viewConfigDefault.scan.actions.interaction_mode),
|
||||
trigger: z.enum(['live', 'none']).default(viewConfigDefault.scan.actions.trigger),
|
||||
.default(viewConfigDefault.triggers.actions.interaction_mode),
|
||||
trigger: z
|
||||
.enum(['live', 'default', 'media', 'none'])
|
||||
.default(viewConfigDefault.triggers.actions.trigger),
|
||||
untrigger: z
|
||||
.enum(['default', 'none'])
|
||||
.default(viewConfigDefault.scan.actions.untrigger),
|
||||
.default(viewConfigDefault.triggers.actions.untrigger),
|
||||
})
|
||||
.default(viewConfigDefault.scan.actions),
|
||||
untrigger_seconds: z.number().default(viewConfigDefault.scan.untrigger_seconds),
|
||||
.default(viewConfigDefault.triggers.actions),
|
||||
untrigger_seconds: z.number().default(viewConfigDefault.triggers.untrigger_seconds),
|
||||
});
|
||||
export type ScanOptions = z.infer<typeof scanSchema>;
|
||||
export type TriggersOptions = z.infer<typeof triggersSchema>;
|
||||
|
||||
const viewConfigSchema = z
|
||||
.object({
|
||||
@@ -1199,7 +1215,7 @@ const viewConfigSchema = z
|
||||
update_entities: z.string().array().optional(),
|
||||
render_entities: z.string().array().optional(),
|
||||
dark_mode: z.enum(['on', 'off', 'auto']).optional(),
|
||||
scan: scanSchema.default(viewConfigDefault.scan),
|
||||
triggers: triggersSchema.default(viewConfigDefault.triggers),
|
||||
})
|
||||
.merge(actionsSchema)
|
||||
.default(viewConfigDefault);
|
||||
|
||||
+18
-16
@@ -54,6 +54,8 @@ export const CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY =
|
||||
`${CONF_CAMERAS}.#.triggers.occupancy` as const;
|
||||
export const CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES =
|
||||
`${CONF_CAMERAS}.#.triggers.entities` as const;
|
||||
export const CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS =
|
||||
`${CONF_CAMERAS}.#.triggers.events` as const;
|
||||
|
||||
const CONF_CAMERAS_GLOBAL = 'cameras_global' as const;
|
||||
export const CONF_CAMERAS_GLOBAL_IMAGE = `${CONF_CAMERAS_GLOBAL}.image` as const;
|
||||
@@ -77,21 +79,20 @@ export const CONF_VIEW_UPDATE_FORCE = `${CONF_VIEW}.update_force` as const;
|
||||
export const CONF_VIEW_UPDATE_SECONDS = `${CONF_VIEW}.update_seconds` as const;
|
||||
export const CONF_VIEW_RESET_AFTER_INTERACTION =
|
||||
`${CONF_VIEW}.reset_after_interaction` as const;
|
||||
export const CONF_VIEW_SCAN = `${CONF_VIEW}.scan` as const;
|
||||
export const CONF_VIEW_SCAN_ENABLED = `${CONF_VIEW_SCAN}.enabled` as const;
|
||||
export const CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS =
|
||||
`${CONF_VIEW_SCAN}.show_trigger_status` as const;
|
||||
export const CONF_VIEW_SCAN_FILTER_SELECTED_CAMERA =
|
||||
`${CONF_VIEW_SCAN}.filter_selected_camera` as const;
|
||||
export const CONF_VIEW_SCAN_UNTRIGGER_SECONDS =
|
||||
`${CONF_VIEW_SCAN}.untrigger_seconds` as const;
|
||||
export const CONF_VIEW_SCAN_ACTIONS = `${CONF_VIEW_SCAN}.actions` as const;
|
||||
export const CONF_VIEW_SCAN_ACTIONS_TRIGGER =
|
||||
`${CONF_VIEW_SCAN_ACTIONS}.trigger` as const;
|
||||
export const CONF_VIEW_SCAN_ACTIONS_UNTRIGGER =
|
||||
`${CONF_VIEW_SCAN_ACTIONS}.untrigger` as const;
|
||||
export const CONF_VIEW_SCAN_ACTIONS_INTERACTION_MODE =
|
||||
`${CONF_VIEW_SCAN_ACTIONS}.interaction_mode` as const;
|
||||
export const CONF_VIEW_TRIGGERS = `${CONF_VIEW}.triggers` as const;
|
||||
export const CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS =
|
||||
`${CONF_VIEW_TRIGGERS}.show_trigger_status` as const;
|
||||
export const CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA =
|
||||
`${CONF_VIEW_TRIGGERS}.filter_selected_camera` as const;
|
||||
export const CONF_VIEW_TRIGGERS_UNTRIGGER_SECONDS =
|
||||
`${CONF_VIEW_TRIGGERS}.untrigger_seconds` as const;
|
||||
export const CONF_VIEW_TRIGGERS_ACTIONS = `${CONF_VIEW_TRIGGERS}.actions` as const;
|
||||
export const CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER =
|
||||
`${CONF_VIEW_TRIGGERS_ACTIONS}.trigger` as const;
|
||||
export const CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER =
|
||||
`${CONF_VIEW_TRIGGERS_ACTIONS}.untrigger` as const;
|
||||
export const CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE =
|
||||
`${CONF_VIEW_TRIGGERS_ACTIONS}.interaction_mode` as const;
|
||||
|
||||
export const CONF_MEDIA_GALLERY = 'media_gallery' as const;
|
||||
export const CONF_MEDIA_GALLERY_CONTROLS_FILTER_MODE =
|
||||
@@ -258,7 +259,8 @@ const CONF_TIMELINE = 'timeline' as const;
|
||||
export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as const;
|
||||
export const CONF_TIMELINE_CLUSTERING_THRESHOLD =
|
||||
`${CONF_TIMELINE}.clustering_threshold` as const;
|
||||
export const CONF_TIMELINE_EVENTS_MEDIA_TYPE = `${CONF_TIMELINE}.events_media_type` as const;
|
||||
export const CONF_TIMELINE_EVENTS_MEDIA_TYPE =
|
||||
`${CONF_TIMELINE}.events_media_type` as const;
|
||||
export const CONF_TIMELINE_SHOW_RECORDINGS = `${CONF_TIMELINE}.show_recordings` as const;
|
||||
export const CONF_TIMELINE_STYLE = `${CONF_TIMELINE}.style` as const;
|
||||
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE =
|
||||
|
||||
+130
-79
@@ -22,9 +22,9 @@ import {
|
||||
} from './config-mgmt.js';
|
||||
import {
|
||||
BUTTON_SIZE_MIN,
|
||||
FRIGATE_MENU_PRIORITY_MAX,
|
||||
FrigateCardConfig,
|
||||
frigateCardConfigDefaults,
|
||||
FRIGATE_MENU_PRIORITY_MAX,
|
||||
RawFrigateCardConfig,
|
||||
RawFrigateCardConfigArray,
|
||||
THUMBNAIL_WIDTH_MAX,
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
CONF_CAMERAS_ARRAY_MOTIONEYE_URL,
|
||||
CONF_CAMERAS_ARRAY_TITLE,
|
||||
CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES,
|
||||
CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS,
|
||||
CONF_CAMERAS_ARRAY_TRIGGERS_MOTION,
|
||||
CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY,
|
||||
CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY,
|
||||
@@ -159,8 +160,8 @@ import {
|
||||
CONF_MEDIA_VIEWER_TRANSITION_EFFECT,
|
||||
CONF_MEDIA_VIEWER_ZOOMABLE,
|
||||
CONF_MENU_ALIGNMENT,
|
||||
CONF_MENU_BUTTONS,
|
||||
CONF_MENU_BUTTON_SIZE,
|
||||
CONF_MENU_BUTTONS,
|
||||
CONF_MENU_POSITION,
|
||||
CONF_MENU_STYLE,
|
||||
CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR,
|
||||
@@ -184,15 +185,14 @@ import {
|
||||
CONF_VIEW_DEFAULT,
|
||||
CONF_VIEW_INTERACTION_SECONDS,
|
||||
CONF_VIEW_RESET_AFTER_INTERACTION,
|
||||
CONF_VIEW_SCAN,
|
||||
CONF_VIEW_SCAN_ACTIONS,
|
||||
CONF_VIEW_SCAN_ACTIONS_INTERACTION_MODE,
|
||||
CONF_VIEW_SCAN_ACTIONS_TRIGGER,
|
||||
CONF_VIEW_SCAN_ACTIONS_UNTRIGGER,
|
||||
CONF_VIEW_SCAN_ENABLED,
|
||||
CONF_VIEW_SCAN_FILTER_SELECTED_CAMERA,
|
||||
CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS,
|
||||
CONF_VIEW_SCAN_UNTRIGGER_SECONDS,
|
||||
CONF_VIEW_TRIGGERS,
|
||||
CONF_VIEW_TRIGGERS_ACTIONS,
|
||||
CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE,
|
||||
CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER,
|
||||
CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER,
|
||||
CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
|
||||
CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS,
|
||||
CONF_VIEW_TRIGGERS_UNTRIGGER_SECONDS,
|
||||
CONF_VIEW_UPDATE_CYCLE_CAMERA,
|
||||
CONF_VIEW_UPDATE_FORCE,
|
||||
CONF_VIEW_UPDATE_SECONDS,
|
||||
@@ -244,8 +244,8 @@ const MENU_OPTIONS = 'options';
|
||||
const MENU_PERFORMANCE_FEATURES = 'performance.features';
|
||||
const MENU_PERFORMANCE_STYLE = 'performance.style';
|
||||
const MENU_TIMELINE_CONTROLS_THUMBNAILS = 'timeline.controls.thumbnails';
|
||||
const MENU_VIEW_SCAN = 'scan';
|
||||
const MENU_VIEW_SCAN_ACTIONS = 'scan.actions';
|
||||
const MENU_VIEW_TRIGGERS = 'view.triggers';
|
||||
const MENU_VIEW_TRIGGERS_ACTIONS = 'view.triggers.actions';
|
||||
|
||||
interface EditorOptionsSet {
|
||||
icon: string;
|
||||
@@ -514,8 +514,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
protected _timelineEventsMediaTypes: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{ value: 'all', label: localize('config.common.timeline.events_media_types.all') },
|
||||
{ value: 'clips', label: localize('config.common.timeline.events_media_types.clips') },
|
||||
{ value: 'snapshots', label: localize('config.common.timeline.events_media_types.snapshots') },
|
||||
{
|
||||
value: 'clips',
|
||||
label: localize('config.common.timeline.events_media_types.clips'),
|
||||
},
|
||||
{
|
||||
value: 'snapshots',
|
||||
label: localize('config.common.timeline.events_media_types.snapshots'),
|
||||
},
|
||||
];
|
||||
|
||||
protected _timelineStyleTypes: EditorSelectOption[] = [
|
||||
@@ -653,43 +659,67 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
},
|
||||
];
|
||||
|
||||
protected _scanActionsInteractionModes: EditorSelectOption[] = [
|
||||
protected _triggersActionsInteractionModes: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{
|
||||
value: 'all',
|
||||
label: localize('config.view.scan.actions.interaction_modes.all'),
|
||||
label: localize('config.view.triggers.actions.interaction_modes.all'),
|
||||
},
|
||||
{
|
||||
value: 'inactive',
|
||||
label: localize('config.view.scan.actions.interaction_modes.inactive'),
|
||||
label: localize('config.view.triggers.actions.interaction_modes.inactive'),
|
||||
},
|
||||
{
|
||||
value: 'active',
|
||||
label: localize('config.view.scan.actions.interaction_modes.active'),
|
||||
label: localize('config.view.triggers.actions.interaction_modes.active'),
|
||||
},
|
||||
];
|
||||
|
||||
protected _scanActionsTrigger: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{
|
||||
value: 'live',
|
||||
label: localize('config.view.scan.actions.triggers.live'),
|
||||
},
|
||||
{
|
||||
value: 'none',
|
||||
label: localize('config.view.scan.actions.triggers.none'),
|
||||
},
|
||||
];
|
||||
|
||||
protected _scanActionsUntrigger: EditorSelectOption[] = [
|
||||
protected _triggersActionsTrigger: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{
|
||||
value: 'default',
|
||||
label: localize('config.view.scan.actions.untriggers.default'),
|
||||
label: localize('config.view.triggers.actions.triggers.default'),
|
||||
},
|
||||
{
|
||||
value: 'live',
|
||||
label: localize('config.view.triggers.actions.triggers.live'),
|
||||
},
|
||||
{
|
||||
value: 'media',
|
||||
label: localize('config.view.triggers.actions.triggers.media'),
|
||||
},
|
||||
{
|
||||
value: 'none',
|
||||
label: localize('config.view.scan.actions.untriggers.none'),
|
||||
label: localize('config.view.triggers.actions.triggers.none'),
|
||||
},
|
||||
];
|
||||
|
||||
protected _triggersActionsUntrigger: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{
|
||||
value: 'default',
|
||||
label: localize('config.view.triggers.actions.untriggers.default'),
|
||||
},
|
||||
{
|
||||
value: 'none',
|
||||
label: localize('config.view.triggers.actions.untriggers.none'),
|
||||
},
|
||||
];
|
||||
|
||||
protected _triggersEvents: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{
|
||||
value: 'events',
|
||||
label: localize('config.cameras.triggers.events.events'),
|
||||
},
|
||||
{
|
||||
value: 'clips',
|
||||
label: localize('config.cameras.triggers.events.clips'),
|
||||
},
|
||||
{
|
||||
value: 'snapshots',
|
||||
label: localize('config.cameras.triggers.events.snapshots'),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -948,57 +978,54 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
);
|
||||
}
|
||||
|
||||
protected _renderViewScanMenu(): TemplateResult {
|
||||
protected _renderViewTriggersMenu(): TemplateResult {
|
||||
return this._putInSubmenu(
|
||||
MENU_VIEW_SCAN,
|
||||
MENU_VIEW_TRIGGERS,
|
||||
true,
|
||||
`config.${CONF_VIEW_SCAN}.editor_label`,
|
||||
`config.${CONF_VIEW_TRIGGERS}.editor_label`,
|
||||
{ name: 'mdi:target-account' },
|
||||
html`
|
||||
${this._renderSwitch(CONF_VIEW_SCAN_ENABLED, this._defaults.view.scan.enabled, {
|
||||
label: localize(`config.${CONF_VIEW_SCAN_ENABLED}`),
|
||||
})}
|
||||
${this._renderSwitch(
|
||||
CONF_VIEW_SCAN_FILTER_SELECTED_CAMERA,
|
||||
this._defaults.view.scan.filter_selected_camera,
|
||||
CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
|
||||
this._defaults.view.triggers.filter_selected_camera,
|
||||
{
|
||||
label: localize(`config.${CONF_VIEW_SCAN_FILTER_SELECTED_CAMERA}`),
|
||||
label: localize(`config.${CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA}`),
|
||||
},
|
||||
)}
|
||||
${this._renderSwitch(
|
||||
CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS,
|
||||
this._defaults.view.scan.show_trigger_status,
|
||||
CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS,
|
||||
this._defaults.view.triggers.show_trigger_status,
|
||||
{
|
||||
label: localize(`config.${CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS}`),
|
||||
label: localize(`config.${CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS}`),
|
||||
},
|
||||
)}
|
||||
${this._renderNumberInput(CONF_VIEW_SCAN_UNTRIGGER_SECONDS, {
|
||||
default: this._defaults.view.scan.untrigger_seconds,
|
||||
${this._renderNumberInput(CONF_VIEW_TRIGGERS_UNTRIGGER_SECONDS, {
|
||||
default: this._defaults.view.triggers.untrigger_seconds,
|
||||
})}
|
||||
${this._putInSubmenu(
|
||||
MENU_VIEW_SCAN_ACTIONS,
|
||||
MENU_VIEW_TRIGGERS_ACTIONS,
|
||||
true,
|
||||
`config.${CONF_VIEW_SCAN_ACTIONS}.editor_label`,
|
||||
`config.${CONF_VIEW_TRIGGERS_ACTIONS}.editor_label`,
|
||||
{ name: 'mdi:cogs' },
|
||||
html` ${this._renderOptionSelector(
|
||||
CONF_VIEW_SCAN_ACTIONS_TRIGGER,
|
||||
this._scanActionsTrigger,
|
||||
CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER,
|
||||
this._triggersActionsTrigger,
|
||||
{
|
||||
label: localize('config.view.scan.actions.trigger'),
|
||||
label: localize('config.view.triggers.actions.trigger'),
|
||||
},
|
||||
)}
|
||||
${this._renderOptionSelector(
|
||||
CONF_VIEW_SCAN_ACTIONS_UNTRIGGER,
|
||||
this._scanActionsUntrigger,
|
||||
CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER,
|
||||
this._triggersActionsUntrigger,
|
||||
{
|
||||
label: localize('config.view.scan.actions.untrigger'),
|
||||
label: localize('config.view.triggers.actions.untrigger'),
|
||||
},
|
||||
)}
|
||||
${this._renderOptionSelector(
|
||||
CONF_VIEW_SCAN_ACTIONS_INTERACTION_MODE,
|
||||
this._scanActionsInteractionModes,
|
||||
CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE,
|
||||
this._triggersActionsInteractionModes,
|
||||
{
|
||||
label: localize('config.view.scan.actions.interaction_mode'),
|
||||
label: localize('config.view.triggers.actions.interaction_mode'),
|
||||
},
|
||||
)}`,
|
||||
)}
|
||||
@@ -1188,9 +1215,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
${this._renderNumberInput(configPathClusteringThreshold, {
|
||||
label: localize(`config.common.${CONF_TIMELINE_CLUSTERING_THRESHOLD}`),
|
||||
})}
|
||||
${this._renderOptionSelector(configPathTimelineEventsMediaType, this._timelineEventsMediaTypes, {
|
||||
label: localize(`config.common.${CONF_TIMELINE_EVENTS_MEDIA_TYPE}`),
|
||||
})}
|
||||
${this._renderOptionSelector(
|
||||
configPathTimelineEventsMediaType,
|
||||
this._timelineEventsMediaTypes,
|
||||
{
|
||||
label: localize(`config.common.${CONF_TIMELINE_EVENTS_MEDIA_TYPE}`),
|
||||
},
|
||||
)}
|
||||
${this._renderSwitch(configPathShowRecordings, defaultShowRecordings, {
|
||||
label: localize(`config.common.${CONF_TIMELINE_SHOW_RECORDINGS}`),
|
||||
})}`;
|
||||
@@ -1801,21 +1832,40 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
cameraIndex,
|
||||
'config.cameras.triggers.editor_label',
|
||||
{ name: 'mdi:magnify-scan' },
|
||||
html` ${this._renderSwitch(
|
||||
getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY, cameraIndex),
|
||||
this._defaults.cameras.triggers.occupancy,
|
||||
)}
|
||||
${this._renderSwitch(
|
||||
getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGERS_MOTION, cameraIndex),
|
||||
this._defaults.cameras.triggers.motion,
|
||||
)}
|
||||
${this._renderOptionSelector(
|
||||
getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES, cameraIndex),
|
||||
entities,
|
||||
{
|
||||
multiple: true,
|
||||
},
|
||||
)}`,
|
||||
html`
|
||||
${this._renderSwitch(
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY,
|
||||
cameraIndex,
|
||||
),
|
||||
this._defaults.cameras.triggers.occupancy,
|
||||
)}
|
||||
${this._renderSwitch(
|
||||
getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGERS_MOTION, cameraIndex),
|
||||
this._defaults.cameras.triggers.motion,
|
||||
)}
|
||||
${this._renderOptionSelector(
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES,
|
||||
cameraIndex,
|
||||
),
|
||||
entities,
|
||||
{
|
||||
multiple: true,
|
||||
},
|
||||
)}
|
||||
${this._renderOptionSelector(
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS,
|
||||
cameraIndex,
|
||||
),
|
||||
this._triggersEvents,
|
||||
{
|
||||
multiple: true,
|
||||
label: localize('config.cameras.triggers.events.editor_label'),
|
||||
},
|
||||
)}
|
||||
`,
|
||||
)}
|
||||
${this._putInSubmenu(
|
||||
MENU_CAMERAS_CAST,
|
||||
@@ -1992,7 +2042,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
CONF_VIEW_UPDATE_CYCLE_CAMERA,
|
||||
this._defaults.view.update_cycle_camera,
|
||||
)}
|
||||
${this._renderViewScanMenu()}
|
||||
${this._renderViewTriggersMenu()}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
@@ -2125,7 +2175,8 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
this._defaults.live.controls.thumbnails,
|
||||
{
|
||||
configPathMediaType: CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA_TYPE,
|
||||
configPathEventsMediaType: CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE,
|
||||
configPathEventsMediaType:
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE,
|
||||
configPathMode: CONF_LIVE_CONTROLS_THUMBNAILS_MODE,
|
||||
},
|
||||
)}
|
||||
|
||||
@@ -85,6 +85,12 @@
|
||||
"triggers": {
|
||||
"editor_label": "Trigger Options",
|
||||
"entities": "Trigger from other entities",
|
||||
"events": {
|
||||
"clips": "Events with new clips",
|
||||
"editor_label": "Trigger Events",
|
||||
"events": "All events",
|
||||
"snapshots": "Events with new snapshots"
|
||||
},
|
||||
"motion": "Trigger by auto-detecting the motion sensor",
|
||||
"occupancy": "Trigger by auto-detecting the occupancy sensor"
|
||||
},
|
||||
@@ -394,9 +400,9 @@
|
||||
"default": "Default view",
|
||||
"interaction_seconds": "Seconds after user action to remain interacted with (0=never)",
|
||||
"reset_after_interaction": "Reset to the default view after user interaction",
|
||||
"scan": {
|
||||
"triggers": {
|
||||
"actions": {
|
||||
"editor_label": "Scan mode actions",
|
||||
"editor_label": "Trigger actions",
|
||||
"interaction_mode": "How to handle actions when the card has human interaction",
|
||||
"interaction_modes": {
|
||||
"active": "Only trigger actions when card has human interaction",
|
||||
@@ -405,7 +411,9 @@
|
||||
},
|
||||
"trigger": "Trigger action",
|
||||
"triggers": {
|
||||
"live": "Change to live",
|
||||
"default": "Change to or update default view",
|
||||
"live": "Change to or update live view",
|
||||
"media": "Change to the relevant media view for new media",
|
||||
"none": "No action"
|
||||
},
|
||||
"untrigger": "Untrigger action",
|
||||
@@ -414,8 +422,7 @@
|
||||
"none": "No action"
|
||||
}
|
||||
},
|
||||
"editor_label": "Scan mode",
|
||||
"enabled": "Scan mode enabled",
|
||||
"editor_label": "Behavior when a camera is triggered",
|
||||
"filter_selected_camera": "Only trigger on selected camera",
|
||||
"show_trigger_status": "Show pulsing border when triggered",
|
||||
"untrigger_seconds": "Seconds after inactive state change to untrigger"
|
||||
|
||||
@@ -85,6 +85,12 @@
|
||||
"triggers": {
|
||||
"editor_label": "Trigger Opzioni",
|
||||
"entities": "Trigger da altre entità",
|
||||
"events": {
|
||||
"clips": "",
|
||||
"editor_label": "",
|
||||
"events": "",
|
||||
"snapshots": ""
|
||||
},
|
||||
"motion": "Trigger rilevando automaticamente dal sensore di movimento",
|
||||
"occupancy": "Attivare rilevando automatico tramite il sensore di presenza"
|
||||
},
|
||||
@@ -390,7 +396,7 @@
|
||||
"default": "Visualizzazione predefinita",
|
||||
"interaction_seconds": "",
|
||||
"reset_after_interaction": "",
|
||||
"scan": {
|
||||
"triggers": {
|
||||
"actions": {
|
||||
"editor_label": "",
|
||||
"interaction_mode": "",
|
||||
@@ -401,7 +407,9 @@
|
||||
},
|
||||
"trigger": "",
|
||||
"triggers": {
|
||||
"default": "",
|
||||
"live": "",
|
||||
"media": "",
|
||||
"none": ""
|
||||
},
|
||||
"untrigger": "",
|
||||
@@ -410,8 +418,7 @@
|
||||
"none": ""
|
||||
}
|
||||
},
|
||||
"editor_label": "Modalità di scansione",
|
||||
"enabled": "Modalità di scansione abilitata",
|
||||
"editor_label": "",
|
||||
"filter_selected_camera": "",
|
||||
"show_trigger_status": "Mostra bordo pulsante quando attivato",
|
||||
"untrigger_seconds": "Reimposta la vista ai valori predefiniti dopo aver annullato l'attivazione"
|
||||
|
||||
@@ -85,6 +85,12 @@
|
||||
"triggers": {
|
||||
"editor_label": "Opções de acionamento",
|
||||
"entities": "Acionar a partir de outras entidades",
|
||||
"events": {
|
||||
"clips": "",
|
||||
"editor_label": "",
|
||||
"events": "",
|
||||
"snapshots": ""
|
||||
},
|
||||
"motion": "Acionar detectando automaticamente o sensor de movimento",
|
||||
"occupancy": "Acionar detectando automaticamente o sensor de ocupação"
|
||||
},
|
||||
@@ -393,7 +399,7 @@
|
||||
"default": "Visualização padrão",
|
||||
"interaction_seconds": "",
|
||||
"reset_after_interaction": "",
|
||||
"scan": {
|
||||
"triggers": {
|
||||
"actions": {
|
||||
"editor_label": "",
|
||||
"interaction_mode": "",
|
||||
@@ -404,7 +410,9 @@
|
||||
},
|
||||
"trigger": "",
|
||||
"triggers": {
|
||||
"default": "",
|
||||
"live": "",
|
||||
"media": "",
|
||||
"none": ""
|
||||
},
|
||||
"untrigger": "",
|
||||
@@ -413,8 +421,7 @@
|
||||
"none": ""
|
||||
}
|
||||
},
|
||||
"editor_label": "Modo scan",
|
||||
"enabled": "Modo scan ativado",
|
||||
"editor_label": "",
|
||||
"filter_selected_camera": "",
|
||||
"show_trigger_status": "Pulsar borda quando acionado",
|
||||
"untrigger_seconds": "Segundos após a mudar para o estado inativo para desacionar"
|
||||
|
||||
@@ -85,6 +85,12 @@
|
||||
"triggers": {
|
||||
"editor_label": "Opções de activação",
|
||||
"entities": "Activar a partir de outras entidades",
|
||||
"events": {
|
||||
"editor_label": "",
|
||||
"events": "",
|
||||
"clips": "",
|
||||
"snapshots": ""
|
||||
},
|
||||
"motion": "Activar detectando automaticamente o sensor de movimento",
|
||||
"occupancy": "Activar detectando automaticamente o sensor de ocupação"
|
||||
},
|
||||
@@ -383,7 +389,7 @@
|
||||
"default": "Visualização padrão",
|
||||
"interaction_seconds": "",
|
||||
"reset_after_interaction": "",
|
||||
"scan": {
|
||||
"triggers": {
|
||||
"actions": {
|
||||
"editor_label": "",
|
||||
"interaction_mode": "",
|
||||
@@ -394,7 +400,9 @@
|
||||
},
|
||||
"trigger": "",
|
||||
"triggers": {
|
||||
"default": "",
|
||||
"live": "",
|
||||
"media": "",
|
||||
"none": ""
|
||||
},
|
||||
"untrigger": "",
|
||||
@@ -403,8 +411,7 @@
|
||||
"none": ""
|
||||
}
|
||||
},
|
||||
"editor_label": "Modo scan",
|
||||
"enabled": "Modo scan ativado",
|
||||
"editor_label": "",
|
||||
"filter_selected_camera": "",
|
||||
"show_trigger_status": "Exibir estado do gatilho",
|
||||
"untrigger_seconds": "Segundos após a mudar para o estado inativo para desacionar"
|
||||
|
||||
+65
-5
@@ -1,4 +1,8 @@
|
||||
import { computeDomain, computeStateDomain, HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import {
|
||||
computeDomain,
|
||||
computeStateDomain,
|
||||
HomeAssistant,
|
||||
} from '@dermotduffy/custom-card-helpers';
|
||||
import { HassEntity, MessageBase } from 'home-assistant-js-websocket';
|
||||
import { StyleInfo } from 'lit/directives/style-map.js';
|
||||
import { ZodSchema } from 'zod';
|
||||
@@ -13,6 +17,12 @@ import {
|
||||
} from '../../types.js';
|
||||
import { domainIcon } from '../icons/domain-icon.js';
|
||||
import { getParseErrorKeys } from '../zod.js';
|
||||
import {
|
||||
HAStateChangeFromTo,
|
||||
haStateChangeTriggerResponseSchema,
|
||||
SubscriptionCallback,
|
||||
SubscriptionUnsubscribe,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* Make a HomeAssistant websocket request. May throw.
|
||||
@@ -103,7 +113,7 @@ interface HassStateDifference {
|
||||
* strings only, firstOnly: whether or not to get the first difference only.
|
||||
* @returns An array of HassStateDifference objects.
|
||||
*/
|
||||
export function getHassDifferences(
|
||||
function getHassDifferences(
|
||||
newHass: HomeAssistant | undefined | null,
|
||||
oldHass: HomeAssistant | undefined | null,
|
||||
entities: string[] | null,
|
||||
@@ -326,11 +336,11 @@ export const sideLoadHomeAssistantElements = async (): Promise<boolean> => {
|
||||
|
||||
/**
|
||||
* Determine if a given state qualifies as 'triggered'.
|
||||
* @param state The HASSEntity.
|
||||
* @param state The HA entity state string.
|
||||
* @returns `true` if triggered, `false` otherwise.
|
||||
*/
|
||||
export const isTriggeredState = (state?: HassEntity): boolean => {
|
||||
return !!state && ['on', 'open'].includes(state.state);
|
||||
export const isTriggeredState = (state?: string): boolean => {
|
||||
return !!state && ['on', 'open'].includes(state);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -390,3 +400,53 @@ export const hasHAConnectionStateChanged = (
|
||||
): boolean => {
|
||||
return oldHass?.connected !== newHass?.connected;
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe to a HA trigger
|
||||
* @param hass The HA object.
|
||||
* @param callback The callback to call with the data.
|
||||
* @param options Parameters to the trigger, see:
|
||||
* https://www.home-assistant.io/docs/automation/trigger/#state-trigger
|
||||
* @returns A callback to unsubscribe.
|
||||
*/
|
||||
export const subscribeToTrigger = async (
|
||||
hass: HomeAssistant,
|
||||
callback: SubscriptionCallback,
|
||||
options?: {
|
||||
entityID?: string | string[];
|
||||
platform?: string;
|
||||
topic?: string;
|
||||
payload?: string;
|
||||
valueTemplate?: string;
|
||||
stateOnly?: boolean;
|
||||
},
|
||||
): Promise<SubscriptionUnsubscribe> => {
|
||||
return await hass.connection.subscribeMessage(callback, {
|
||||
type: 'subscribe_trigger',
|
||||
trigger: {
|
||||
...(options?.platform && { platform: options.platform }),
|
||||
...(options?.entityID && { entity_id: options.entityID }),
|
||||
...(options?.topic && { topic: options.topic }),
|
||||
...(options?.payload && { payload: options.payload }),
|
||||
...(options?.valueTemplate && { value_template: options.valueTemplate }),
|
||||
...(options?.stateOnly && {
|
||||
from: null,
|
||||
to: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a state change trigger response.
|
||||
* @param data The raw data.
|
||||
* @returns A HAStateChangeFromTo object.
|
||||
*/
|
||||
export const parseStateChangeTrigger = (data: unknown): HAStateChangeFromTo | null => {
|
||||
const parseResult = haStateChangeTriggerResponseSchema.safeParse(data);
|
||||
if (!parseResult.success) {
|
||||
console.warn('Ignoring unparseable HA state change', data);
|
||||
return null;
|
||||
}
|
||||
return parseResult.data.variables.trigger;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export type SubscriptionUnsubscribe = () => Promise<void>;
|
||||
export type SubscriptionCallback = (data: unknown) => void;
|
||||
|
||||
const haStateChangeSchema = z.object({
|
||||
entity_id: z.string(),
|
||||
state: z.string(),
|
||||
});
|
||||
|
||||
const haStateChangeFromToSchema = z.object({
|
||||
from_state: haStateChangeSchema,
|
||||
to_state: haStateChangeSchema,
|
||||
});
|
||||
export type HAStateChangeFromTo = z.infer<typeof haStateChangeFromToSchema>;
|
||||
|
||||
export const haStateChangeTriggerResponseSchema = z.object({
|
||||
variables: z.object({
|
||||
trigger: haStateChangeFromToSchema,
|
||||
}),
|
||||
});
|
||||
@@ -22,7 +22,7 @@ export class Initializer {
|
||||
): Promise<boolean> {
|
||||
const results = await allPromises(
|
||||
Object.entries(aspects),
|
||||
async ([aspect, options]) => this.initializeIfNecessary(aspect, options),
|
||||
async ([aspect, options]) => await this.initializeIfNecessary(aspect, options),
|
||||
);
|
||||
return results.every(Boolean);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export const changeViewToRecentEventsForCameraAndDependents = async (
|
||||
eventsMediaType?: ClipsOrSnapshotsOrAll;
|
||||
targetView?: FrigateCardView;
|
||||
select?: ResultSelectType;
|
||||
useCache?: boolean;
|
||||
},
|
||||
): Promise<void> => {
|
||||
const cameraIDs = options?.allCameras
|
||||
@@ -52,6 +53,7 @@ export const changeViewToRecentEventsForCameraAndDependents = async (
|
||||
{
|
||||
targetView: options?.targetView,
|
||||
select: options?.select,
|
||||
useCache: options?.useCache,
|
||||
},
|
||||
)
|
||||
)?.dispatchChangeEvent(element);
|
||||
@@ -93,6 +95,7 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
|
||||
allCameras?: boolean;
|
||||
targetView?: FrigateCardView;
|
||||
select?: ResultSelectType;
|
||||
useCache?: boolean;
|
||||
},
|
||||
): Promise<void> => {
|
||||
const cameraIDs = options?.allCameras
|
||||
@@ -120,6 +123,7 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
|
||||
{
|
||||
targetView: options?.targetView,
|
||||
select: options?.select,
|
||||
useCache: options?.useCache,
|
||||
},
|
||||
)
|
||||
)?.dispatchChangeEvent(element);
|
||||
@@ -147,6 +151,7 @@ export const executeMediaQueryForView = async (
|
||||
targetView?: FrigateCardView;
|
||||
targetTime?: Date;
|
||||
select?: ResultSelectType;
|
||||
useCache?: boolean;
|
||||
},
|
||||
): Promise<View | null> => {
|
||||
const queries = query.getQueries();
|
||||
@@ -154,7 +159,9 @@ export const executeMediaQueryForView = async (
|
||||
return null;
|
||||
}
|
||||
|
||||
const mediaArray = await cameraManager.executeMediaQueries<MediaQuery>(queries);
|
||||
const mediaArray = await cameraManager.executeMediaQueries<MediaQuery>(queries, {
|
||||
useCache: options?.useCache,
|
||||
});
|
||||
if (!mediaArray) {
|
||||
return null;
|
||||
}
|
||||
@@ -194,6 +201,7 @@ export const executeMediaQueryForViewWithErrorDispatching = async (
|
||||
targetView?: FrigateCardView;
|
||||
targetTime?: Date;
|
||||
select?: ResultSelectType;
|
||||
useCache?: boolean;
|
||||
},
|
||||
): Promise<View | null> => {
|
||||
try {
|
||||
@@ -202,6 +210,7 @@ export const executeMediaQueryForViewWithErrorDispatching = async (
|
||||
targetView: options?.targetView,
|
||||
targetTime: options?.targetTime,
|
||||
select: options?.select,
|
||||
useCache: options?.useCache,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
errorToConsole(e as Error);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { BrowseMediaCamera } from '../../../src/camera-manager/browse-media/camera';
|
||||
import { CameraManagerEngine } from '../../../src/camera-manager/engine';
|
||||
import { EntityRegistryManager } from '../../../src/utils/ha/entity-registry';
|
||||
import { Entity } from '../../../src/utils/ha/entity-registry/types';
|
||||
import { createCameraConfig, createHASS } from '../../test-utils';
|
||||
|
||||
describe('BrowseMediaCamera', () => {
|
||||
describe('should initialize', () => {
|
||||
it('without a camera_entity', async () => {
|
||||
const camera = new BrowseMediaCamera(
|
||||
createCameraConfig(),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
|
||||
expect(
|
||||
async () => await camera.initialize(createHASS(), mock<EntityRegistryManager>()),
|
||||
).rejects.toThrowError(/Could not find camera entity/);
|
||||
});
|
||||
|
||||
it('with a camera_entity', async () => {
|
||||
const camera = new BrowseMediaCamera(
|
||||
createCameraConfig({
|
||||
camera_entity: 'camera.foo',
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const entityRegistryManager = mock<EntityRegistryManager>();
|
||||
const entity = mock<Entity>();
|
||||
entityRegistryManager.getEntity.mockResolvedValue(entity);
|
||||
|
||||
expect(await camera.initialize(createHASS(), entityRegistryManager)).toBe(camera);
|
||||
expect(camera.getEntity()).toBe(entity);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,57 +1,170 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { Camera } from '../../src/camera-manager/camera.js';
|
||||
import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic.js';
|
||||
import { createCameraCapabilities, createCameraConfig } from '../test-utils.js';
|
||||
import { EntityRegistryManager } from '../../src/utils/ha/entity-registry/index.js';
|
||||
import {
|
||||
callHASubscribeMessageHandler,
|
||||
createCameraCapabilities,
|
||||
createCameraConfig,
|
||||
createHASS,
|
||||
} from '../test-utils.js';
|
||||
|
||||
describe('Camera', () => {
|
||||
it('should get config', async () => {
|
||||
const config = createCameraConfig();
|
||||
const camera = new Camera(
|
||||
config,
|
||||
new GenericCameraManagerEngine(),
|
||||
createCameraCapabilities(),
|
||||
);
|
||||
const camera = new Camera(config, new GenericCameraManagerEngine());
|
||||
expect(camera.getConfig()).toBe(config);
|
||||
});
|
||||
|
||||
it('should get capabilities', async () => {
|
||||
const capabilities = createCameraCapabilities();
|
||||
const camera = new Camera(
|
||||
createCameraConfig(),
|
||||
new GenericCameraManagerEngine(),
|
||||
capabilities,
|
||||
);
|
||||
expect(camera.getCapabilities()).toBe(capabilities);
|
||||
describe('should get capabilities', async () => {
|
||||
it('when populated', async () => {
|
||||
const capabilities = createCameraCapabilities();
|
||||
const camera = new Camera(createCameraConfig(), new GenericCameraManagerEngine(), {
|
||||
capabilities: capabilities,
|
||||
});
|
||||
expect(camera.getCapabilities()).toBe(capabilities);
|
||||
});
|
||||
|
||||
it('when unpopulated', async () => {
|
||||
const camera = new Camera(createCameraConfig(), new GenericCameraManagerEngine());
|
||||
expect(camera.getCapabilities()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('should get engine', async () => {
|
||||
const engine = new GenericCameraManagerEngine();
|
||||
const camera = new Camera(createCameraConfig(), engine, createCameraCapabilities());
|
||||
const camera = new Camera(createCameraConfig(), engine);
|
||||
expect(camera.getEngine()).toBe(engine);
|
||||
});
|
||||
|
||||
it('should set and get id', async () => {
|
||||
const config = createCameraConfig();
|
||||
const camera = new Camera(
|
||||
config,
|
||||
new GenericCameraManagerEngine(),
|
||||
createCameraCapabilities(),
|
||||
);
|
||||
const camera = new Camera(createCameraConfig(), new GenericCameraManagerEngine());
|
||||
camera.setID('foo');
|
||||
expect(camera.getID()).toBe('foo');
|
||||
expect(camera.getConfig().id).toBe('foo');
|
||||
});
|
||||
|
||||
it('should throw without id', async () => {
|
||||
const config = createCameraConfig();
|
||||
const camera = new Camera(
|
||||
config,
|
||||
new GenericCameraManagerEngine(),
|
||||
createCameraCapabilities(),
|
||||
);
|
||||
const camera = new Camera(createCameraConfig(), new GenericCameraManagerEngine());
|
||||
expect(() => camera.getID()).toThrowError(
|
||||
'Could not determine camera id for the following ' +
|
||||
"camera, may need to set 'id' parameter manually",
|
||||
);
|
||||
});
|
||||
|
||||
it('should initialize and destroy', async () => {
|
||||
const camera = new Camera(
|
||||
createCameraConfig({
|
||||
triggers: {
|
||||
entities: ['camera.foo'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(),
|
||||
);
|
||||
const hass = createHASS();
|
||||
const unsubcribeCallback = vi.fn();
|
||||
|
||||
vi.mocked(hass.connection.subscribeMessage).mockResolvedValue(unsubcribeCallback);
|
||||
|
||||
await camera.initialize(hass, mock<EntityRegistryManager>());
|
||||
|
||||
expect(hass.connection.subscribeMessage).toBeCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
type: 'subscribe_trigger',
|
||||
trigger: {
|
||||
entity_id: ['camera.foo'],
|
||||
platform: 'state',
|
||||
from: null,
|
||||
to: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(unsubcribeCallback).not.toBeCalled();
|
||||
|
||||
await camera.destroy();
|
||||
expect(unsubcribeCallback).toBeCalled();
|
||||
});
|
||||
|
||||
it('should not subscribe without trigger entities', async () => {
|
||||
const camera = new Camera(createCameraConfig(), new GenericCameraManagerEngine());
|
||||
const hass = createHASS();
|
||||
|
||||
await camera.initialize(hass, mock<EntityRegistryManager>());
|
||||
expect(hass.connection.subscribeMessage).not.toBeCalled();
|
||||
});
|
||||
|
||||
describe('should handle trigger state changes', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('when malformed', async () => {
|
||||
vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
|
||||
|
||||
const eventCallback = vi.fn();
|
||||
const camera = new Camera(
|
||||
createCameraConfig({
|
||||
triggers: {
|
||||
entities: ['camera.foo'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(),
|
||||
{ eventCallback: eventCallback },
|
||||
);
|
||||
|
||||
const hass = createHASS();
|
||||
await camera.initialize(hass, mock<EntityRegistryManager>());
|
||||
|
||||
callHASubscribeMessageHandler(hass, 'MALFORMED_DATA');
|
||||
|
||||
expect(eventCallback).not.toBeCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['off' as const, 'on' as const, 'new' as const],
|
||||
['on' as const, 'off' as const, 'end' as const],
|
||||
])(
|
||||
'from %s to %s',
|
||||
async (stateFrom: string, stateTo: string, eventType: 'new' | 'end') => {
|
||||
vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
|
||||
|
||||
const eventCallback = vi.fn();
|
||||
const camera = new Camera(
|
||||
createCameraConfig({
|
||||
id: 'camera_1',
|
||||
triggers: {
|
||||
entities: ['binary_sensor.foo'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(),
|
||||
{ eventCallback: eventCallback },
|
||||
);
|
||||
|
||||
const hass = createHASS();
|
||||
await camera.initialize(hass, mock<EntityRegistryManager>());
|
||||
|
||||
callHASubscribeMessageHandler(hass, {
|
||||
variables: {
|
||||
trigger: {
|
||||
from_state: {
|
||||
entity_id: 'binary_sensor.foo',
|
||||
state: stateFrom,
|
||||
},
|
||||
to_state: {
|
||||
entity_id: 'binary_sensor.foo',
|
||||
state: stateTo,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(eventCallback).toBeCalledWith({
|
||||
cameraID: 'camera_1',
|
||||
type: eventType,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,965 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { CameraManagerEngine } from '../../../src/camera-manager/engine';
|
||||
import { FrigateCamera } from '../../../src/camera-manager/frigate/camera';
|
||||
import { getPTZInfo } from '../../../src/camera-manager/frigate/requests';
|
||||
import {
|
||||
FrigateEventChange,
|
||||
FrigateEventChangeTriggerResponse,
|
||||
FrigateEventChangeType,
|
||||
} from '../../../src/camera-manager/frigate/types';
|
||||
import { CameraTriggerEventType } from '../../../src/config/types';
|
||||
import { EntityRegistryManager } from '../../../src/utils/ha/entity-registry';
|
||||
import { Entity } from '../../../src/utils/ha/entity-registry/types';
|
||||
import {
|
||||
callHASubscribeMessageHandler,
|
||||
createCameraConfig,
|
||||
createHASS,
|
||||
createRegistryEntity,
|
||||
} from '../../test-utils';
|
||||
|
||||
vi.mock('../../../src/camera-manager/frigate/requests');
|
||||
|
||||
const createFrigateEventChangeTrigger = (
|
||||
type: FrigateEventChangeType,
|
||||
before: FrigateEventChange,
|
||||
after: FrigateEventChange,
|
||||
): FrigateEventChangeTriggerResponse => {
|
||||
return {
|
||||
variables: {
|
||||
trigger: {
|
||||
payload_json: {
|
||||
before: before,
|
||||
after: after,
|
||||
type: type,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('FrigateCamera', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getPTZInfo).mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe('should initialize config', () => {
|
||||
describe('should detect camera name', () => {
|
||||
it('without a camera_entity', async () => {
|
||||
const config = createCameraConfig();
|
||||
const camera = new FrigateCamera(config, mock<CameraManagerEngine>());
|
||||
const beforeConfig = { ...config };
|
||||
|
||||
await camera.initialize(createHASS(), mock<EntityRegistryManager>());
|
||||
|
||||
expect(beforeConfig).toEqual(camera.getConfig());
|
||||
});
|
||||
|
||||
it('with a missing camera_entity', async () => {
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
camera_entity: 'camera.not_here',
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const entityRegistryManager = mock<EntityRegistryManager>();
|
||||
entityRegistryManager.getEntity.mockRejectedValue(null);
|
||||
|
||||
expect(
|
||||
async () => await camera.initialize(createHASS(), entityRegistryManager),
|
||||
).rejects.toThrowError(/Could not find camera entity/);
|
||||
});
|
||||
|
||||
it('with a valid camera_entity', async () => {
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
camera_entity: 'camera.front_door',
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const entityRegistryManager = mock<EntityRegistryManager>();
|
||||
const entity = createRegistryEntity({
|
||||
unique_id: '8c4e19d258359e82bc0cf9d47b021c46:camera:fnt_dr',
|
||||
platform: 'frigate',
|
||||
});
|
||||
entityRegistryManager.getEntity.mockResolvedValue(entity);
|
||||
|
||||
await camera.initialize(createHASS(), entityRegistryManager);
|
||||
|
||||
expect(camera.getConfig().frigate.camera_name).toBe('fnt_dr');
|
||||
});
|
||||
|
||||
it('with a camera_entity without camera_name match', async () => {
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
camera_entity: 'camera.front_door',
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const entityRegistryManager = mock<EntityRegistryManager>();
|
||||
const entity = createRegistryEntity({
|
||||
unique_id: '8c4e19d258359e82bc0cf9d47b021c46:WRONG:fnt_dr',
|
||||
platform: 'frigate',
|
||||
});
|
||||
entityRegistryManager.getEntity.mockResolvedValue(entity);
|
||||
|
||||
await camera.initialize(createHASS(), entityRegistryManager);
|
||||
|
||||
expect(camera.getConfig().frigate.camera_name).toBeUndefined();
|
||||
});
|
||||
|
||||
it('with a camera_entity without platform match', async () => {
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
camera_entity: 'camera.front_door',
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const entityRegistryManager = mock<EntityRegistryManager>();
|
||||
const entity = createRegistryEntity({
|
||||
unique_id: '8c4e19d258359e82bc0cf9d47b021c46:camera:fnt_dr',
|
||||
platform: 'something_else',
|
||||
});
|
||||
entityRegistryManager.getEntity.mockResolvedValue(entity);
|
||||
|
||||
await camera.initialize(createHASS(), entityRegistryManager);
|
||||
|
||||
expect(camera.getConfig().frigate.camera_name).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('should detect capabilities', () => {
|
||||
const nonBirdseyeBaseCapabilities = {
|
||||
canFavoriteEvents: true,
|
||||
canFavoriteRecordings: true,
|
||||
canSeek: true,
|
||||
supportsClips: true,
|
||||
supportsSnapshots: true,
|
||||
supportsRecordings: true,
|
||||
supportsTimeline: true,
|
||||
};
|
||||
|
||||
it('basic non-birdseye', async () => {
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig(),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
|
||||
await camera.initialize(createHASS(), mock<EntityRegistryManager>());
|
||||
|
||||
expect(camera.getCapabilities()).toEqual(nonBirdseyeBaseCapabilities);
|
||||
});
|
||||
|
||||
it('basic birdseye', async () => {
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
camera_name: 'birdseye',
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
|
||||
await camera.initialize(createHASS(), mock<EntityRegistryManager>());
|
||||
|
||||
expect(camera.getCapabilities()).toEqual({
|
||||
...nonBirdseyeBaseCapabilities,
|
||||
canFavoriteEvents: false,
|
||||
canFavoriteRecordings: false,
|
||||
supportsClips: false,
|
||||
supportsSnapshots: false,
|
||||
supportsRecordings: false,
|
||||
supportsTimeline: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe('with ptz', () => {
|
||||
it('when getPTZInfo call fails', async () => {
|
||||
const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
|
||||
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
camera_name: 'front_door',
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
vi.mocked(getPTZInfo).mockRejectedValue(new Error());
|
||||
await camera.initialize(createHASS(), mock<EntityRegistryManager>());
|
||||
|
||||
expect(camera.getCapabilities()).toEqual({
|
||||
...nonBirdseyeBaseCapabilities,
|
||||
});
|
||||
expect(consoleSpy).toBeCalled();
|
||||
});
|
||||
|
||||
it('when getPTZInfo call succeeds with continuous motion', async () => {
|
||||
vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
|
||||
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
camera_name: 'front_door',
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
vi.mocked(getPTZInfo).mockResolvedValue({
|
||||
features: ['pt', 'zoom'],
|
||||
name: 'front_door',
|
||||
presets: ['preset01'],
|
||||
});
|
||||
await camera.initialize(createHASS(), mock<EntityRegistryManager>());
|
||||
|
||||
expect(camera.getCapabilities()).toEqual({
|
||||
...nonBirdseyeBaseCapabilities,
|
||||
ptz: {
|
||||
panTilt: ['continuous'],
|
||||
zoom: ['continuous'],
|
||||
presets: ['preset01'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('when getPTZInfo call succeeds with relative motion', async () => {
|
||||
vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
|
||||
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
camera_name: 'front_door',
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
vi.mocked(getPTZInfo).mockResolvedValue({
|
||||
features: ['pt-r', 'zoom-r'],
|
||||
name: 'front_door',
|
||||
presets: ['preset01'],
|
||||
});
|
||||
await camera.initialize(createHASS(), mock<EntityRegistryManager>());
|
||||
|
||||
expect(camera.getCapabilities()).toEqual({
|
||||
...nonBirdseyeBaseCapabilities,
|
||||
ptz: {
|
||||
panTilt: ['relative'],
|
||||
zoom: ['relative'],
|
||||
presets: ['preset01'],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('should handle events', () => {
|
||||
it('should subscribe', async () => {
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
client_id: 'CLIENT_ID',
|
||||
camera_name: 'CAMERA',
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const hass = createHASS();
|
||||
|
||||
await camera.initialize(hass, mock<EntityRegistryManager>());
|
||||
|
||||
expect(hass.connection.subscribeMessage).toBeCalledWith(expect.anything(), {
|
||||
type: 'subscribe_trigger',
|
||||
trigger: {
|
||||
platform: 'mqtt',
|
||||
topic: `CLIENT_ID/events`,
|
||||
payload: 'CAMERA',
|
||||
value_template: '{{ value_json.after.camera }}',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not subscribe with no trigger events', async () => {
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
client_id: 'CLIENT_ID',
|
||||
camera_name: 'CAMERA',
|
||||
},
|
||||
triggers: {
|
||||
events: [],
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const hass = createHASS();
|
||||
|
||||
await camera.initialize(hass, mock<EntityRegistryManager>());
|
||||
|
||||
expect(hass.connection.subscribeMessage).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not subscribe with no camera name', async () => {
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
client_id: 'CLIENT_ID',
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const hass = createHASS();
|
||||
|
||||
await camera.initialize(hass, mock<EntityRegistryManager>());
|
||||
|
||||
expect(hass.connection.subscribeMessage).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should unsubscribe on destroy', async () => {
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: { camera_name: 'front_door' },
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const hass = createHASS();
|
||||
const unsubscribeCallback = vi.fn();
|
||||
vi.mocked(hass.connection.subscribeMessage).mockResolvedValue(unsubscribeCallback);
|
||||
|
||||
await camera.initialize(hass, mock<EntityRegistryManager>());
|
||||
expect(unsubscribeCallback).not.toBeCalled();
|
||||
|
||||
await camera.destroy();
|
||||
expect(unsubscribeCallback).toBeCalled();
|
||||
});
|
||||
|
||||
describe('should call handler correctly', () => {
|
||||
it('with malformed Frigate event', async () => {
|
||||
const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
|
||||
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: { camera_name: 'front_door' },
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
|
||||
const hass = createHASS();
|
||||
await camera.initialize(hass, mock<EntityRegistryManager>());
|
||||
|
||||
callHASubscribeMessageHandler(hass, 'GARBAGE');
|
||||
|
||||
expect(consoleSpy).toBeCalledWith(
|
||||
'Ignoring unparseable Frigate event',
|
||||
'GARBAGE',
|
||||
);
|
||||
});
|
||||
|
||||
it('with wrong camera', async () => {
|
||||
const eventCallback = vi.fn();
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
id: 'CAMERA_1',
|
||||
frigate: {
|
||||
camera_name: 'camera.front_door',
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
{
|
||||
eventCallback: eventCallback,
|
||||
},
|
||||
);
|
||||
|
||||
const hass = createHASS();
|
||||
await camera.initialize(hass, mock<EntityRegistryManager>());
|
||||
|
||||
callHASubscribeMessageHandler(
|
||||
hass,
|
||||
createFrigateEventChangeTrigger(
|
||||
'new',
|
||||
{
|
||||
camera: 'camera.back_door',
|
||||
snapshot: null,
|
||||
has_clip: false,
|
||||
has_snapshot: false,
|
||||
label: 'person',
|
||||
current_zones: [],
|
||||
},
|
||||
{
|
||||
camera: 'camera.back_door',
|
||||
snapshot: null,
|
||||
has_clip: false,
|
||||
has_snapshot: true,
|
||||
label: 'person',
|
||||
current_zones: [],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(eventCallback).not.toBeCalled();
|
||||
});
|
||||
|
||||
describe('should handle event type correctly', () => {
|
||||
it.each([
|
||||
[
|
||||
['events' as const, 'snapshots' as const, 'clips' as const],
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
],
|
||||
[
|
||||
['events' as const, 'snapshots' as const, 'clips' as const],
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
],
|
||||
[
|
||||
['events' as const, 'snapshots' as const, 'clips' as const],
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
],
|
||||
[
|
||||
['events' as const, 'snapshots' as const, 'clips' as const],
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
],
|
||||
|
||||
[['events' as const, 'snapshots' as const], false, false, true],
|
||||
[['events' as const, 'snapshots' as const], false, true, true],
|
||||
[['events' as const, 'snapshots' as const], true, false, true],
|
||||
[['events' as const, 'snapshots' as const], true, true, true],
|
||||
|
||||
[['events' as const, 'clips' as const], false, false, true],
|
||||
[['events' as const, 'clips' as const], false, true, true],
|
||||
[['events' as const, 'clips' as const], true, false, true],
|
||||
[['events' as const, 'clips' as const], true, true, true],
|
||||
|
||||
[['events' as const], false, false, true],
|
||||
[['events' as const], false, true, true],
|
||||
[['events' as const], true, false, true],
|
||||
[['events' as const], true, true, true],
|
||||
|
||||
[['snapshots' as const, 'clips' as const], false, false, false],
|
||||
[['snapshots' as const, 'clips' as const], false, true, true],
|
||||
[['snapshots' as const, 'clips' as const], true, false, true],
|
||||
[['snapshots' as const, 'clips' as const], true, true, true],
|
||||
|
||||
[['snapshots' as const], false, false, false],
|
||||
[['snapshots' as const], false, true, false],
|
||||
[['snapshots' as const], true, false, true],
|
||||
[['snapshots' as const], true, true, true],
|
||||
|
||||
[['clips' as const], false, false, false],
|
||||
[['clips' as const], false, true, true],
|
||||
[['clips' as const], true, false, false],
|
||||
[['clips' as const], true, true, true],
|
||||
])(
|
||||
'with events %s when snapshot %s and clip %s',
|
||||
async (
|
||||
events: CameraTriggerEventType[],
|
||||
hasSnapshot: boolean,
|
||||
hasClip: boolean,
|
||||
call: boolean,
|
||||
) => {
|
||||
const eventCallback = vi.fn();
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
id: 'CAMERA_1',
|
||||
frigate: {
|
||||
camera_name: 'camera.front_door',
|
||||
},
|
||||
triggers: {
|
||||
events: events,
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
{
|
||||
eventCallback: eventCallback,
|
||||
},
|
||||
);
|
||||
|
||||
const hass = createHASS();
|
||||
await camera.initialize(hass, mock<EntityRegistryManager>());
|
||||
|
||||
callHASubscribeMessageHandler(
|
||||
hass,
|
||||
createFrigateEventChangeTrigger(
|
||||
'new',
|
||||
{
|
||||
camera: 'camera.front_door',
|
||||
snapshot: null,
|
||||
has_clip: false,
|
||||
has_snapshot: false,
|
||||
label: 'person',
|
||||
current_zones: [],
|
||||
},
|
||||
{
|
||||
camera: 'camera.front_door',
|
||||
snapshot: null,
|
||||
has_clip: hasClip,
|
||||
has_snapshot: hasSnapshot,
|
||||
label: 'person',
|
||||
current_zones: [],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (call) {
|
||||
expect(eventCallback).toBeCalledWith({
|
||||
type: 'new',
|
||||
cameraID: 'CAMERA_1',
|
||||
clip: hasClip && events.includes('clips'),
|
||||
snapshot: hasSnapshot && events.includes('snapshots'),
|
||||
fidelity: 'high',
|
||||
});
|
||||
} else {
|
||||
expect(eventCallback).not.toBeCalled();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('should handle zones correctly', () => {
|
||||
it.each([
|
||||
['has no zone', [], false],
|
||||
['has mismatched zone', ['fence'], false],
|
||||
['has matching zone', ['front_steps'], true],
|
||||
])('%s', async (_name: string, zones: string[], call: boolean) => {
|
||||
const eventCallback = vi.fn();
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
id: 'CAMERA_1',
|
||||
frigate: {
|
||||
camera_name: 'camera.front_door',
|
||||
zones: ['front_steps'],
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
{
|
||||
eventCallback: eventCallback,
|
||||
},
|
||||
);
|
||||
|
||||
const hass = createHASS();
|
||||
await camera.initialize(hass, mock<EntityRegistryManager>());
|
||||
|
||||
callHASubscribeMessageHandler(
|
||||
hass,
|
||||
createFrigateEventChangeTrigger(
|
||||
'new',
|
||||
{
|
||||
camera: 'camera.front_door',
|
||||
snapshot: null,
|
||||
has_clip: false,
|
||||
has_snapshot: false,
|
||||
label: 'person',
|
||||
current_zones: [],
|
||||
},
|
||||
{
|
||||
camera: 'camera.front_door',
|
||||
snapshot: null,
|
||||
has_clip: false,
|
||||
has_snapshot: true,
|
||||
label: 'person',
|
||||
current_zones: zones,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(eventCallback).toHaveBeenCalledTimes(call ? 1 : 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should handle labels correctly', () => {
|
||||
it.each([
|
||||
['has mismatched label', 'car', false],
|
||||
['has matching label', 'person', true],
|
||||
])('%s', async (_name: string, label: string, call: boolean) => {
|
||||
const eventCallback = vi.fn();
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
id: 'CAMERA_1',
|
||||
frigate: {
|
||||
camera_name: 'camera.front_door',
|
||||
labels: ['person'],
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
{
|
||||
eventCallback: eventCallback,
|
||||
},
|
||||
);
|
||||
|
||||
const hass = createHASS();
|
||||
await camera.initialize(hass, mock<EntityRegistryManager>());
|
||||
|
||||
callHASubscribeMessageHandler(
|
||||
hass,
|
||||
createFrigateEventChangeTrigger(
|
||||
'new',
|
||||
{
|
||||
camera: 'camera.front_door',
|
||||
snapshot: null,
|
||||
has_clip: false,
|
||||
has_snapshot: false,
|
||||
// Even new events appear to have the event label in the
|
||||
// 'before' dictionary.
|
||||
label: label,
|
||||
current_zones: [],
|
||||
},
|
||||
{
|
||||
camera: 'camera.front_door',
|
||||
snapshot: null,
|
||||
has_clip: false,
|
||||
has_snapshot: true,
|
||||
label: label,
|
||||
current_zones: [],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(eventCallback).toHaveBeenCalledTimes(call ? 1 : 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('should handle triggers', () => {
|
||||
const cameraEntity: Partial<Entity> = {
|
||||
config_entry_id: 'config_entry_id',
|
||||
};
|
||||
|
||||
const occupancySensorEntityAll: Partial<Entity> = {
|
||||
config_entry_id: 'config_entry_id',
|
||||
disabled_by: null,
|
||||
entity_id: 'binary_sensor.foo',
|
||||
unique_id: '8c4e19d258359e82bc0cf9d47b021c46:occupancy_sensor:front_door_all',
|
||||
};
|
||||
|
||||
const motionSensorEntity: Partial<Entity> = {
|
||||
config_entry_id: 'config_entry_id',
|
||||
disabled_by: null,
|
||||
entity_id: 'binary_sensor.foo',
|
||||
unique_id: '8c4e19d258359e82bc0cf9d47b021c46:motion_sensor:front_door',
|
||||
};
|
||||
|
||||
describe('should detect motion sensor', () => {
|
||||
it('without a camera name', async () => {
|
||||
const entityRegistryManager = mock<EntityRegistryManager>();
|
||||
entityRegistryManager.getEntity.mockResolvedValue(
|
||||
createRegistryEntity(cameraEntity),
|
||||
);
|
||||
entityRegistryManager.getMatchingEntities.mockResolvedValue([
|
||||
createRegistryEntity(motionSensorEntity),
|
||||
]);
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
triggers: {
|
||||
motion: true,
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const hass = createHASS();
|
||||
await camera.initialize(hass, entityRegistryManager);
|
||||
|
||||
expect(camera.getConfig().triggers.entities).toEqual([]);
|
||||
});
|
||||
|
||||
it('with camera entity and name', async () => {
|
||||
const entityRegistryManager = mock<EntityRegistryManager>();
|
||||
entityRegistryManager.getEntity.mockResolvedValue(
|
||||
createRegistryEntity(cameraEntity),
|
||||
);
|
||||
entityRegistryManager.getMatchingEntities.mockResolvedValue([
|
||||
createRegistryEntity(motionSensorEntity),
|
||||
]);
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
camera_entity: 'camera.front_door',
|
||||
frigate: {
|
||||
camera_name: 'front_door',
|
||||
},
|
||||
triggers: {
|
||||
motion: true,
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const hass = createHASS();
|
||||
await camera.initialize(hass, entityRegistryManager);
|
||||
|
||||
expect(camera.getConfig().triggers.entities).toEqual(['binary_sensor.foo']);
|
||||
});
|
||||
|
||||
it('with matching entity', async () => {
|
||||
const entityRegistryManager = mock<EntityRegistryManager>();
|
||||
entityRegistryManager.getEntity.mockResolvedValue(
|
||||
createRegistryEntity(cameraEntity),
|
||||
);
|
||||
entityRegistryManager.getMatchingEntities.mockResolvedValue([
|
||||
createRegistryEntity(motionSensorEntity),
|
||||
]);
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
camera_name: 'front_door',
|
||||
},
|
||||
triggers: {
|
||||
motion: true,
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const hass = createHASS();
|
||||
await camera.initialize(hass, entityRegistryManager);
|
||||
|
||||
expect(camera.getConfig().triggers.entities).toEqual(['binary_sensor.foo']);
|
||||
});
|
||||
|
||||
it('without matching entity', async () => {
|
||||
const entityRegistryManager = mock<EntityRegistryManager>();
|
||||
entityRegistryManager.getEntity.mockResolvedValue(
|
||||
createRegistryEntity(cameraEntity),
|
||||
);
|
||||
entityRegistryManager.getMatchingEntities.mockResolvedValue([]);
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
camera_name: 'front_door',
|
||||
},
|
||||
triggers: {
|
||||
motion: true,
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const hass = createHASS();
|
||||
await camera.initialize(hass, entityRegistryManager);
|
||||
|
||||
expect(camera.getConfig().triggers.entities).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should detect occupancy sensor', () => {
|
||||
it('without a camera name', async () => {
|
||||
const entityRegistryManager = mock<EntityRegistryManager>();
|
||||
entityRegistryManager.getEntity.mockResolvedValue(
|
||||
createRegistryEntity(cameraEntity),
|
||||
);
|
||||
entityRegistryManager.getMatchingEntities.mockResolvedValue([
|
||||
createRegistryEntity(occupancySensorEntityAll),
|
||||
]);
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
triggers: {
|
||||
occupancy: true,
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const hass = createHASS();
|
||||
await camera.initialize(hass, entityRegistryManager);
|
||||
|
||||
expect(camera.getConfig().triggers.entities).toEqual([]);
|
||||
});
|
||||
|
||||
it('without a camera name but with occupancy trigger', async () => {
|
||||
const entityRegistryManager = mock<EntityRegistryManager>();
|
||||
entityRegistryManager.getEntity.mockResolvedValue(
|
||||
createRegistryEntity(cameraEntity),
|
||||
);
|
||||
entityRegistryManager.getMatchingEntities.mockResolvedValue([
|
||||
createRegistryEntity(occupancySensorEntityAll),
|
||||
]);
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
triggers: {
|
||||
occupancy: true,
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const hass = createHASS();
|
||||
await camera.initialize(hass, entityRegistryManager);
|
||||
|
||||
expect(camera.getConfig().triggers.entities).toEqual([]);
|
||||
});
|
||||
|
||||
it('with matching entity', async () => {
|
||||
const entityRegistryManager = mock<EntityRegistryManager>();
|
||||
entityRegistryManager.getEntity.mockResolvedValue(
|
||||
createRegistryEntity(cameraEntity),
|
||||
);
|
||||
entityRegistryManager.getMatchingEntities.mockResolvedValue([
|
||||
createRegistryEntity(occupancySensorEntityAll),
|
||||
]);
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
camera_name: 'front_door',
|
||||
},
|
||||
triggers: {
|
||||
occupancy: true,
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const hass = createHASS();
|
||||
await camera.initialize(hass, entityRegistryManager);
|
||||
|
||||
expect(camera.getConfig().triggers.entities).toEqual(['binary_sensor.foo']);
|
||||
});
|
||||
|
||||
it('without matching entity', async () => {
|
||||
const entityRegistryManager = mock<EntityRegistryManager>();
|
||||
entityRegistryManager.getEntity.mockResolvedValue(
|
||||
createRegistryEntity(cameraEntity),
|
||||
);
|
||||
entityRegistryManager.getMatchingEntities.mockResolvedValue([]);
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
camera_name: 'front_door',
|
||||
},
|
||||
triggers: {
|
||||
occupancy: true,
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const hass = createHASS();
|
||||
await camera.initialize(hass, entityRegistryManager);
|
||||
|
||||
expect(camera.getConfig().triggers.entities).toEqual([]);
|
||||
});
|
||||
|
||||
it('with zones', async () => {
|
||||
const entityRegistryManager = mock<EntityRegistryManager>();
|
||||
entityRegistryManager.getEntity.mockResolvedValue(
|
||||
createRegistryEntity(cameraEntity),
|
||||
);
|
||||
entityRegistryManager.getMatchingEntities.mockResolvedValue([
|
||||
createRegistryEntity({
|
||||
...occupancySensorEntityAll,
|
||||
unique_id: '8c4e19d258359e82bc0cf9d47b021c46:occupancy_sensor:zone_all',
|
||||
}),
|
||||
]);
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
camera_name: 'front_door',
|
||||
zones: ['zone'],
|
||||
},
|
||||
triggers: {
|
||||
occupancy: true,
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const hass = createHASS();
|
||||
await camera.initialize(hass, entityRegistryManager);
|
||||
|
||||
expect(camera.getConfig().triggers.entities).toEqual(['binary_sensor.foo']);
|
||||
});
|
||||
|
||||
it('with labels', async () => {
|
||||
const entityRegistryManager = mock<EntityRegistryManager>();
|
||||
entityRegistryManager.getEntity.mockResolvedValue(
|
||||
createRegistryEntity(cameraEntity),
|
||||
);
|
||||
entityRegistryManager.getMatchingEntities.mockResolvedValue([
|
||||
createRegistryEntity({
|
||||
...occupancySensorEntityAll,
|
||||
unique_id:
|
||||
'8c4e19d258359e82bc0cf9d47b021c46:occupancy_sensor:front_door_car',
|
||||
}),
|
||||
]);
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
camera_name: 'front_door',
|
||||
labels: ['car'],
|
||||
},
|
||||
triggers: {
|
||||
occupancy: true,
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const hass = createHASS();
|
||||
await camera.initialize(hass, entityRegistryManager);
|
||||
|
||||
expect(camera.getConfig().triggers.entities).toEqual(['binary_sensor.foo']);
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter entities with correct function', async () => {
|
||||
const entityRegistryManager = mock<EntityRegistryManager>();
|
||||
entityRegistryManager.getEntity.mockResolvedValue(
|
||||
createRegistryEntity(cameraEntity),
|
||||
);
|
||||
entityRegistryManager.getMatchingEntities.mockResolvedValue([
|
||||
createRegistryEntity({
|
||||
...occupancySensorEntityAll,
|
||||
}),
|
||||
]);
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
camera_entity: 'camera.foo',
|
||||
triggers: {
|
||||
occupancy: true,
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const hass = createHASS();
|
||||
await camera.initialize(hass, entityRegistryManager);
|
||||
|
||||
const filterFunc = entityRegistryManager.getMatchingEntities.mock.calls[0][1];
|
||||
|
||||
expect(
|
||||
filterFunc(
|
||||
createRegistryEntity({
|
||||
config_entry_id: cameraEntity.config_entry_id,
|
||||
disabled_by: '',
|
||||
entity_id: 'binary_sensor.foo',
|
||||
}),
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
expect(
|
||||
filterFunc(
|
||||
createRegistryEntity({
|
||||
config_entry_id: cameraEntity.config_entry_id,
|
||||
disabled_by: 'user',
|
||||
entity_id: 'binary_sensor.foo',
|
||||
}),
|
||||
),
|
||||
).toBeFalsy();
|
||||
|
||||
expect(
|
||||
filterFunc(
|
||||
createRegistryEntity({
|
||||
config_entry_id: cameraEntity.config_entry_id,
|
||||
disabled_by: '',
|
||||
entity_id: 'camera.is_not_a_binary_sensor',
|
||||
}),
|
||||
),
|
||||
).toBeFalsy();
|
||||
|
||||
expect(
|
||||
filterFunc(
|
||||
createRegistryEntity({
|
||||
config_entry_id: 'not_a_matching_config_entry_id',
|
||||
disabled_by: '',
|
||||
entity_id: 'binary_sensor.foo',
|
||||
}),
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ describe('GenericCameraManagerEngine', () => {
|
||||
|
||||
it('should initialize camera', async () => {
|
||||
const config = createGenericCameraConfig();
|
||||
const camera = await createEngine().initializeCamera(
|
||||
const camera = await createEngine().createCamera(
|
||||
createHASS(),
|
||||
mock<EntityRegistryManager>(),
|
||||
config,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { Camera } from '../../src/camera-manager/camera.js';
|
||||
import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js';
|
||||
@@ -6,11 +6,7 @@ import { CameraManagerStore } from '../../src/camera-manager/store.js';
|
||||
import { Engine } from '../../src/camera-manager/types.js';
|
||||
import { EntityRegistryManager } from '../../src/utils/ha/entity-registry/index.js';
|
||||
import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media.js';
|
||||
import {
|
||||
TestViewMedia,
|
||||
createCameraCapabilities,
|
||||
createCameraConfig,
|
||||
} from '../test-utils.js';
|
||||
import { createCameraConfig, TestViewMedia } from '../test-utils.js';
|
||||
|
||||
describe('CameraManagerStore', async () => {
|
||||
const configVisible = createCameraConfig({
|
||||
@@ -31,10 +27,14 @@ describe('CameraManagerStore', async () => {
|
||||
|
||||
const setupStore = (): CameraManagerStore => {
|
||||
const store = new CameraManagerStore();
|
||||
store.addCamera(
|
||||
new Camera(configVisible, engineGeneric, createCameraCapabilities()),
|
||||
);
|
||||
store.addCamera(new Camera(configHidden, engineFrigate, createCameraCapabilities()));
|
||||
const camera_1 = new Camera(configVisible, engineGeneric);
|
||||
const camera_2 = new Camera(configHidden, engineFrigate);
|
||||
|
||||
camera_1.destroy = vi.fn();
|
||||
camera_2.destroy = vi.fn();
|
||||
|
||||
store.addCamera(camera_1);
|
||||
store.addCamera(camera_2);
|
||||
return store;
|
||||
};
|
||||
|
||||
@@ -123,11 +123,15 @@ describe('CameraManagerStore', async () => {
|
||||
|
||||
it('reset', async () => {
|
||||
const store = setupStore();
|
||||
const cameras = [...store.getCameras().values()];
|
||||
|
||||
store.reset();
|
||||
await store.reset();
|
||||
|
||||
expect(store.getCameraCount()).toBe(0);
|
||||
expect(store.getVisibleCameraCount()).toBe(0);
|
||||
for (const camera of cameras) {
|
||||
expect(camera.destroy).toBeCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('getCameraConfigForMedia', async () => {
|
||||
@@ -169,7 +173,6 @@ describe('CameraManagerStore', async () => {
|
||||
id: 'camera-visible2',
|
||||
},
|
||||
engineGeneric,
|
||||
createCameraCapabilities(),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -209,7 +212,6 @@ describe('CameraManagerStore', async () => {
|
||||
},
|
||||
}),
|
||||
engineGeneric,
|
||||
createCameraCapabilities(),
|
||||
),
|
||||
);
|
||||
store.addCamera(
|
||||
@@ -218,7 +220,6 @@ describe('CameraManagerStore', async () => {
|
||||
id: 'two',
|
||||
}),
|
||||
engineGeneric,
|
||||
createCameraCapabilities(),
|
||||
),
|
||||
);
|
||||
expect(store.getAllDependentCameras('one')).toEqual(new Set(['one', 'two']));
|
||||
@@ -234,7 +235,6 @@ describe('CameraManagerStore', async () => {
|
||||
},
|
||||
}),
|
||||
engineGeneric,
|
||||
createCameraCapabilities(),
|
||||
),
|
||||
);
|
||||
store.addCamera(
|
||||
@@ -243,7 +243,6 @@ describe('CameraManagerStore', async () => {
|
||||
id: 'two',
|
||||
}),
|
||||
engineGeneric,
|
||||
createCameraCapabilities(),
|
||||
),
|
||||
);
|
||||
expect(store.getAllDependentCameras('one')).toEqual(new Set(['one', 'two']));
|
||||
|
||||
@@ -107,6 +107,19 @@ describe('sortMedia', () => {
|
||||
it('should remove duplicate id', () => {
|
||||
expect(sortMedia([media_1, media_2, media_3_dup_id])).toEqual([media_1, media_2]);
|
||||
});
|
||||
it('should sort by id when time not available', () => {
|
||||
expect(
|
||||
sortMedia([
|
||||
new TestViewMedia({ id: 'snake' }),
|
||||
new TestViewMedia({ id: 'zebra' }),
|
||||
new TestViewMedia({ id: 'aardvark' }),
|
||||
]),
|
||||
).toEqual([
|
||||
new TestViewMedia({ id: 'aardvark' }),
|
||||
new TestViewMedia({ id: 'snake' }),
|
||||
new TestViewMedia({ id: 'zebra' }),
|
||||
]);
|
||||
});
|
||||
it('should remove de-duplicate by object if no id', () => {
|
||||
expect(sortMedia([media_1, media_2, media_4_no_id, media_4_no_id])).toEqual([
|
||||
media_1,
|
||||
|
||||
@@ -154,6 +154,8 @@ describe('CardElementManager', () => {
|
||||
element.removeEventListener = removeEventListener;
|
||||
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getCameraManager().reset).mockResolvedValue();
|
||||
|
||||
const manager = new CardElementManager(
|
||||
api,
|
||||
element,
|
||||
@@ -184,5 +186,7 @@ describe('CardElementManager', () => {
|
||||
expect.anything(),
|
||||
);
|
||||
expect(windowRemoveEventListener).toBeCalledWith('popstate', expect.anything());
|
||||
|
||||
expect(api.getCameraManager().reset).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -206,6 +206,14 @@ describe('ConditionsManager', () => {
|
||||
expect(epoch_1).not.toBe(epoch_2);
|
||||
});
|
||||
|
||||
it('should get state', () => {
|
||||
const state = { fullscreen: true };
|
||||
|
||||
const manager = new ConditionsManager(createCardAPI());
|
||||
manager.setState(state);
|
||||
expect(manager.getState()).toEqual(state);
|
||||
});
|
||||
|
||||
it('should not return hasHAStateConditions without HA state conditions', () => {
|
||||
const manager = new ConditionsManager(createCardAPI());
|
||||
expect(manager.hasHAStateConditions()).toBeFalsy();
|
||||
|
||||
@@ -66,18 +66,6 @@ describe('HASSManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should update triggered cameras', () => {
|
||||
const api = createAPIWithoutMediaPlayers();
|
||||
const manager = new HASSManager(api);
|
||||
|
||||
const originalHASS = createHASS();
|
||||
manager.setHASS(originalHASS);
|
||||
expect(api.getTriggersManager().updateTriggerHAState).toBeCalledWith(null);
|
||||
|
||||
manager.setHASS(createHASS());
|
||||
expect(api.getTriggersManager().updateTriggerHAState).toBeCalledWith(originalHASS);
|
||||
});
|
||||
|
||||
describe('should handle connection state change when', () => {
|
||||
it('initially disconnected', () => {
|
||||
const api = createAPIWithoutMediaPlayers();
|
||||
@@ -156,37 +144,6 @@ describe('HASSManager', () => {
|
||||
});
|
||||
|
||||
describe('should set default view when', () => {
|
||||
it('selected camera trigger entity changes', () => {
|
||||
const api = createAPIWithoutMediaPlayers();
|
||||
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
|
||||
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera.foo',
|
||||
config: createCameraConfig({
|
||||
triggers: {
|
||||
entities: ['binary_sensor.motion'],
|
||||
},
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(
|
||||
createView({
|
||||
camera: 'camera.foo',
|
||||
}),
|
||||
);
|
||||
|
||||
const manager = new HASSManager(api);
|
||||
const hass = createHASS({
|
||||
'binary_sensor.motion': createStateEntity(),
|
||||
});
|
||||
|
||||
manager.setHASS(hass);
|
||||
|
||||
expect(api.getViewManager().setViewDefault).toBeCalled();
|
||||
});
|
||||
|
||||
it('selected camera is unknown', () => {
|
||||
const api = createAPIWithoutMediaPlayers();
|
||||
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import add from 'date-fns/add';
|
||||
import { HassEntities } from 'home-assistant-js-websocket';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CardController } from '../../src/card-controller/controller';
|
||||
import { TriggersManager } from '../../src/card-controller/triggers-manager';
|
||||
import { ScanOptions, scanSchema } from '../../src/config/types';
|
||||
import { TriggersOptions, triggersSchema } from '../../src/config/types';
|
||||
import {
|
||||
createCameraConfig,
|
||||
createCameraManager,
|
||||
createCardAPI,
|
||||
createConfig,
|
||||
createHASS,
|
||||
createStateEntity,
|
||||
createStore,
|
||||
createView,
|
||||
} from '../test-utils';
|
||||
|
||||
const baseScanConfig: Partial<ScanOptions> = {
|
||||
enabled: true,
|
||||
vi.mock('lodash-es/throttle', () => ({
|
||||
default: vi.fn((fn) => fn),
|
||||
}));
|
||||
|
||||
const baseTriggersConfig: Partial<TriggersOptions> = {
|
||||
untrigger_seconds: 10,
|
||||
filter_selected_camera: false,
|
||||
actions: {
|
||||
@@ -29,21 +29,20 @@ const baseScanConfig: Partial<ScanOptions> = {
|
||||
// Creating and mocking a trigger API is a lot of boilerplate, this convenience
|
||||
// function reduces it.
|
||||
const createTriggerAPI = (options?: {
|
||||
config?: Partial<ScanOptions>;
|
||||
hassStates?: HassEntities;
|
||||
config?: Partial<TriggersOptions>;
|
||||
interaction?: boolean;
|
||||
}): CardController => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
|
||||
createConfig({
|
||||
view: {
|
||||
scan: options?.config ? scanSchema.parse(options.config) : baseScanConfig,
|
||||
triggers: options?.config
|
||||
? triggersSchema.parse(options.config)
|
||||
: baseTriggersConfig,
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(
|
||||
createHASS(options?.hassStates),
|
||||
);
|
||||
vi.mocked(api.getConditionsManager().getState).mockReturnValue({});
|
||||
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
|
||||
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
|
||||
createStore([
|
||||
@@ -60,22 +59,22 @@ const createTriggerAPI = (options?: {
|
||||
vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(
|
||||
options?.interaction ?? false,
|
||||
);
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(
|
||||
createView({
|
||||
camera: 'camera_1' as const,
|
||||
}),
|
||||
);
|
||||
|
||||
return api;
|
||||
};
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('TriggersManager', () => {
|
||||
const hassActiveState = {
|
||||
'binary_sensor.motion': createStateEntity({ state: 'on' }),
|
||||
};
|
||||
const hassInactiveState = {
|
||||
'binary_sensor.motion': createStateEntity({ state: 'off' }),
|
||||
};
|
||||
const start = new Date('2023-10-01T17:14');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(start);
|
||||
});
|
||||
|
||||
it('should not be triggered by default', () => {
|
||||
@@ -83,23 +82,8 @@ describe('TriggersManager', () => {
|
||||
expect(manager.isTriggered()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should not trigger when scan mode disabled default', () => {
|
||||
const api = createTriggerAPI({
|
||||
config: { enabled: false },
|
||||
hassStates: hassActiveState,
|
||||
});
|
||||
const manager = new TriggersManager(api);
|
||||
|
||||
manager.updateTriggerHAState(null);
|
||||
|
||||
expect(manager.isTriggered()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should not trigger without a scan config', () => {
|
||||
const api = createTriggerAPI({
|
||||
hassStates: hassActiveState,
|
||||
});
|
||||
|
||||
it('should not trigger without a triggers config', () => {
|
||||
const api = createTriggerAPI();
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(
|
||||
createView({
|
||||
camera: 'camera_1' as const,
|
||||
@@ -108,19 +92,23 @@ describe('TriggersManager', () => {
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(null);
|
||||
|
||||
const manager = new TriggersManager(api);
|
||||
manager.updateView(null);
|
||||
|
||||
manager.handleCameraEvent({
|
||||
cameraID: 'camera_1',
|
||||
type: 'new',
|
||||
});
|
||||
|
||||
expect(manager.isTriggered()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should trigger and untrigger based on entity state', () => {
|
||||
const start = new Date('2023-10-01T17:14');
|
||||
vi.setSystemTime(start);
|
||||
const api = createTriggerAPI({
|
||||
hassStates: hassActiveState,
|
||||
});
|
||||
it('should trigger and untrigger based on low fidelity event', () => {
|
||||
const api = createTriggerAPI();
|
||||
const manager = new TriggersManager(api);
|
||||
|
||||
manager.updateTriggerHAState(createHASS(hassInactiveState));
|
||||
manager.handleCameraEvent({
|
||||
cameraID: 'camera_1',
|
||||
type: 'new',
|
||||
});
|
||||
|
||||
expect(manager.isTriggered()).toBeTruthy();
|
||||
expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith({
|
||||
@@ -132,14 +120,14 @@ describe('TriggersManager', () => {
|
||||
cameraID: 'camera_1' as const,
|
||||
});
|
||||
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(
|
||||
createHASS(hassInactiveState),
|
||||
);
|
||||
vi.mocked(api.getConditionsManager().getState).mockReturnValue({
|
||||
triggered: new Set(['camera_1']),
|
||||
});
|
||||
|
||||
manager.updateTriggerHAState(createHASS(hassActiveState));
|
||||
|
||||
// Intentional state update with no change.
|
||||
manager.updateTriggerHAState(createHASS(hassActiveState));
|
||||
manager.handleCameraEvent({
|
||||
cameraID: 'camera_1',
|
||||
type: 'end',
|
||||
});
|
||||
|
||||
// Will still be triggered, but untrigger timer will be running.
|
||||
expect(manager.isTriggered()).toBeTruthy();
|
||||
@@ -155,34 +143,141 @@ describe('TriggersManager', () => {
|
||||
expect(api.getViewManager().setViewDefault).toBeCalled();
|
||||
});
|
||||
|
||||
it('should trigger when entity state is active on startup', () => {
|
||||
describe('should treat high fidelity events appropriately', () => {
|
||||
it('with no media', () => {
|
||||
const api = createTriggerAPI();
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
|
||||
createConfig({
|
||||
view: {
|
||||
default: 'clips',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const manager = new TriggersManager(api);
|
||||
manager.handleCameraEvent({
|
||||
cameraID: 'camera_1',
|
||||
type: 'new',
|
||||
fidelity: 'high',
|
||||
|
||||
// Intentionally blank.
|
||||
});
|
||||
|
||||
expect(manager.isTriggered()).toBeTruthy();
|
||||
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('with media', () => {
|
||||
const api = createTriggerAPI();
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
|
||||
createConfig({
|
||||
view: {
|
||||
default: 'clips',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const manager = new TriggersManager(api);
|
||||
manager.handleCameraEvent({
|
||||
cameraID: 'camera_1',
|
||||
type: 'new',
|
||||
fidelity: 'high',
|
||||
clip: true,
|
||||
});
|
||||
|
||||
expect(manager.isTriggered()).toBeTruthy();
|
||||
expect(api.getViewManager().setViewDefault).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should change to default view when suitably configured', () => {
|
||||
const api = createTriggerAPI({
|
||||
hassStates: hassActiveState,
|
||||
config: {
|
||||
actions: {
|
||||
interaction_mode: 'all',
|
||||
trigger: 'default',
|
||||
untrigger: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
const manager = new TriggersManager(api);
|
||||
expect(manager.isTriggered()).toBeFalsy();
|
||||
|
||||
manager.updateTriggerHAState(null);
|
||||
manager.handleCameraEvent({
|
||||
cameraID: 'camera_1',
|
||||
type: 'new',
|
||||
});
|
||||
|
||||
expect(manager.isTriggered()).toBeTruthy();
|
||||
expect(api.getViewManager().setViewDefault).toBeCalledWith({
|
||||
cameraID: 'camera_1',
|
||||
});
|
||||
});
|
||||
|
||||
describe('should change to media view', () => {
|
||||
it.each([
|
||||
[false, false, null],
|
||||
[false, true, 'clip' as const],
|
||||
[true, false, 'snapshot' as const],
|
||||
[true, true, 'clip' as const],
|
||||
])(
|
||||
'with snapshot %s and clip %s',
|
||||
async (
|
||||
hasSnapshot: boolean,
|
||||
hasClip: boolean,
|
||||
viewName: 'clip' | 'snapshot' | null,
|
||||
) => {
|
||||
const api = createTriggerAPI({
|
||||
config: {
|
||||
actions: {
|
||||
interaction_mode: 'all',
|
||||
trigger: 'media',
|
||||
untrigger: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
const manager = new TriggersManager(api);
|
||||
|
||||
manager.handleCameraEvent({
|
||||
cameraID: 'camera_1',
|
||||
type: 'new',
|
||||
fidelity: 'high',
|
||||
snapshot: hasSnapshot,
|
||||
clip: hasClip,
|
||||
});
|
||||
|
||||
if (!viewName) {
|
||||
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
||||
} else {
|
||||
expect(manager.isTriggered()).toBeTruthy();
|
||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
|
||||
cameraID: 'camera_1',
|
||||
viewName: viewName,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should take no actions with human interactions', () => {
|
||||
const start = new Date('2023-10-01T17:14');
|
||||
const api = createTriggerAPI({
|
||||
hassStates: hassActiveState,
|
||||
// Interaction present.
|
||||
interaction: true,
|
||||
});
|
||||
const manager = new TriggersManager(api);
|
||||
manager.updateTriggerHAState(createHASS(hassInactiveState));
|
||||
|
||||
manager.handleCameraEvent({
|
||||
cameraID: 'camera_1',
|
||||
type: 'new',
|
||||
});
|
||||
|
||||
expect(manager.isTriggered()).toBeTruthy();
|
||||
|
||||
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
||||
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(
|
||||
createHASS(hassInactiveState),
|
||||
);
|
||||
manager.updateTriggerHAState(createHASS(hassActiveState));
|
||||
manager.handleCameraEvent({
|
||||
cameraID: 'camera_1',
|
||||
type: 'end',
|
||||
});
|
||||
|
||||
vi.setSystemTime(add(start, { seconds: 10 }));
|
||||
vi.runOnlyPendingTimers();
|
||||
@@ -193,11 +288,8 @@ describe('TriggersManager', () => {
|
||||
});
|
||||
|
||||
it('should take no actions when actions are set to none', () => {
|
||||
const start = new Date('2023-10-01T17:14');
|
||||
const api = createTriggerAPI({
|
||||
hassStates: hassActiveState,
|
||||
config: {
|
||||
enabled: true,
|
||||
actions: {
|
||||
interaction_mode: 'all',
|
||||
trigger: 'none',
|
||||
@@ -206,32 +298,31 @@ describe('TriggersManager', () => {
|
||||
},
|
||||
});
|
||||
const manager = new TriggersManager(api);
|
||||
manager.updateTriggerHAState(createHASS(hassInactiveState));
|
||||
manager.handleCameraEvent({
|
||||
cameraID: 'camera_1',
|
||||
type: 'new',
|
||||
});
|
||||
expect(manager.isTriggered()).toBeTruthy();
|
||||
|
||||
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
||||
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(
|
||||
createHASS(hassInactiveState),
|
||||
);
|
||||
manager.updateTriggerHAState(createHASS(hassActiveState));
|
||||
manager.handleCameraEvent({
|
||||
cameraID: 'camera_1',
|
||||
type: 'end',
|
||||
});
|
||||
|
||||
vi.setSystemTime(add(start, { seconds: 10 }));
|
||||
vi.runOnlyPendingTimers();
|
||||
|
||||
expect(manager.isTriggered()).toBeFalsy();
|
||||
|
||||
expect(api.getViewManager().setViewDefault).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should take actions with human interactions when interaction mode is active', () => {
|
||||
const start = new Date('2023-10-01T17:14');
|
||||
const api = createTriggerAPI({
|
||||
hassStates: hassActiveState,
|
||||
// Interaction present.
|
||||
interaction: true,
|
||||
config: {
|
||||
...baseScanConfig,
|
||||
...baseTriggersConfig,
|
||||
actions: {
|
||||
trigger: 'live' as const,
|
||||
untrigger: 'default' as const,
|
||||
@@ -240,18 +331,21 @@ describe('TriggersManager', () => {
|
||||
},
|
||||
});
|
||||
const manager = new TriggersManager(api);
|
||||
manager.updateTriggerHAState(createHASS(hassInactiveState));
|
||||
expect(manager.isTriggered()).toBeTruthy();
|
||||
manager.handleCameraEvent({
|
||||
cameraID: 'camera_1',
|
||||
type: 'new',
|
||||
});
|
||||
|
||||
expect(manager.isTriggered()).toBeTruthy();
|
||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
|
||||
viewName: 'live' as const,
|
||||
cameraID: 'camera_1' as const,
|
||||
});
|
||||
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(
|
||||
createHASS(hassInactiveState),
|
||||
);
|
||||
manager.updateTriggerHAState(createHASS(hassActiveState));
|
||||
manager.handleCameraEvent({
|
||||
cameraID: 'camera_1',
|
||||
type: 'end',
|
||||
});
|
||||
|
||||
vi.setSystemTime(add(start, { seconds: 10 }));
|
||||
vi.runOnlyPendingTimers();
|
||||
@@ -262,14 +356,7 @@ describe('TriggersManager', () => {
|
||||
});
|
||||
|
||||
it('should report multiple triggered cameras', () => {
|
||||
const start = new Date('2023-10-01T17:14');
|
||||
const bothOnState = {
|
||||
'binary_sensor.one': createStateEntity({ state: 'on' }),
|
||||
'binary_sensor.two': createStateEntity({ state: 'on' }),
|
||||
};
|
||||
const api = createTriggerAPI({
|
||||
hassStates: bothOnState,
|
||||
});
|
||||
const api = createTriggerAPI();
|
||||
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
|
||||
createStore([
|
||||
{
|
||||
@@ -297,7 +384,15 @@ describe('TriggersManager', () => {
|
||||
expect(manager.getMostRecentlyTriggeredCameraID()).toBeNull();
|
||||
expect(manager.getTriggeredCameraIDs()).toEqual(new Set());
|
||||
|
||||
manager.updateTriggerHAState(null);
|
||||
manager.handleCameraEvent({
|
||||
cameraID: 'camera_1',
|
||||
type: 'new',
|
||||
});
|
||||
manager.handleCameraEvent({
|
||||
cameraID: 'camera_2',
|
||||
type: 'new',
|
||||
});
|
||||
|
||||
expect(manager.isTriggered()).toBeTruthy();
|
||||
expect(manager.getTriggeredCameraIDs()).toEqual(new Set(['camera_1', 'camera_2']));
|
||||
|
||||
@@ -306,14 +401,10 @@ describe('TriggersManager', () => {
|
||||
manager.getMostRecentlyTriggeredCameraID(),
|
||||
);
|
||||
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(
|
||||
createHASS({
|
||||
'binary_sensor.one': createStateEntity({ state: 'off' }),
|
||||
'binary_sensor.two': createStateEntity({ state: 'on' }),
|
||||
}),
|
||||
);
|
||||
|
||||
manager.updateTriggerHAState(createHASS(bothOnState));
|
||||
manager.handleCameraEvent({
|
||||
cameraID: 'camera_1',
|
||||
type: 'end',
|
||||
});
|
||||
|
||||
vi.setSystemTime(add(start, { seconds: 10 }));
|
||||
vi.runOnlyPendingTimers();
|
||||
@@ -322,75 +413,87 @@ describe('TriggersManager', () => {
|
||||
expect(manager.getMostRecentlyTriggeredCameraID()).toBe('camera_2');
|
||||
});
|
||||
|
||||
it('should filter triggers by camera', () => {
|
||||
const start = new Date('2023-10-01T17:14');
|
||||
const api = createTriggerAPI({
|
||||
config: {
|
||||
...baseScanConfig,
|
||||
// Filter triggers to selected camera only.
|
||||
filter_selected_camera: true,
|
||||
},
|
||||
hassStates: hassActiveState,
|
||||
});
|
||||
const manager = new TriggersManager(api);
|
||||
manager.updateTriggerHAState(createHASS(hassInactiveState));
|
||||
expect(manager.isTriggered()).toBeFalsy();
|
||||
describe('should filter triggers by camera', () => {
|
||||
it('no dependencies', () => {
|
||||
const api = createTriggerAPI({
|
||||
config: {
|
||||
...baseTriggersConfig,
|
||||
// Filter triggers to selected camera only.
|
||||
filter_selected_camera: true,
|
||||
},
|
||||
});
|
||||
const manager = new TriggersManager(api);
|
||||
expect(manager.isTriggered()).toBeFalsy();
|
||||
|
||||
const otherCameraSelected = createView({
|
||||
camera: 'camera_SOME_OTHER_CAMERA' as const,
|
||||
const otherCameraSelected = createView({
|
||||
camera: 'camera_SOME_OTHER_CAMERA' as const,
|
||||
});
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(otherCameraSelected);
|
||||
|
||||
manager.handleCameraEvent({
|
||||
cameraID: 'camera_1',
|
||||
type: 'new',
|
||||
});
|
||||
expect(manager.isTriggered()).toBeFalsy();
|
||||
|
||||
const thisCameraSelected = createView({
|
||||
camera: 'camera_1' as const,
|
||||
});
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(thisCameraSelected);
|
||||
|
||||
manager.handleCameraEvent({
|
||||
cameraID: 'camera_1',
|
||||
type: 'new',
|
||||
});
|
||||
expect(manager.isTriggered()).toBeTruthy();
|
||||
});
|
||||
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(otherCameraSelected);
|
||||
manager.updateView(null);
|
||||
expect(manager.isTriggered()).toBeFalsy();
|
||||
it('dependencies', () => {
|
||||
const api = createTriggerAPI({
|
||||
config: {
|
||||
...baseTriggersConfig,
|
||||
// Filter triggers to selected camera only.
|
||||
filter_selected_camera: true,
|
||||
},
|
||||
});
|
||||
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera_primary',
|
||||
config: createCameraConfig({
|
||||
triggers: {
|
||||
entities: ['binary_sensor.motion'],
|
||||
},
|
||||
dependencies: {
|
||||
all_cameras: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera_secondary',
|
||||
config: createCameraConfig({
|
||||
triggers: {
|
||||
entities: ['binary_sensor.motion'],
|
||||
},
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const thisCameraSelected = createView({
|
||||
camera: 'camera_1' as const,
|
||||
const manager = new TriggersManager(api);
|
||||
|
||||
const primaryCameraView = createView({
|
||||
camera: 'camera_primary' as const,
|
||||
});
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(primaryCameraView);
|
||||
|
||||
// Events for the secondary will still trigger when filter_selected_camera
|
||||
// is true.
|
||||
manager.handleCameraEvent({
|
||||
cameraID: 'camera_secondary',
|
||||
type: 'new',
|
||||
});
|
||||
expect(manager.isTriggered()).toBeTruthy();
|
||||
});
|
||||
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(thisCameraSelected);
|
||||
manager.updateView(otherCameraSelected);
|
||||
expect(manager.isTriggered()).toBeTruthy();
|
||||
|
||||
// Ensure a view change to the same camera stays triggered.
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(thisCameraSelected);
|
||||
manager.updateView(thisCameraSelected);
|
||||
expect(manager.isTriggered()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not untrigger triggers by camera', () => {
|
||||
const start = new Date('2023-10-01T17:14');
|
||||
const api = createTriggerAPI({
|
||||
config: {
|
||||
...baseScanConfig,
|
||||
// Filter triggers to selected camera only.
|
||||
filter_selected_camera: true,
|
||||
},
|
||||
hassStates: hassActiveState,
|
||||
});
|
||||
const manager = new TriggersManager(api);
|
||||
manager.updateTriggerHAState(createHASS(hassInactiveState));
|
||||
expect(manager.isTriggered()).toBeFalsy();
|
||||
|
||||
const otherCameraSelected = createView({
|
||||
camera: 'camera_SOME_OTHER_CAMERA' as const,
|
||||
});
|
||||
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(otherCameraSelected);
|
||||
manager.updateView(null);
|
||||
expect(manager.isTriggered()).toBeFalsy();
|
||||
|
||||
const thisCameraSelected = createView({
|
||||
camera: 'camera_1' as const,
|
||||
});
|
||||
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(thisCameraSelected);
|
||||
manager.updateView(otherCameraSelected);
|
||||
expect(manager.isTriggered()).toBeTruthy();
|
||||
|
||||
// Ensure a view change to the same camera stays triggered.
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(thisCameraSelected);
|
||||
manager.updateView(thisCameraSelected);
|
||||
expect(manager.isTriggered()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
+94
-46
@@ -4,6 +4,7 @@ import {
|
||||
createRangedTransform,
|
||||
deleteConfigValue,
|
||||
deleteTransform,
|
||||
deleteWithOverrides,
|
||||
getArrayConfigPath,
|
||||
getConfigValue,
|
||||
isConfigUpgradeable,
|
||||
@@ -344,6 +345,12 @@ describe('upgrade functions', () => {
|
||||
it('should have upgrades with bad input data', () => {
|
||||
expect(upgradeConfig(3 as unknown as RawFrigateCardConfig)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should delete properties', () => {
|
||||
const config = { c: 10, d: 10 };
|
||||
expect(deleteWithOverrides('c')(config)).toBeTruthy();
|
||||
expect(config).toEqual({ d: 10 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('should handle version specific upgrades', () => {
|
||||
@@ -1167,52 +1174,6 @@ describe('should handle version specific upgrades', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('should move and transform untrigger_reset', () => {
|
||||
it('when true', () => {
|
||||
const config = {
|
||||
type: 'custom:frigate-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
view: {
|
||||
scan: {
|
||||
untrigger_reset: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config).toEqual({
|
||||
type: 'custom:frigate-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
view: {
|
||||
scan: {
|
||||
actions: {
|
||||
untrigger: 'default',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('when false', () => {
|
||||
const config = {
|
||||
type: 'custom:frigate-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
view: {
|
||||
scan: {
|
||||
untrigger_reset: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config).toEqual({
|
||||
type: 'custom:frigate-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
view: {
|
||||
scan: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should move view.timeout_seconds', () => {
|
||||
const config = {
|
||||
type: 'custom:frigate-card',
|
||||
@@ -1690,5 +1651,92 @@ describe('should handle version specific upgrades', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('should transform scan mode', () => {
|
||||
describe('should move and transform untrigger_reset', () => {
|
||||
it('when true', () => {
|
||||
const config = {
|
||||
type: 'custom:frigate-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
view: {
|
||||
scan: {
|
||||
untrigger_reset: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config).toEqual({
|
||||
type: 'custom:frigate-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
view: {
|
||||
triggers: {
|
||||
actions: {
|
||||
untrigger: 'default',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('when false', () => {
|
||||
const config = {
|
||||
type: 'custom:frigate-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
view: {
|
||||
scan: {
|
||||
untrigger_reset: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config).toEqual({
|
||||
type: 'custom:frigate-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
view: {
|
||||
triggers: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('should rename view.scan.enabled to a trigger action', () => {
|
||||
it('when true', () => {
|
||||
const config = {
|
||||
view: {
|
||||
scan: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config).toEqual({
|
||||
view: {
|
||||
triggers: {
|
||||
filter_selected_camera: false,
|
||||
actions: {
|
||||
trigger: 'live',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('when false', () => {
|
||||
const config = {
|
||||
view: {
|
||||
scan: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config).toEqual({
|
||||
view: {
|
||||
triggers: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,9 +38,10 @@ describe('config defaults', () => {
|
||||
},
|
||||
},
|
||||
triggers: {
|
||||
events: ['events', 'clips', 'snapshots'],
|
||||
entities: [],
|
||||
motion: false,
|
||||
occupancy: true,
|
||||
occupancy: false,
|
||||
},
|
||||
},
|
||||
debug: {
|
||||
@@ -279,16 +280,15 @@ describe('config defaults', () => {
|
||||
camera_select: 'current',
|
||||
dark_mode: 'off',
|
||||
default: 'live',
|
||||
scan: {
|
||||
enabled: false,
|
||||
show_trigger_status: true,
|
||||
triggers: {
|
||||
show_trigger_status: false,
|
||||
untrigger_seconds: 0,
|
||||
actions: {
|
||||
trigger: 'live',
|
||||
untrigger: 'default',
|
||||
trigger: 'default',
|
||||
untrigger: 'none',
|
||||
interaction_mode: 'inactive',
|
||||
},
|
||||
filter_selected_camera: false,
|
||||
filter_selected_camera: true,
|
||||
},
|
||||
interaction_seconds: 300,
|
||||
reset_after_interaction: true,
|
||||
|
||||
+37
-9
@@ -1,5 +1,6 @@
|
||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import { HassEntities, HassEntity } from 'home-assistant-js-websocket';
|
||||
import { vi } from 'vitest';
|
||||
import { expect, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { Camera } from '../src/camera-manager/camera';
|
||||
import { CameraManagerEngine } from '../src/camera-manager/engine';
|
||||
@@ -8,6 +9,7 @@ import { GenericCameraManagerEngine } from '../src/camera-manager/generic/engine
|
||||
import { CameraManager } from '../src/camera-manager/manager';
|
||||
import { CameraManagerStore } from '../src/camera-manager/store';
|
||||
import {
|
||||
CameraEventCallback,
|
||||
CameraManagerCameraCapabilities,
|
||||
CameraManagerCapabilities,
|
||||
CameraManagerMediaCapabilities,
|
||||
@@ -36,16 +38,17 @@ import { TriggersManager } from '../src/card-controller/triggers-manager';
|
||||
import { ViewManager } from '../src/card-controller/view-manager';
|
||||
import {
|
||||
CameraConfig,
|
||||
cameraConfigSchema,
|
||||
FrigateCardCondition,
|
||||
frigateCardConditionSchema,
|
||||
FrigateCardConfig,
|
||||
frigateCardConfigSchema,
|
||||
PerformanceConfig,
|
||||
performanceConfigSchema,
|
||||
RawFrigateCardConfig,
|
||||
cameraConfigSchema,
|
||||
frigateCardConditionSchema,
|
||||
frigateCardConfigSchema,
|
||||
performanceConfigSchema,
|
||||
} from '../src/config/types';
|
||||
import { ExtendedHomeAssistant, MediaLoadedInfo } from '../src/types';
|
||||
import { EntityRegistryManager } from '../src/utils/ha/entity-registry';
|
||||
import { Entity } from '../src/utils/ha/entity-registry/types';
|
||||
import { ViewMedia, ViewMediaType } from '../src/view/media';
|
||||
import { MediaQueriesResults } from '../src/view/media-queries-results';
|
||||
@@ -69,11 +72,20 @@ export const createConfig = (config?: RawFrigateCardConfig): FrigateCardConfig =
|
||||
});
|
||||
};
|
||||
|
||||
export const createCamera = (
|
||||
config: CameraConfig,
|
||||
engine: CameraManagerEngine,
|
||||
capabilities?: CameraManagerCameraCapabilities,
|
||||
): Camera => {
|
||||
return new Camera(config, engine, { capabilities: capabilities });
|
||||
};
|
||||
|
||||
export const createHASS = (states?: HassEntities): ExtendedHomeAssistant => {
|
||||
const hass = mock<ExtendedHomeAssistant>();
|
||||
if (states) {
|
||||
hass.states = states;
|
||||
}
|
||||
hass.connection.subscribeMessage = vi.fn();
|
||||
return hass;
|
||||
};
|
||||
|
||||
@@ -158,14 +170,19 @@ export const createStore = (
|
||||
engine?: CameraManagerEngine;
|
||||
config?: CameraConfig;
|
||||
capabilities?: CameraManagerCameraCapabilities;
|
||||
eventCallback?: CameraEventCallback;
|
||||
}[],
|
||||
): CameraManagerStore => {
|
||||
const store = new CameraManagerStore();
|
||||
for (const cameraProps of cameras ?? []) {
|
||||
const eventCallback = cameraProps.eventCallback ?? vi.fn();
|
||||
const camera = new Camera(
|
||||
cameraProps.config ?? createCameraConfig(),
|
||||
cameraProps.engine ?? new GenericCameraManagerEngine(),
|
||||
cameraProps.capabilities ?? createCameraCapabilities(),
|
||||
cameraProps.engine ?? new GenericCameraManagerEngine(eventCallback),
|
||||
{
|
||||
capabilities: cameraProps.capabilities ?? createCameraCapabilities(),
|
||||
eventCallback: eventCallback,
|
||||
},
|
||||
);
|
||||
camera.setID(cameraProps.cameraID);
|
||||
store.addCamera(camera);
|
||||
@@ -260,10 +277,10 @@ export class TestViewMedia extends ViewMedia {
|
||||
|
||||
constructor(options?: {
|
||||
id?: string | null;
|
||||
startTime?: Date;
|
||||
startTime?: Date | null;
|
||||
mediaType?: ViewMediaType;
|
||||
cameraID?: string;
|
||||
endTime?: Date;
|
||||
endTime?: Date | null;
|
||||
inProgress?: boolean;
|
||||
contentID?: string;
|
||||
title?: string;
|
||||
@@ -364,6 +381,7 @@ export const createCardAPI = (): CardController => {
|
||||
api.getConditionsManager.mockReturnValue(mock<ConditionsManager>());
|
||||
api.getConfigManager.mockReturnValue(mock<ConfigManager>());
|
||||
api.getDownloadManager.mockReturnValue(mock<DownloadManager>());
|
||||
api.getEntityRegistryManager.mockReturnValue(mock<EntityRegistryManager>());
|
||||
api.getExpandManager.mockReturnValue(mock<ExpandManager>());
|
||||
api.getFullscreenManager.mockReturnValue(mock<FullscreenManager>());
|
||||
api.getHASSManager.mockReturnValue(mock<HASSManager>());
|
||||
@@ -380,3 +398,13 @@ export const createCardAPI = (): CardController => {
|
||||
|
||||
return api;
|
||||
};
|
||||
|
||||
export const callHASubscribeMessageHandler = (
|
||||
hass: HomeAssistant,
|
||||
ev: unknown,
|
||||
n = 0,
|
||||
): void => {
|
||||
const mock = vi.mocked(hass.connection.subscribeMessage).mock;
|
||||
expect(mock.calls.length).greaterThan(n);
|
||||
mock.calls[n][0](ev);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
getEntityIcon,
|
||||
hasHAConnectionStateChanged,
|
||||
parseStateChangeTrigger,
|
||||
subscribeToTrigger,
|
||||
} from '../../../src/utils/ha/index.js';
|
||||
import { createHASS, createStateEntity } from '../../test-utils.js';
|
||||
|
||||
@@ -67,3 +69,105 @@ describe('getEntityIcon', () => {
|
||||
expect(getEntityIcon(createHASS(), 'camera.test')).toBe('mdi:video');
|
||||
});
|
||||
});
|
||||
|
||||
describe('should subscribe to trigger', () => {
|
||||
it('mqtt', async () => {
|
||||
const hass = createHASS();
|
||||
const callback = vi.fn();
|
||||
|
||||
await subscribeToTrigger(hass, callback, {
|
||||
platform: 'mqtt',
|
||||
topic: 'topic',
|
||||
payload: 'payload',
|
||||
valueTemplate: 'value_template',
|
||||
});
|
||||
|
||||
expect(hass.connection.subscribeMessage).toBeCalledWith(callback, {
|
||||
type: 'subscribe_trigger',
|
||||
trigger: {
|
||||
platform: 'mqtt',
|
||||
topic: 'topic',
|
||||
payload: 'payload',
|
||||
value_template: 'value_template',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('state and attributes', async () => {
|
||||
const hass = createHASS();
|
||||
const callback = vi.fn();
|
||||
|
||||
await subscribeToTrigger(hass, callback, {
|
||||
platform: 'state',
|
||||
entityID: 'camera.foo',
|
||||
});
|
||||
|
||||
expect(hass.connection.subscribeMessage).toBeCalledWith(callback, {
|
||||
type: 'subscribe_trigger',
|
||||
trigger: {
|
||||
platform: 'state',
|
||||
entity_id: 'camera.foo',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('state only', async () => {
|
||||
const hass = createHASS();
|
||||
const callback = vi.fn();
|
||||
|
||||
await subscribeToTrigger(hass, callback, {
|
||||
platform: 'state',
|
||||
entityID: 'camera.foo',
|
||||
stateOnly: true,
|
||||
});
|
||||
|
||||
expect(hass.connection.subscribeMessage).toBeCalledWith(callback, {
|
||||
type: 'subscribe_trigger',
|
||||
trigger: {
|
||||
platform: 'state',
|
||||
entity_id: 'camera.foo',
|
||||
from: null,
|
||||
to: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('should parse state change response', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('malformed response', async () => {
|
||||
const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
|
||||
|
||||
expect(parseStateChangeTrigger('INVALID')).toBeNull();
|
||||
|
||||
expect(consoleSpy).toBeCalledWith('Ignoring unparseable HA state change', 'INVALID');
|
||||
});
|
||||
|
||||
it('valid response', async () => {
|
||||
const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
|
||||
|
||||
const stateChange = {
|
||||
from_state: {
|
||||
entity_id: 'camera.foo',
|
||||
state: 'off',
|
||||
},
|
||||
to_state: {
|
||||
entity_id: 'camera.foo',
|
||||
state: 'on',
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
parseStateChangeTrigger({
|
||||
variables: {
|
||||
trigger: stateChange,
|
||||
},
|
||||
}),
|
||||
).toEqual(stateChange);
|
||||
|
||||
expect(consoleSpy).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -202,6 +202,40 @@ describe('changeViewToRecentEventsForCameraAndDependents', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect useCache', async () => {
|
||||
const cameraManager = createCameraManager();
|
||||
vi.mocked(cameraManager.getStore).mockReturnValue(
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera',
|
||||
},
|
||||
]),
|
||||
);
|
||||
vi.mocked(cameraManager.generateDefaultEventQueries).mockReturnValue([
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: new Set(['camera']),
|
||||
},
|
||||
]);
|
||||
|
||||
await changeViewToRecentEventsForCameraAndDependents(
|
||||
createElementListenForView().element,
|
||||
cameraManager,
|
||||
{},
|
||||
createView(),
|
||||
{
|
||||
useCache: false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(vi.mocked(cameraManager.executeMediaQueries)).toBeCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
useCache: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('should respect request for media type', () => {
|
||||
it.each([
|
||||
['snapshots' as const, 'hasSnapshot'],
|
||||
@@ -463,6 +497,42 @@ describe('changeViewToRecentRecordingForCameraAndDependents', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect useCache', async () => {
|
||||
const cameraManager = createCameraManager();
|
||||
vi.mocked(cameraManager.getStore).mockReturnValue(
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera',
|
||||
},
|
||||
]),
|
||||
);
|
||||
vi.mocked(cameraManager.generateDefaultRecordingQueries).mockReturnValue([
|
||||
{
|
||||
type: QueryType.Recording,
|
||||
cameraIDs: new Set(['camera']),
|
||||
},
|
||||
]);
|
||||
|
||||
await changeViewToRecentRecordingForCameraAndDependents(
|
||||
createElementListenForView().element,
|
||||
cameraManager,
|
||||
{},
|
||||
createView(),
|
||||
{
|
||||
targetView: 'recordings',
|
||||
select: 'latest',
|
||||
useCache: false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(vi.mocked(cameraManager.executeMediaQueries)).toBeCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
useCache: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
+10
-5
@@ -3,17 +3,22 @@ import { defineConfig } from 'vitest/config';
|
||||
// ts-prune-ignore-next
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: [
|
||||
"tests/**/*.test.ts"
|
||||
],
|
||||
coverage: {
|
||||
// Favor istanbul for coverage over v8 due to better accuracy.
|
||||
provider: 'istanbul',
|
||||
|
||||
// Thresholds will automatically be updated as coverage improves to avoid
|
||||
// back-sliding.
|
||||
thresholdAutoUpdate: true,
|
||||
statements: 73.59,
|
||||
branches: 63.6,
|
||||
functions: 74.86,
|
||||
lines: 73.48,
|
||||
thresholds: {
|
||||
autoUpdate: true,
|
||||
statements: 73.59,
|
||||
branches: 63.6,
|
||||
functions: 74.86,
|
||||
lines: 73.48,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user