feat: Add experimental reolink media support (#1694)
* feat: Add experimental rich reolink support. * Formatting fix
This commit is contained in:
@@ -28,6 +28,7 @@ cameras_global:
|
|||||||
| `icon` | Autodetected from `camera_entity` if that is specified. | The icon to use for this camera in the camera menu and in the next & previous controls when using the `icon` style. |
|
| `icon` | Autodetected from `camera_entity` if that is specified. | The icon to use for this camera in the camera menu and in the next & previous controls when using the `icon` style. |
|
||||||
| `id` | `camera_entity`, `webrtc_card.entity` or `frigate.camera_name` if set (in that preference order). | An optional identifier to use throughout the card configuration to refer unambiguously to this camera. This `id` may be used in [conditions](../conditions.md), dependencies or custom [actions](../actions/README.md) to refer to a given camera unambiguously. |
|
| `id` | `camera_entity`, `webrtc_card.entity` or `frigate.camera_name` if set (in that preference order). | An optional identifier to use throughout the card configuration to refer unambiguously to this camera. This `id` may be used in [conditions](../conditions.md), dependencies or custom [actions](../actions/README.md) to refer to a given camera unambiguously. |
|
||||||
| `live_provider` | `auto` | The choice of live stream provider. See [Live Provider](live-provider.md). |
|
| `live_provider` | `auto` | The choice of live stream provider. See [Live Provider](live-provider.md). |
|
||||||
|
| `proxy` | | Controls whether/how content is proxied via [hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration) (must be installed separately). See below. |
|
||||||
| `title` | Autodetected from `camera_entity` if that is specified. | A friendly name for this camera to use in the card. |
|
| `title` | Autodetected from `camera_entity` if that is specified. | A friendly name for this camera to use in the card. |
|
||||||
| `triggers` | | Define what should cause this camera to update/trigger. See below. |
|
| `triggers` | | Define what should cause this camera to update/trigger. See below. |
|
||||||
| `webrtc_card` | | The WebRTC entity/URL to use for this camera with the `webrtc-card` live provider. See below. |
|
| `webrtc_card` | | The WebRTC entity/URL to use for this camera with the `webrtc-card` live provider. See below. |
|
||||||
@@ -239,6 +240,50 @@ cameras:
|
|||||||
|
|
||||||
`[action]` is any [perform-action](../actions/stock/README.md?id=perform-action) action.
|
`[action]` is any [perform-action](../actions/stock/README.md?id=perform-action) action.
|
||||||
|
|
||||||
|
## `proxy`
|
||||||
|
|
||||||
|
Configures whether and how the content is proxied via
|
||||||
|
[hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration)
|
||||||
|
(this must be installed separately). This allows fetching media **through** the
|
||||||
|
Home Assistant process itself, allowing the card to access resources it
|
||||||
|
otherwise would not be able to directly access. There are [security and
|
||||||
|
performance
|
||||||
|
implications](https://github.com/dermotduffy/hass-web-proxy-integration?tab=readme-ov-file#considerations)
|
||||||
|
to consider before installing
|
||||||
|
[hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration)
|
||||||
|
and using this functionality.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cameras:
|
||||||
|
- camera_entity: camera.office
|
||||||
|
proxy:
|
||||||
|
# [...]
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Not all [engines](./engine.md) benefit from proxying:
|
||||||
|
|
||||||
|
| Engine | Purpose of proxying |
|
||||||
|
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `frigate` | The Frigate integration already comes with a built-in proxy, so this functionality does not serve any purpose for `frigate`. |
|
||||||
|
| `reolink`, `motioneEye` | May be used to fetch videos in cases where the browser may not be able to access the camera/NVR, or the camera/NVR may use a self-signed SSL certificate that your browser would otherwise reject due to [mixed content](https://developer.mozilla.org/en-US/docs/Web/Security/Mixed_content). |
|
||||||
|
| `generic` | `generic` cameras do not have media, so proxying currently would serve no purpose. |
|
||||||
|
|
||||||
|
Regardless of the parameters, the integration will never attempt to proxy
|
||||||
|
content if the
|
||||||
|
[hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration)
|
||||||
|
is not detected.
|
||||||
|
|
||||||
|
Proxying parameters:
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
| ------------------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `media` | `auto` | Whether or not to proxy media items. `true` to proxy, `false` to not proxy, or `auto` to allow the camera engine to decide whether to proxy or not. |
|
||||||
|
| `dynamic` | `true` | Whether to dynamically (at the time) request proxying of the required media item, or rely on statically user-configured pre-existing proxying. See the [hass-web-proxy-integration documentation](https://github.com/dermotduffy/hass-web-proxy-integration). |
|
||||||
|
| `ssl_verification` | `auto` | Whether to verify the validity of SSL certificates. If `true` always verifies, if `false` never verifies and if `auto` the [engine](./engine.md) decides the best setting for that camera ecosystem. |
|
||||||
|
| `ssl_ciphers` | `auto` | Whether to use `default`, `intermediate`, `insecure` or `modern` SSL ciphers. See the [Home Assistant code](https://github.com/home-assistant/core/blob/dev/homeassistant/util/ssl.py) for the precise list of SSL ciphers each implies. If `auto` the [engine](./engine.md) decides the best setting for that camera ecosystem. |
|
||||||
|
|
||||||
## `triggers`
|
## `triggers`
|
||||||
|
|
||||||
The `triggers` block configures what triggers a camera. Triggering can be used
|
The `triggers` block configures what triggers a camera. Triggering can be used
|
||||||
@@ -264,19 +309,11 @@ cameras:
|
|||||||
|
|
||||||
[](../common/expanded-warning.md ':include')
|
[](../common/expanded-warning.md ':include')
|
||||||
|
|
||||||
|
See [Engines](engine.md) and [Live Providers](live-provider.md) for other options nested under `cameras`.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
cameras:
|
cameras:
|
||||||
- camera_entity: camera.front_Door
|
- camera_entity: camera.front_Door
|
||||||
live_provider: ha
|
|
||||||
engine: auto
|
|
||||||
frigate:
|
|
||||||
url: http://my.frigate.local
|
|
||||||
client_id: frigate
|
|
||||||
camera_name: front_door
|
|
||||||
labels:
|
|
||||||
- person
|
|
||||||
zones:
|
|
||||||
- steps
|
|
||||||
# Show events for camera-2 when this camera is viewed.
|
# Show events for camera-2 when this camera is viewed.
|
||||||
dependencies:
|
dependencies:
|
||||||
all_cameras: false
|
all_cameras: false
|
||||||
@@ -297,23 +334,10 @@ cameras:
|
|||||||
x: 50
|
x: 50
|
||||||
y: 50
|
y: 50
|
||||||
- camera_entity: camera.entrance
|
- camera_entity: camera.entrance
|
||||||
live_provider: webrtc-card
|
|
||||||
engine: auto
|
|
||||||
frigate:
|
|
||||||
url: http://my-other.frigate.local
|
|
||||||
client_id: frigate-other
|
|
||||||
camera_name: entrance
|
|
||||||
labels:
|
|
||||||
- car
|
|
||||||
zones:
|
|
||||||
- driveway
|
|
||||||
icon: 'mdi:car'
|
icon: 'mdi:car'
|
||||||
title: 'Front entrance'
|
title: 'Front entrance'
|
||||||
# Custom identifier for the camera to refer to it above.
|
# Custom identifier for the camera to refer to it above.
|
||||||
id: 'camera-2'
|
id: 'camera-2'
|
||||||
webrtc_card:
|
|
||||||
entity: camera.entrance_rtsp
|
|
||||||
url: 'rtsp://username:password@camera:554/av_stream/ch0'
|
|
||||||
triggers:
|
triggers:
|
||||||
motion: false
|
motion: false
|
||||||
occupancy: true
|
occupancy: true
|
||||||
@@ -321,59 +345,6 @@ cameras:
|
|||||||
- binary_sensor.entrance_sensor
|
- binary_sensor.entrance_sensor
|
||||||
dependencies:
|
dependencies:
|
||||||
all_cameras: false
|
all_cameras: false
|
||||||
- camera_entity: camera.sitting_room
|
|
||||||
live_provider: go2rtc
|
|
||||||
go2rtc:
|
|
||||||
modes:
|
|
||||||
- webrtc
|
|
||||||
- mse
|
|
||||||
- mp4
|
|
||||||
- mjpeg
|
|
||||||
stream: sitting_room
|
|
||||||
url: 'https://my.custom.go2rtc.backend'
|
|
||||||
cast:
|
|
||||||
method: dashboard
|
|
||||||
dashboard:
|
|
||||||
dashboard_path: cast
|
|
||||||
view_path: front-door
|
|
||||||
- camera_entity: camera.sitting_room_webrtc_card
|
|
||||||
live_provider: webrtc_card
|
|
||||||
webrtc_card:
|
|
||||||
# Arbitrary WebRTC Card options, see https://github.com/AlexxIT/WebRTC#configuration .
|
|
||||||
entity: camera.sitting_room_rtsp
|
|
||||||
ui: true
|
|
||||||
- camera_entity: camera.kitchen
|
|
||||||
live_provider: jsmpeg
|
|
||||||
jsmpeg:
|
|
||||||
options:
|
|
||||||
audio: false
|
|
||||||
video: true
|
|
||||||
pauseWhenHidden: false
|
|
||||||
disableGl: false
|
|
||||||
disableWebAssembly: false
|
|
||||||
preserveDrawingBuffer: false
|
|
||||||
progressive: true
|
|
||||||
throttled: true
|
|
||||||
chunkSize: 1048576
|
|
||||||
maxAudioLag: 10
|
|
||||||
videoBufferSize: 524288
|
|
||||||
audioBufferSize: 131072
|
|
||||||
- camera_entity: camera.back_yard
|
|
||||||
live_provider: image
|
|
||||||
image:
|
|
||||||
mode: auto
|
|
||||||
refresh_seconds: 1
|
|
||||||
url: 'https://path/to/image.png'
|
|
||||||
entity: image.office_person
|
|
||||||
entity_parameters: 'width=400&height=200'
|
|
||||||
- camera_entity: camera.office_motioneye
|
|
||||||
motioneye:
|
|
||||||
images:
|
|
||||||
directory_pattern: '%Y-%m-%d'
|
|
||||||
file_pattern: '%H-%M-%S'
|
|
||||||
movies:
|
|
||||||
directory_pattern: '%Y-%m-%d'
|
|
||||||
file_pattern: '%H-%M-%S'
|
|
||||||
- camera_entity: camera.zoomed
|
- camera_entity: camera.zoomed
|
||||||
dimensions:
|
dimensions:
|
||||||
layout:
|
layout:
|
||||||
@@ -436,6 +407,13 @@ cameras:
|
|||||||
device: '048123'
|
device: '048123'
|
||||||
cmd: preset
|
cmd: preset
|
||||||
preset: window
|
preset: window
|
||||||
|
- camera_entity: camera.needs_proxy
|
||||||
|
proxy:
|
||||||
|
media: auto
|
||||||
|
dynamic: true
|
||||||
|
ssl_verification: auto
|
||||||
|
ssl_ciphers: auto
|
||||||
cameras_global:
|
cameras_global:
|
||||||
live_provider: ha
|
triggers:
|
||||||
|
motion: false
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -4,19 +4,21 @@
|
|||||||
|
|
||||||
A "Camera Engine" defines what "type" of camera is being configured (e.g. `frigate`), each engine offers different capabilities:
|
A "Camera Engine" defines what "type" of camera is being configured (e.g. `frigate`), each engine offers different 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 | Detect new events | Detect new snapshots | Detect new clips |
|
| 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 | May requiring [proxying](./README.md?id=proxy) |
|
||||||
| ----------- | ------------------ | ------------------------ | ------------------------ | ------------------------ | ------------------------ | --------------------------- | -------------------------------- | ------------------------ | ------------------------ | ------------------------ | ------------------------ | ------------------------ |
|
| ----------- | ------------------ | ------------------------ | ------------------------ | ------------------------ | ------------------------ | --------------------------- | -------------------------------- | ------------------------ | ------------------------ | ------------------------ | ------------------------ | ------------------------ | ---------------------------------------------- |
|
||||||
| `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: |
|
| `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: | :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: | :heavy_multiplication_x: | :heavy_multiplication_x: | :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: | :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: |
|
| `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: | :white_check_mark: |
|
||||||
|
| `reolink` | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :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: | :white_check_mark: |
|
||||||
|
|
||||||
#### Live providers supported per Engine
|
### Live providers supported per Engine
|
||||||
|
|
||||||
| Engine / Live Provider | `ha` | `image` | `jsmpeg` | `go2rtc` | `webrtc-card` |
|
| Engine / Live Provider | `ha` | `image` | `jsmpeg` | `go2rtc` | `webrtc-card` |
|
||||||
| ---------------------- | ------------------ | ------------------ | ------------------------ | ------------------ | ------------------------ |
|
| ---------------------- | ------------------ | ------------------ | ------------------------ | ------------------ | ------------------------ |
|
||||||
| `frigate` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
| `frigate` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||||
| `generic` | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :white_check_mark: |
|
| `generic` | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :white_check_mark: |
|
||||||
| `motioneye` | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: |
|
| `motioneye` | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: |
|
||||||
|
| `reolink` | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: |
|
||||||
|
|
||||||
See [Live Provider Configuration](live-provider.md) for more details on live providers.
|
See [Live Provider Configuration](live-provider.md) for more details on live providers.
|
||||||
|
|
||||||
@@ -80,3 +82,54 @@ cameras:
|
|||||||
| ------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `directory_pattern` | `%Y-%m-%d` | The directory that motionEye is configured to store media into. May contain multiple sub-directories separated by `/`. Path must encode the date of the media using MotionEye patterns such as `%Y`, `%m`, `%d`, `%H`, `%M`, `%S` (at least one pattern is required). Consult MotionEye help text for information on these substitutions. |
|
| `directory_pattern` | `%Y-%m-%d` | The directory that motionEye is configured to store media into. May contain multiple sub-directories separated by `/`. Path must encode the date of the media using MotionEye patterns such as `%Y`, `%m`, `%d`, `%H`, `%M`, `%S` (at least one pattern is required). Consult MotionEye help text for information on these substitutions. |
|
||||||
| `file_pattern` | `%H-%M-%S` | Within a directory (as matched by `directory_pattern`) the media items must exist and match this pattern. `file_pattern` must encode the time of the media using MotionEye patterns such as `%Y`, `%m`, `%d`, `%H`, `%M`, `%S` (at least one pattern is required). Consult MotionEye help text for information on these substitutions. |
|
| `file_pattern` | `%H-%M-%S` | Within a directory (as matched by `directory_pattern`) the media items must exist and match this pattern. `file_pattern` must encode the time of the media using MotionEye patterns such as `%Y`, `%m`, `%d`, `%H`, `%M`, `%S` (at least one pattern is required). Consult MotionEye help text for information on these substitutions. |
|
||||||
|
|
||||||
|
## `reolink`
|
||||||
|
|
||||||
|
[](../common/experimental-warning.md ':include')
|
||||||
|
|
||||||
|
The `reolink` block configures options for a Reolink camera.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cameras:
|
||||||
|
- camera_entity: camera.office
|
||||||
|
reolink:
|
||||||
|
# [...]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
| ------------------ | ------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `media_resolution` | `low` | Whether to retrieve `high` or `low` resolution media items. |
|
||||||
|
| `url` | | The URL of the Reolink camera/NVR UI. If set, this value will be (exclusively) used for a `Camera UI` menu button. |
|
||||||
|
|
||||||
|
## Fully expanded reference
|
||||||
|
|
||||||
|
[](../common/expanded-warning.md ':include')
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cameras:
|
||||||
|
- camera_entity: camera.office_auto
|
||||||
|
engine: auto
|
||||||
|
- camera_entity: camera.office_frigate
|
||||||
|
frigate:
|
||||||
|
url: http://my.frigate.local
|
||||||
|
client_id: frigate
|
||||||
|
camera_name: front_door
|
||||||
|
labels:
|
||||||
|
- person
|
||||||
|
zones:
|
||||||
|
- steps
|
||||||
|
- camera_entity: camera.office_motioneye
|
||||||
|
motioneye:
|
||||||
|
images:
|
||||||
|
directory_pattern: '%Y-%m-%d'
|
||||||
|
file_pattern: '%H-%M-%S'
|
||||||
|
movies:
|
||||||
|
directory_pattern: '%Y-%m-%d'
|
||||||
|
file_pattern: '%H-%M-%S'
|
||||||
|
- camera_entity: camera.office_generic
|
||||||
|
engine: generic
|
||||||
|
- camera_entity: camera.office_reolink
|
||||||
|
reolink:
|
||||||
|
url: http://my.reolink.local
|
||||||
|
media_resolution: low
|
||||||
|
```
|
||||||
|
|||||||
@@ -89,3 +89,56 @@ cameras:
|
|||||||
| `entity` | | The RTSP camera entity to pass to the WebRTC Card for this camera. |
|
| `entity` | | The RTSP camera entity to pass to the WebRTC Card for this camera. |
|
||||||
| `url` | Depends on the camera engine (e.g. Frigate cameras will automatically use the camera name since this is the [recommended setup](https://deploy-preview-4055--frigate-docs.netlify.app/guides/configuring_go2rtc/)). | The RTSP url to pass to the WebRTC Card, e.g. `rtsp://USERNAME:PASSWORD@CAMERA:554/RTSP_PATH` |
|
| `url` | Depends on the camera engine (e.g. Frigate cameras will automatically use the camera name since this is the [recommended setup](https://deploy-preview-4055--frigate-docs.netlify.app/guides/configuring_go2rtc/)). | The RTSP url to pass to the WebRTC Card, e.g. `rtsp://USERNAME:PASSWORD@CAMERA:554/RTSP_PATH` |
|
||||||
| `*` | | Any options specified in the `webrtc_card:` YAML dictionary are silently passed through to the AlexxIT's WebRTC Card. See [WebRTC Configuration](https://github.com/AlexxIT/WebRTC#configuration) for full details this external card provides, e.g. `ui: true` will enable the WebRTC Card UI. |
|
| `*` | | Any options specified in the `webrtc_card:` YAML dictionary are silently passed through to the AlexxIT's WebRTC Card. See [WebRTC Configuration](https://github.com/AlexxIT/WebRTC#configuration) for full details this external card provides, e.g. `ui: true` will enable the WebRTC Card UI. |
|
||||||
|
|
||||||
|
## Fully expanded reference
|
||||||
|
|
||||||
|
[](../common/expanded-warning.md ':include')
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cameras:
|
||||||
|
- camera_entity: camera.office_ha
|
||||||
|
live_provider: ha
|
||||||
|
- camera_entity: camera.office_webrtc_card
|
||||||
|
live_provider: webrtc-card
|
||||||
|
webrtc_card:
|
||||||
|
entity: camera.office_rtsp
|
||||||
|
url: 'rtsp://username:password@camera:554/av_stream/ch0'
|
||||||
|
# Arbitrary WebRTC Card options, see https://github.com/AlexxIT/WebRTC#configuration .
|
||||||
|
ui: true
|
||||||
|
- camera_entity: camera.office_go2rtc
|
||||||
|
live_provider: go2rtc
|
||||||
|
go2rtc:
|
||||||
|
modes:
|
||||||
|
- webrtc
|
||||||
|
- mse
|
||||||
|
- mp4
|
||||||
|
- mjpeg
|
||||||
|
stream: sitting_room
|
||||||
|
url: 'https://my.custom.go2rtc.backend'
|
||||||
|
- camera_entity: camera.office_jsmpeg
|
||||||
|
live_provider: jsmpeg
|
||||||
|
jsmpeg:
|
||||||
|
options:
|
||||||
|
audio: false
|
||||||
|
video: true
|
||||||
|
pauseWhenHidden: false
|
||||||
|
disableGl: false
|
||||||
|
disableWebAssembly: false
|
||||||
|
preserveDrawingBuffer: false
|
||||||
|
progressive: true
|
||||||
|
throttled: true
|
||||||
|
chunkSize: 1048576
|
||||||
|
maxAudioLag: 10
|
||||||
|
videoBufferSize: 524288
|
||||||
|
audioBufferSize: 131072
|
||||||
|
- camera_entity: camera.office_image
|
||||||
|
live_provider: image
|
||||||
|
image:
|
||||||
|
mode: auto
|
||||||
|
refresh_seconds: 1
|
||||||
|
url: 'https://path/to/image.png'
|
||||||
|
entity: image.office_person
|
||||||
|
entity_parameters: 'width=400&height=200'
|
||||||
|
cameras_global:
|
||||||
|
live_provider: go2rtc
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
!> This functionality is experimental. It may be broken, slow or change without warning.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
version="1.1"
|
||||||
|
id="svg62"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
sodipodi:docname="reolink.svg"
|
||||||
|
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs66" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview64"
|
||||||
|
pagecolor="#505050"
|
||||||
|
bordercolor="#eeeeee"
|
||||||
|
borderopacity="1"
|
||||||
|
inkscape:showpageshadow="0"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#505050"
|
||||||
|
showgrid="true"
|
||||||
|
inkscape:zoom="36.417984"
|
||||||
|
inkscape:cx="18.191562"
|
||||||
|
inkscape:cy="8.6633022"
|
||||||
|
inkscape:window-width="3840"
|
||||||
|
inkscape:window-height="1527"
|
||||||
|
inkscape:window-x="1080"
|
||||||
|
inkscape:window-y="227"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="g68">
|
||||||
|
<inkscape:grid
|
||||||
|
type="xygrid"
|
||||||
|
id="grid1175"
|
||||||
|
dotted="false"
|
||||||
|
snapvisiblegridlinesonly="true" />
|
||||||
|
</sodipodi:namedview>
|
||||||
|
<g
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
inkscape:label="Reolink Logo"
|
||||||
|
id="g68">
|
||||||
|
<path
|
||||||
|
style="fill:#ffffff;stroke-width:0.0393282;fill-opacity:1"
|
||||||
|
d="M 17.037254,23.964909 C 16.764634,23.917573 16.561708,23.83617 16.325735,23.67954 16.196719,23.593913 14.543544,21.952905 12.217956,19.602014 10.077426,17.438191 8.2198993,15.582673 8.0901166,15.478642 7.6038998,15.088895 7.0877741,14.869424 6.4986827,14.801908 6.3140805,14.780765 5.079061,14.763305 3.7541964,14.763126 l -2.408845,-4.78e-4 0.00713,-7.3937713 v -7.3935985 l 6.2040046,3.536e-5 c 3.9799342,1.969e-5 6.3590882,0.01465833 6.6366112,0.0408227 2.331195,0.21982024 4.364771,1.49885914 5.578726,3.50085534 0.686043,1.1313823 1.020901,2.2783695 1.067186,3.6554196 0.06335,1.8844953 -0.57426,3.6597378 -1.831469,5.0992438 -0.756457,0.866146 -1.861127,1.62991 -2.928729,2.024919 l -0.319718,0.118287 -1.951287,-1.979284 -1.951286,-1.979278 1.046741,-0.02346 c 0.575706,-0.01291 1.099833,-0.03689 1.164724,-0.0533 0.596068,-0.150649 0.949703,-0.313968 1.322206,-0.6106282 0.643573,-0.5125401 1.046169,-1.2439257 1.137985,-2.0673522 0.167852,-1.505317 -0.771478,-2.8704888 -2.263547,-3.2897068 L 13.950001,4.3234481 9.7981831,4.3106846 5.6392389,4.29801 v 3.0692702 3.0692648 l 1.4185165,7.9e-4 c 1.6275801,8.87e-4 1.7789152,0.01912 2.3105245,0.27984 0.3095679,0.15183 0.389559,0.219499 1.0817441,0.915083 3.027399,3.042274 12.19404,12.30837 12.204589,12.337002 0.0161,0.04373 -5.364393,0.03953 -5.617359,-0.0045 z"
|
||||||
|
id="path1173"
|
||||||
|
inkscape:label="R" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
@@ -9,3 +9,4 @@ builtin custom items not found in the traditional [Material Design Icons
|
|||||||
| ----------- | --------------------------------------------------------------------------- |
|
| ----------- | --------------------------------------------------------------------------- |
|
||||||
| `frigate` |  |
|
| `frigate` |  |
|
||||||
| `motioneye` |  |
|
| `motioneye` |  |
|
||||||
|
| `reolink` |  |
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
FRIGATE="./src/camera-manager/frigate/assets/frigate.svg"
|
FRIGATE="./src/camera-manager/frigate/assets/frigate.svg"
|
||||||
MOTIONEYE="./src/camera-manager/motioneye/assets/motioneye.svg"
|
MOTIONEYE="./src/camera-manager/motioneye/assets/motioneye.svg"
|
||||||
|
REOLINK="./src/camera-manager/reolink/assets/reolink.svg"
|
||||||
DIR_ICONS="./docs/images/icons"
|
DIR_ICONS="./docs/images/icons"
|
||||||
|
|
||||||
copy() {
|
copy() {
|
||||||
@@ -14,3 +15,4 @@ copy() {
|
|||||||
|
|
||||||
copy "$FRIGATE"
|
copy "$FRIGATE"
|
||||||
copy "$MOTIONEYE"
|
copy "$MOTIONEYE"
|
||||||
|
copy "$REOLINK"
|
||||||
|
|||||||
@@ -9,18 +9,23 @@ URL_THINNER_THAN_WIDTH="https://docs.google.com/drawings/d/e/2PACX-1vRkMd89N0tkZ
|
|||||||
URL_SHORTER_THAN_HEIGHT="https://docs.google.com/drawings/d/e/2PACX-1vTKVsXEWIbj9lYKrCeugdLKcK_rOwAZZDK8IzhPdHH4wMwV2v7kEI0nsn2Qgugb00qDVHsE7kE8CBIC/pub?w=$WIDTH"
|
URL_SHORTER_THAN_HEIGHT="https://docs.google.com/drawings/d/e/2PACX-1vTKVsXEWIbj9lYKrCeugdLKcK_rOwAZZDK8IzhPdHH4wMwV2v7kEI0nsn2Qgugb00qDVHsE7kE8CBIC/pub?w=$WIDTH"
|
||||||
URL_VIEW_BOX="https://docs.google.com/drawings/d/e/2PACX-1vRpPNsaStxW2ENmv1kfUQg41cua9XQ2sQziq2PC8LdRCtkvjHSKYH3CyPO1Pz7kOdiQ2yQKrBX88-TF/pub?w=$WIDTH"
|
URL_VIEW_BOX="https://docs.google.com/drawings/d/e/2PACX-1vRpPNsaStxW2ENmv1kfUQg41cua9XQ2sQziq2PC8LdRCtkvjHSKYH3CyPO1Pz7kOdiQ2yQKrBX88-TF/pub?w=$WIDTH"
|
||||||
URL_PAN_ZOOM="https://docs.google.com/drawings/d/e/2PACX-1vTcbuMiqKo7-w0my3jaht1xdEFUhLSur1nSxhkxbuX0eagwuisaNCBfKvDjFY4hzNVGRZoBy7YehaMn/pub?w=$WIDTH"
|
URL_PAN_ZOOM="https://docs.google.com/drawings/d/e/2PACX-1vTcbuMiqKo7-w0my3jaht1xdEFUhLSur1nSxhkxbuX0eagwuisaNCBfKvDjFY4hzNVGRZoBy7YehaMn/pub?w=$WIDTH"
|
||||||
|
URL_PROXY="https://docs.google.com/drawings/d/e/2PACX-1vRrRftUrBnlBN1KVBJjovLPzu966uBKoj9GWz_vaKVHPyayKSl2lioQTWkM8Gqik2zsImiYdCy23Z_O/pub?w=$WIDTH"
|
||||||
|
|
||||||
DIR_MEDIA_LAYOUT_IMAGES="./docs/images/media_layout"
|
DIR_IMAGES="./docs/images"
|
||||||
|
DIR_MEDIA_LAYOUT_IMAGES="$DIR_IMAGES/media_layout"
|
||||||
|
|
||||||
fetch() {
|
fetch() {
|
||||||
URL="$1"
|
URL="$1"
|
||||||
BASENAME="$2"
|
BASENAME="$2"
|
||||||
echo "Fetching image $BASENAME.png..."
|
TARGET_DIR="$3"
|
||||||
wget -q -O "$DIR_MEDIA_LAYOUT_IMAGES/$BASENAME.png" "$URL"
|
|
||||||
|
echo "Fetching image $BASENAME.png to $TARGET_DIR..."
|
||||||
|
wget -q -O "$TARGET_DIR/$BASENAME.png" "$URL"
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch "$URL_FIT" "fit"
|
fetch "$URL_FIT" "fit" "$DIR_MEDIA_LAYOUT_IMAGES"
|
||||||
fetch "$URL_THINNER_THAN_WIDTH" "position-thinner-than-width"
|
fetch "$URL_THINNER_THAN_WIDTH" "position-thinner-than-width" "$DIR_MEDIA_LAYOUT_IMAGES"
|
||||||
fetch "$URL_SHORTER_THAN_HEIGHT" "position-shorter-than-height"
|
fetch "$URL_SHORTER_THAN_HEIGHT" "position-shorter-than-height" "$DIR_MEDIA_LAYOUT_IMAGES"
|
||||||
fetch "$URL_VIEW_BOX" "view-box"
|
fetch "$URL_VIEW_BOX" "view-box" "$DIR_MEDIA_LAYOUT_IMAGES"
|
||||||
fetch "$URL_PAN_ZOOM" "pan-zoom"
|
fetch "$URL_PAN_ZOOM" "pan-zoom" "$DIR_MEDIA_LAYOUT_IMAGES"
|
||||||
|
fetch "$URL_PROXY" "proxy" "$DIR_IMAGES"
|
||||||
|
|||||||
@@ -1,24 +1,15 @@
|
|||||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
|
||||||
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
|
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
|
||||||
import { CameraConfig } from '../../config/types';
|
import { CameraConfig } from '../../config/types';
|
||||||
import { ExtendedHomeAssistant } from '../../types';
|
import { ExtendedHomeAssistant } from '../../types';
|
||||||
import { canonicalizeHAURL } from '../../utils/ha';
|
import { canonicalizeHAURL } from '../../utils/ha';
|
||||||
import { BrowseMediaManager } from '../../utils/ha/browse-media/browse-media-manager';
|
import { BrowseMediaManager } from '../../utils/ha/browse-media/browse-media-manager';
|
||||||
import {
|
import { BROWSE_MEDIA_CACHE_SECONDS } from '../../utils/ha/browse-media/types';
|
||||||
BROWSE_MEDIA_CACHE_SECONDS,
|
|
||||||
MEDIA_CLASS_IMAGE,
|
|
||||||
MEDIA_CLASS_VIDEO,
|
|
||||||
RichBrowseMedia,
|
|
||||||
} from '../../utils/ha/browse-media/types';
|
|
||||||
import { EntityRegistryManager } from '../../utils/ha/registry/entity';
|
import { EntityRegistryManager } from '../../utils/ha/registry/entity';
|
||||||
import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media';
|
import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media';
|
||||||
import { ViewMedia } from '../../view/media';
|
import { ViewMedia } from '../../view/media';
|
||||||
import { RequestCache } from '../cache';
|
import { RequestCache } from '../cache';
|
||||||
import { Camera } from '../camera';
|
|
||||||
import { Capabilities } from '../capabilities';
|
|
||||||
import { CameraManagerEngine } from '../engine';
|
import { CameraManagerEngine } from '../engine';
|
||||||
import { GenericCameraManagerEngine } from '../generic/engine-generic';
|
import { GenericCameraManagerEngine } from '../generic/engine-generic';
|
||||||
import { rangesOverlap } from '../range';
|
|
||||||
import { CameraManagerReadOnlyConfigStore } from '../store';
|
import { CameraManagerReadOnlyConfigStore } from '../store';
|
||||||
import {
|
import {
|
||||||
CameraEndpoint,
|
CameraEndpoint,
|
||||||
@@ -29,93 +20,8 @@ import {
|
|||||||
PartialEventQuery,
|
PartialEventQuery,
|
||||||
QueryType,
|
QueryType,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
|
||||||
import { BrowseMediaCamera } from './camera';
|
|
||||||
import { BrowseMediaViewMediaFactory } from './media';
|
|
||||||
import { BrowseMediaMetadata } from './types';
|
import { BrowseMediaMetadata } from './types';
|
||||||
|
|
||||||
/**
|
|
||||||
* A utility method to determine if a browse media object matches against a
|
|
||||||
* start and end date.
|
|
||||||
* @param media The browse media object (with rich metadata).
|
|
||||||
* @param start The optional start date.
|
|
||||||
* @param end The optional end date.
|
|
||||||
* @returns `true` if the media falls within the provided dates.
|
|
||||||
*/
|
|
||||||
export const isMediaWithinDates = (
|
|
||||||
media: RichBrowseMedia<BrowseMediaMetadata>,
|
|
||||||
start?: Date,
|
|
||||||
end?: Date,
|
|
||||||
): boolean => {
|
|
||||||
// If no date is specified at all, everything matches.
|
|
||||||
const dateReference = start ?? end;
|
|
||||||
if (!dateReference) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there's no metadata, nothing matches.
|
|
||||||
if (!media._metadata) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if:
|
|
||||||
// - The media starts within the query timeframe.
|
|
||||||
// - The media ends within the query timeframe.
|
|
||||||
// - The media entirely encompasses the query timeframe.
|
|
||||||
return rangesOverlap(
|
|
||||||
{
|
|
||||||
start: media._metadata.startDate,
|
|
||||||
end: media._metadata.endDate,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
start: start ?? dateReference,
|
|
||||||
end: end ?? dateReference,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getViewMediaFromBrowseMediaArray = (
|
|
||||||
browseMedia: RichBrowseMedia<BrowseMediaMetadata>[],
|
|
||||||
): ViewMedia[] | null => {
|
|
||||||
const lookup: Map<string, ViewMedia> = new Map();
|
|
||||||
for (const browseMediaItem of browseMedia) {
|
|
||||||
const cameraID = browseMediaItem._metadata?.cameraID;
|
|
||||||
if (!cameraID) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mediaType =
|
|
||||||
browseMediaItem.media_class === MEDIA_CLASS_VIDEO
|
|
||||||
? 'clip'
|
|
||||||
: browseMediaItem.media_class === MEDIA_CLASS_IMAGE
|
|
||||||
? 'snapshot'
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!mediaType) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const media = BrowseMediaViewMediaFactory.createEventViewMedia(
|
|
||||||
mediaType,
|
|
||||||
browseMediaItem,
|
|
||||||
cameraID,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (media) {
|
|
||||||
const id = media.getID();
|
|
||||||
const existing = lookup.get(id);
|
|
||||||
// De-duplicate events with precisely the same ID (same
|
|
||||||
// hour/minute/second) choosing clip > snapshot.
|
|
||||||
if (
|
|
||||||
!existing ||
|
|
||||||
(existing.getMediaType() === 'snapshot' && media.getMediaType() === 'clip')
|
|
||||||
) {
|
|
||||||
lookup.set(id, media);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...lookup.values()];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A base class for cameras that read events from HA BrowseMedia interface.
|
* A base class for cameras that read events from HA BrowseMedia interface.
|
||||||
*/
|
*/
|
||||||
@@ -143,38 +49,6 @@ export class BrowseMediaCameraManagerEngine
|
|||||||
this._requestCache = requestCache;
|
this._requestCache = requestCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createCamera(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
cameraConfig: CameraConfig,
|
|
||||||
): Promise<Camera> {
|
|
||||||
const camera = new BrowseMediaCamera(cameraConfig, this, {
|
|
||||||
capabilities: new Capabilities(
|
|
||||||
{
|
|
||||||
'favorite-events': false,
|
|
||||||
'favorite-recordings': false,
|
|
||||||
clips: true,
|
|
||||||
live: true,
|
|
||||||
menu: true,
|
|
||||||
recordings: false,
|
|
||||||
seek: false,
|
|
||||||
snapshots: true,
|
|
||||||
substream: true,
|
|
||||||
ptz: getPTZCapabilitiesFromCameraConfig(cameraConfig) ?? undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
disable: cameraConfig.capabilities?.disable,
|
|
||||||
disableExcept: cameraConfig.capabilities?.disable_except,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
eventCallback: this._eventCallback,
|
|
||||||
});
|
|
||||||
return await camera.initialize({
|
|
||||||
entityRegistryManager: this._entityRegistryManager,
|
|
||||||
hass,
|
|
||||||
stateWatcher: this._stateWatcher,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public generateDefaultEventQuery(
|
public generateDefaultEventQuery(
|
||||||
_store: CameraManagerReadOnlyConfigStore,
|
_store: CameraManagerReadOnlyConfigStore,
|
||||||
cameraIDs: Set<string>,
|
cameraIDs: Set<string>,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class BrowseMediaEventViewMedia extends ViewMedia implements EventViewMedia {
|
|||||||
return this._browseMedia._metadata?.startDate ?? null;
|
return this._browseMedia._metadata?.startDate ?? null;
|
||||||
}
|
}
|
||||||
public getEndTime(): Date | null {
|
public getEndTime(): Date | null {
|
||||||
return null;
|
return this._browseMedia._metadata?.endDate ?? null;
|
||||||
}
|
}
|
||||||
public getVideoContentType(): VideoContentType | null {
|
public getVideoContentType(): VideoContentType | null {
|
||||||
return VideoContentType.MP4;
|
return VideoContentType.MP4;
|
||||||
@@ -79,7 +79,7 @@ export class BrowseMediaViewMediaFactory {
|
|||||||
mediaType: 'clip' | 'snapshot',
|
mediaType: 'clip' | 'snapshot',
|
||||||
browseMedia: RichBrowseMedia<BrowseMediaMetadata>,
|
browseMedia: RichBrowseMedia<BrowseMediaMetadata>,
|
||||||
cameraID: string,
|
cameraID: string,
|
||||||
): BrowseMediaEventViewMedia | null {
|
): BrowseMediaEventViewMedia {
|
||||||
return new BrowseMediaEventViewMedia(mediaType, cameraID, browseMedia);
|
return new BrowseMediaEventViewMedia(mediaType, cameraID, browseMedia);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import {
|
||||||
|
RichBrowseMedia,
|
||||||
|
MEDIA_CLASS_VIDEO,
|
||||||
|
MEDIA_CLASS_IMAGE,
|
||||||
|
} from '../../../utils/ha/browse-media/types';
|
||||||
|
import { ViewMedia } from '../../../view/media';
|
||||||
|
import { BrowseMediaViewMediaFactory } from '../media';
|
||||||
|
import { BrowseMediaMetadata } from '../types';
|
||||||
|
|
||||||
|
export const getViewMediaFromBrowseMediaArray = (
|
||||||
|
browseMedia: RichBrowseMedia<BrowseMediaMetadata>[],
|
||||||
|
): ViewMedia[] | null => {
|
||||||
|
const lookup: Map<string, ViewMedia> = new Map();
|
||||||
|
for (const browseMediaItem of browseMedia) {
|
||||||
|
const cameraID = browseMediaItem._metadata?.cameraID;
|
||||||
|
if (!cameraID) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaType =
|
||||||
|
browseMediaItem.media_class === MEDIA_CLASS_VIDEO
|
||||||
|
? 'clip'
|
||||||
|
: browseMediaItem.media_class === MEDIA_CLASS_IMAGE
|
||||||
|
? 'snapshot'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!mediaType) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const media = BrowseMediaViewMediaFactory.createEventViewMedia(
|
||||||
|
mediaType,
|
||||||
|
browseMediaItem,
|
||||||
|
cameraID,
|
||||||
|
);
|
||||||
|
|
||||||
|
const id = media.getID();
|
||||||
|
const existing = lookup.get(id);
|
||||||
|
|
||||||
|
// De-duplicate events with precisely the same ID (same
|
||||||
|
// hour/minute/second) choosing clip > snapshot.
|
||||||
|
if (
|
||||||
|
!existing ||
|
||||||
|
(existing.getMediaType() === 'snapshot' && media.getMediaType() === 'clip')
|
||||||
|
) {
|
||||||
|
lookup.set(id, media);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...lookup.values()];
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { RichBrowseMedia } from '../../../utils/ha/browse-media/types';
|
||||||
|
import { rangesOverlap } from '../../range';
|
||||||
|
import { BrowseMediaMetadata } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A utility method to determine if a browse media object matches against a
|
||||||
|
* start and end date.
|
||||||
|
* @param media The browse media object (with rich metadata).
|
||||||
|
* @param start The optional start date.
|
||||||
|
* @param end The optional end date.
|
||||||
|
* @returns `true` if the media falls within the provided dates.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const isMediaWithinDates = (
|
||||||
|
media: RichBrowseMedia<BrowseMediaMetadata>,
|
||||||
|
start?: Date,
|
||||||
|
end?: Date,
|
||||||
|
): boolean => {
|
||||||
|
// If there's no metadata, nothing matches.
|
||||||
|
if (!media._metadata) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start && end) {
|
||||||
|
// Determine if:
|
||||||
|
// - The media starts within the query timeframe.
|
||||||
|
// - The media ends within the query timeframe.
|
||||||
|
// - The media entirely encompasses the query timeframe.
|
||||||
|
return rangesOverlap(
|
||||||
|
{
|
||||||
|
start: media._metadata.startDate,
|
||||||
|
end: media._metadata.endDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!start && end) {
|
||||||
|
return media._metadata.startDate <= end;
|
||||||
|
}
|
||||||
|
if (start && !end) {
|
||||||
|
return media._metadata.startDate >= start;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no date is specified at all, everything matches.
|
||||||
|
return true;
|
||||||
|
};
|
||||||
@@ -5,7 +5,7 @@ import { HassStateDifference, isTriggeredState } from '../utils/ha';
|
|||||||
import { Capabilities } from './capabilities';
|
import { Capabilities } from './capabilities';
|
||||||
import { CameraManagerEngine } from './engine';
|
import { CameraManagerEngine } from './engine';
|
||||||
import { CameraNoIDError } from './error';
|
import { CameraNoIDError } from './error';
|
||||||
import { CameraEventCallback } from './types';
|
import { CameraEventCallback, CameraProxyConfig } from './types';
|
||||||
|
|
||||||
export interface CameraInitializationOptions {
|
export interface CameraInitializationOptions {
|
||||||
stateWatcher: StateWatcherSubscriptionInterface;
|
stateWatcher: StateWatcherSubscriptionInterface;
|
||||||
@@ -69,6 +69,18 @@ export class Camera {
|
|||||||
return this._capabilities ?? null;
|
return this._capabilities ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getProxyConfig(): CameraProxyConfig {
|
||||||
|
return {
|
||||||
|
dynamic: this._config.proxy.dynamic,
|
||||||
|
media: this._config.proxy.media === 'auto' ? false : this._config.proxy.media,
|
||||||
|
ssl_verification: this._config.proxy.ssl_verification !== false,
|
||||||
|
ssl_ciphers:
|
||||||
|
this._config.proxy.ssl_ciphers === 'auto'
|
||||||
|
? 'default'
|
||||||
|
: this._config.proxy.ssl_ciphers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
protected _stateChangeHandler = (difference: HassStateDifference): void => {
|
protected _stateChangeHandler = (difference: HassStateDifference): void => {
|
||||||
this._eventCallback?.({
|
this._eventCallback?.({
|
||||||
cameraID: this.getID(),
|
cameraID: this.getID(),
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state
|
|||||||
import { CameraConfig } from '../config/types';
|
import { CameraConfig } from '../config/types';
|
||||||
import { localize } from '../localize/localize';
|
import { localize } from '../localize/localize';
|
||||||
import { BrowseMediaManager } from '../utils/ha/browse-media/browse-media-manager';
|
import { BrowseMediaManager } from '../utils/ha/browse-media/browse-media-manager';
|
||||||
import { BrowseMedia } from '../utils/ha/browse-media/types';
|
|
||||||
import { EntityRegistryManager } from '../utils/ha/registry/entity';
|
import { EntityRegistryManager } from '../utils/ha/registry/entity';
|
||||||
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
|
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
|
||||||
import { MemoryRequestCache, RecordingSegmentsCache, RequestCache } from './cache';
|
import { RecordingSegmentsCache, RequestCache } from './cache';
|
||||||
import { CameraManagerEngine } from './engine';
|
import { CameraManagerEngine } from './engine';
|
||||||
import { CameraInitializationError } from './error';
|
import { CameraInitializationError } from './error';
|
||||||
import { CameraEventCallback, Engine } from './types';
|
import { CameraEventCallback, Engine } from './types';
|
||||||
@@ -56,7 +55,18 @@ export class CameraManagerEngineFactory {
|
|||||||
cameraManagerEngine = new MotionEyeCameraManagerEngine(
|
cameraManagerEngine = new MotionEyeCameraManagerEngine(
|
||||||
this._entityRegistryManager,
|
this._entityRegistryManager,
|
||||||
options.stateWatcher,
|
options.stateWatcher,
|
||||||
new BrowseMediaManager(new MemoryRequestCache<string, BrowseMedia>()),
|
new BrowseMediaManager(),
|
||||||
|
options.resolvedMediaCache,
|
||||||
|
new RequestCache(),
|
||||||
|
options.eventCallback,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case Engine.Reolink:
|
||||||
|
const { ReolinkCameraManagerEngine } = await import('./reolink/engine-reolink');
|
||||||
|
cameraManagerEngine = new ReolinkCameraManagerEngine(
|
||||||
|
this._entityRegistryManager,
|
||||||
|
options.stateWatcher,
|
||||||
|
new BrowseMediaManager(),
|
||||||
options.resolvedMediaCache,
|
options.resolvedMediaCache,
|
||||||
new RequestCache(),
|
new RequestCache(),
|
||||||
options.eventCallback,
|
options.eventCallback,
|
||||||
@@ -76,6 +86,8 @@ export class CameraManagerEngineFactory {
|
|||||||
engine = Engine.MotionEye;
|
engine = Engine.MotionEye;
|
||||||
} else if (cameraConfig.engine === 'generic') {
|
} else if (cameraConfig.engine === 'generic') {
|
||||||
engine = Engine.Generic;
|
engine = Engine.Generic;
|
||||||
|
} else if (cameraConfig.engine === 'reolink') {
|
||||||
|
engine = Engine.Reolink;
|
||||||
} else {
|
} else {
|
||||||
const cameraEntity = getCameraEntityFromConfig(cameraConfig);
|
const cameraEntity = getCameraEntityFromConfig(cameraConfig);
|
||||||
|
|
||||||
@@ -101,6 +113,9 @@ export class CameraManagerEngineFactory {
|
|||||||
case 'motioneye':
|
case 'motioneye':
|
||||||
engine = Engine.MotionEye;
|
engine = Engine.MotionEye;
|
||||||
break;
|
break;
|
||||||
|
case 'reolink':
|
||||||
|
engine = Engine.Reolink;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
engine = Engine.Generic;
|
engine = Engine.Generic;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||||
import { localize } from '../../localize/localize';
|
import { localize } from '../../localize/localize';
|
||||||
import { FrigateCardError } from '../../types';
|
import { FrigateCardError } from '../../types';
|
||||||
import { homeAssistantWSRequest } from '../../utils/ha';
|
import { homeAssistantWSRequest } from '../../utils/ha/ws-request';
|
||||||
import { RecordingSegment } from '../types';
|
import { RecordingSegment } from '../types';
|
||||||
import {
|
import {
|
||||||
EventSummary,
|
EventSummary,
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { BrowseMediaCamera } from '../browse-media/camera';
|
||||||
|
import { CameraProxyConfig } from '../types';
|
||||||
|
|
||||||
|
export class MotionEyeCamera extends BrowseMediaCamera {
|
||||||
|
public getProxyConfig(): CameraProxyConfig {
|
||||||
|
return {
|
||||||
|
...super.getProxyConfig(),
|
||||||
|
|
||||||
|
// For motionEye, media is always proxied unless explicitly turned off.
|
||||||
|
media: this._config.proxy.media === 'auto' ? true : this._config.proxy.media,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,20 +8,21 @@ import {
|
|||||||
BrowseMediaTarget,
|
BrowseMediaTarget,
|
||||||
} from '../../utils/ha/browse-media/browse-media-manager';
|
} from '../../utils/ha/browse-media/browse-media-manager';
|
||||||
import {
|
import {
|
||||||
BrowseMedia,
|
|
||||||
BROWSE_MEDIA_CACHE_SECONDS,
|
BROWSE_MEDIA_CACHE_SECONDS,
|
||||||
|
BrowseMedia,
|
||||||
MEDIA_CLASS_IMAGE,
|
MEDIA_CLASS_IMAGE,
|
||||||
MEDIA_CLASS_VIDEO,
|
MEDIA_CLASS_VIDEO,
|
||||||
RichBrowseMedia,
|
RichBrowseMedia,
|
||||||
} from '../../utils/ha/browse-media/types';
|
} from '../../utils/ha/browse-media/types';
|
||||||
import { ViewMedia } from '../../view/media';
|
import { ViewMedia } from '../../view/media';
|
||||||
import { BrowseMediaCamera } from '../browse-media/camera';
|
import { BrowseMediaCamera } from '../browse-media/camera';
|
||||||
import {
|
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
|
||||||
BrowseMediaCameraManagerEngine,
|
|
||||||
getViewMediaFromBrowseMediaArray,
|
|
||||||
isMediaWithinDates,
|
|
||||||
} from '../browse-media/engine-browse-media';
|
|
||||||
import { BrowseMediaMetadata } from '../browse-media/types';
|
import { BrowseMediaMetadata } from '../browse-media/types';
|
||||||
|
import { getViewMediaFromBrowseMediaArray } from '../browse-media/utils/browse-media-to-view-media';
|
||||||
|
import { isMediaWithinDates } from '../browse-media/utils/within-dates';
|
||||||
|
import { MemoryRequestCache } from '../cache';
|
||||||
|
import { Camera } from '../camera';
|
||||||
|
import { Capabilities } from '../capabilities';
|
||||||
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
|
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
|
||||||
import { CameraManagerReadOnlyConfigStore } from '../store';
|
import { CameraManagerReadOnlyConfigStore } from '../store';
|
||||||
import {
|
import {
|
||||||
@@ -41,7 +42,9 @@ import {
|
|||||||
QueryResultsType,
|
QueryResultsType,
|
||||||
QueryReturnType,
|
QueryReturnType,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
||||||
import motioneyeLogo from './assets/motioneye.svg';
|
import motioneyeLogo from './assets/motioneye.svg';
|
||||||
|
import { MotionEyeCamera } from './camera';
|
||||||
import { MotionEyeEventQueryResults } from './types';
|
import { MotionEyeEventQueryResults } from './types';
|
||||||
|
|
||||||
class MotionEyeQueryResultsClassifier {
|
class MotionEyeQueryResultsClassifier {
|
||||||
@@ -65,10 +68,45 @@ const MOTIONEYE_REPL_SUBSTITUTIONS: Record<string, string> = {
|
|||||||
const MOTIONEYE_REPL_REGEXP = new RegExp(/(%Y|%m|%d|%H|%M|%S)/g);
|
const MOTIONEYE_REPL_REGEXP = new RegExp(/(%Y|%m|%d|%H|%M|%S)/g);
|
||||||
|
|
||||||
export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
||||||
|
protected _directoryCache = new MemoryRequestCache<string, BrowseMedia>();
|
||||||
|
protected _fileCache = new MemoryRequestCache<string, BrowseMedia>();
|
||||||
|
|
||||||
public getEngineType(): Engine {
|
public getEngineType(): Engine {
|
||||||
return Engine.MotionEye;
|
return Engine.MotionEye;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async createCamera(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameraConfig: CameraConfig,
|
||||||
|
): Promise<Camera> {
|
||||||
|
const camera = new MotionEyeCamera(cameraConfig, this, {
|
||||||
|
capabilities: new Capabilities(
|
||||||
|
{
|
||||||
|
'favorite-events': false,
|
||||||
|
'favorite-recordings': false,
|
||||||
|
clips: true,
|
||||||
|
live: true,
|
||||||
|
menu: true,
|
||||||
|
recordings: false,
|
||||||
|
seek: false,
|
||||||
|
snapshots: true,
|
||||||
|
substream: true,
|
||||||
|
ptz: getPTZCapabilitiesFromCameraConfig(cameraConfig) ?? undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
disable: cameraConfig.capabilities?.disable,
|
||||||
|
disableExcept: cameraConfig.capabilities?.disable_except,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
eventCallback: this._eventCallback,
|
||||||
|
});
|
||||||
|
return await camera.initialize({
|
||||||
|
entityRegistryManager: this._entityRegistryManager,
|
||||||
|
hass,
|
||||||
|
stateWatcher: this._stateWatcher,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected _convertMotionEyeTimeFormatToDateFNS(part: string): string {
|
protected _convertMotionEyeTimeFormatToDateFNS(part: string): string {
|
||||||
return part.replace(
|
return part.replace(
|
||||||
MOTIONEYE_REPL_REGEXP,
|
MOTIONEYE_REPL_REGEXP,
|
||||||
@@ -202,7 +240,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
|
|||||||
: []),
|
: []),
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
useCache: engineOptions?.useCache,
|
...(engineOptions?.useCache !== false && { cache: this._directoryCache }),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -251,6 +289,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
|
|||||||
cameraConfig.motioneye.images.file_pattern,
|
cameraConfig.motioneye.images.file_pattern,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const limit = perCameraQuery.limit ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT;
|
||||||
const media = await this._browseMediaManager.walkBrowseMedias(
|
const media = await this._browseMediaManager.walkBrowseMedias(
|
||||||
hass,
|
hass,
|
||||||
[
|
[
|
||||||
@@ -275,12 +314,15 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
earlyExit: (media) => media.length >= limit,
|
||||||
matcher: (media: RichBrowseMedia<BrowseMediaMetadata>) =>
|
matcher: (media: RichBrowseMedia<BrowseMediaMetadata>) =>
|
||||||
!media.can_expand &&
|
!media.can_expand &&
|
||||||
isMediaWithinDates(media, perCameraQuery.start, perCameraQuery.end),
|
isMediaWithinDates(media, perCameraQuery.start, perCameraQuery.end),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{ useCache: engineOptions?.useCache },
|
{
|
||||||
|
...(engineOptions?.useCache !== false && { cache: this._fileCache }),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sort by most recent then slice at the query limit.
|
// Sort by most recent then slice at the query limit.
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
version="1.1"
|
||||||
|
id="svg62"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
sodipodi:docname="reolink.svg"
|
||||||
|
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs66" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview64"
|
||||||
|
pagecolor="#505050"
|
||||||
|
bordercolor="#eeeeee"
|
||||||
|
borderopacity="1"
|
||||||
|
inkscape:showpageshadow="0"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#505050"
|
||||||
|
showgrid="true"
|
||||||
|
inkscape:zoom="36.417984"
|
||||||
|
inkscape:cx="18.191562"
|
||||||
|
inkscape:cy="8.6633022"
|
||||||
|
inkscape:window-width="3840"
|
||||||
|
inkscape:window-height="1527"
|
||||||
|
inkscape:window-x="1080"
|
||||||
|
inkscape:window-y="227"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="g68">
|
||||||
|
<inkscape:grid
|
||||||
|
type="xygrid"
|
||||||
|
id="grid1175"
|
||||||
|
dotted="false"
|
||||||
|
snapvisiblegridlinesonly="true" />
|
||||||
|
</sodipodi:namedview>
|
||||||
|
<g
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
inkscape:label="Reolink Logo"
|
||||||
|
id="g68">
|
||||||
|
<path
|
||||||
|
style="fill:#ffffff;stroke-width:0.0393282;fill-opacity:1"
|
||||||
|
d="M 17.037254,23.964909 C 16.764634,23.917573 16.561708,23.83617 16.325735,23.67954 16.196719,23.593913 14.543544,21.952905 12.217956,19.602014 10.077426,17.438191 8.2198993,15.582673 8.0901166,15.478642 7.6038998,15.088895 7.0877741,14.869424 6.4986827,14.801908 6.3140805,14.780765 5.079061,14.763305 3.7541964,14.763126 l -2.408845,-4.78e-4 0.00713,-7.3937713 v -7.3935985 l 6.2040046,3.536e-5 c 3.9799342,1.969e-5 6.3590882,0.01465833 6.6366112,0.0408227 2.331195,0.21982024 4.364771,1.49885914 5.578726,3.50085534 0.686043,1.1313823 1.020901,2.2783695 1.067186,3.6554196 0.06335,1.8844953 -0.57426,3.6597378 -1.831469,5.0992438 -0.756457,0.866146 -1.861127,1.62991 -2.928729,2.024919 l -0.319718,0.118287 -1.951287,-1.979284 -1.951286,-1.979278 1.046741,-0.02346 c 0.575706,-0.01291 1.099833,-0.03689 1.164724,-0.0533 0.596068,-0.150649 0.949703,-0.313968 1.322206,-0.6106282 0.643573,-0.5125401 1.046169,-1.2439257 1.137985,-2.0673522 0.167852,-1.505317 -0.771478,-2.8704888 -2.263547,-3.2897068 L 13.950001,4.3234481 9.7981831,4.3106846 5.6392389,4.29801 v 3.0692702 3.0692648 l 1.4185165,7.9e-4 c 1.6275801,8.87e-4 1.7789152,0.01912 2.3105245,0.27984 0.3095679,0.15183 0.389559,0.219499 1.0817441,0.915083 3.027399,3.042274 12.19404,12.30837 12.204589,12.337002 0.0161,0.04373 -5.364393,0.03953 -5.617359,-0.0045 z"
|
||||||
|
id="path1173"
|
||||||
|
inkscape:label="R" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,64 @@
|
|||||||
|
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||||
|
import { localize } from '../../localize/localize';
|
||||||
|
import { EntityRegistryManager } from '../../utils/ha/registry/entity';
|
||||||
|
import { BrowseMediaCamera } from '../browse-media/camera';
|
||||||
|
import { Camera, CameraInitializationOptions } from '../camera';
|
||||||
|
import { CameraInitializationError } from '../error';
|
||||||
|
import { CameraProxyConfig } from '../types';
|
||||||
|
|
||||||
|
interface ReolinkCameraInitializationOptions extends CameraInitializationOptions {
|
||||||
|
entityRegistryManager: EntityRegistryManager;
|
||||||
|
hass: HomeAssistant;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReolinkInitializationError extends CameraInitializationError {}
|
||||||
|
|
||||||
|
export class ReolinkCamera extends BrowseMediaCamera {
|
||||||
|
protected _channel: number | null = null;
|
||||||
|
|
||||||
|
public async initialize(options: ReolinkCameraInitializationOptions): Promise<Camera> {
|
||||||
|
await super.initialize(options);
|
||||||
|
this._initializeChannel();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _initializeChannel(): void {
|
||||||
|
const uniqueID = this._entity?.unique_id;
|
||||||
|
const match = uniqueID ? String(uniqueID).match(/(.*)_(?<channel>\d+)/) : null;
|
||||||
|
const channel = match && match.groups?.channel ? Number(match.groups.channel) : null;
|
||||||
|
|
||||||
|
if (channel === null) {
|
||||||
|
throw new ReolinkInitializationError(
|
||||||
|
localize('error.camera_initialization_reolink'),
|
||||||
|
this.getConfig(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this._channel = channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getChannel(): number | null {
|
||||||
|
return this._channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getProxyConfig(): CameraProxyConfig {
|
||||||
|
return {
|
||||||
|
...super.getProxyConfig(),
|
||||||
|
|
||||||
|
// For reolink, media is always proxied unless explicitly turned off.
|
||||||
|
media: this._config.proxy.media === 'auto' ? true : this._config.proxy.media,
|
||||||
|
|
||||||
|
// Reolink does not verify SSL certificates since they may be self-signed.
|
||||||
|
ssl_verification:
|
||||||
|
this._config.proxy.ssl_verification === 'auto'
|
||||||
|
? false
|
||||||
|
: this._config.proxy.ssl_verification,
|
||||||
|
|
||||||
|
// Through experimentation 'intermediate' is the "highest
|
||||||
|
// lowest-common-denominator" Reolink devices appear to support.
|
||||||
|
ssl_ciphers:
|
||||||
|
this._config.proxy.ssl_ciphers === 'auto'
|
||||||
|
? 'intermediate'
|
||||||
|
: this._config.proxy.ssl_ciphers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||||
|
import { add, endOfDay, parse, startOfDay } from 'date-fns';
|
||||||
|
import { orderBy } from 'lodash-es';
|
||||||
|
import { CameraConfig } from '../../config/types';
|
||||||
|
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
|
||||||
|
import { sortMediaByStartDate } from '../../utils/ha/browse-media/browse-media-manager';
|
||||||
|
import {
|
||||||
|
BROWSE_MEDIA_CACHE_SECONDS,
|
||||||
|
BrowseMedia,
|
||||||
|
MEDIA_CLASS_VIDEO,
|
||||||
|
RichBrowseMedia,
|
||||||
|
} from '../../utils/ha/browse-media/types';
|
||||||
|
import { ViewMedia } from '../../view/media';
|
||||||
|
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
|
||||||
|
import { BrowseMediaMetadata } from '../browse-media/types';
|
||||||
|
import { getViewMediaFromBrowseMediaArray } from '../browse-media/utils/browse-media-to-view-media';
|
||||||
|
import { isMediaWithinDates } from '../browse-media/utils/within-dates';
|
||||||
|
import { MemoryRequestCache } from '../cache';
|
||||||
|
import { Camera } from '../camera';
|
||||||
|
import { Capabilities } from '../capabilities';
|
||||||
|
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
|
||||||
|
import { CameraManagerReadOnlyConfigStore } from '../store';
|
||||||
|
import {
|
||||||
|
CameraEndpoint,
|
||||||
|
CameraEndpoints,
|
||||||
|
CameraEndpointsContext,
|
||||||
|
CameraManagerCameraMetadata,
|
||||||
|
Engine,
|
||||||
|
EngineOptions,
|
||||||
|
EventQuery,
|
||||||
|
EventQueryResults,
|
||||||
|
EventQueryResultsMap,
|
||||||
|
MediaMetadataQuery,
|
||||||
|
MediaMetadataQueryResults,
|
||||||
|
MediaMetadataQueryResultsMap,
|
||||||
|
QueryResults,
|
||||||
|
QueryResultsType,
|
||||||
|
QueryReturnType,
|
||||||
|
} from '../types';
|
||||||
|
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
||||||
|
import reolinkLogo from './assets/reolink.svg';
|
||||||
|
import { ReolinkCamera } from './camera';
|
||||||
|
import { ReolinkEventQueryResults } from './types';
|
||||||
|
|
||||||
|
export class ReolinkQueryResultsClassifier {
|
||||||
|
public static isReolinkEventQueryResults(
|
||||||
|
results: QueryResults,
|
||||||
|
): results is ReolinkEventQueryResults {
|
||||||
|
return results.engine === Engine.Reolink && results.type === QueryResultsType.Event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
||||||
|
protected _directoryCache = new MemoryRequestCache<string, BrowseMedia>();
|
||||||
|
protected _fileCache = new MemoryRequestCache<string, BrowseMedia>();
|
||||||
|
|
||||||
|
public getEngineType(): Engine {
|
||||||
|
return Engine.Reolink;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _reolinkFileMetadataGenerator(
|
||||||
|
cameraID: string,
|
||||||
|
media: BrowseMedia,
|
||||||
|
parent?: RichBrowseMedia<BrowseMediaMetadata>,
|
||||||
|
): BrowseMediaMetadata | null {
|
||||||
|
/* istanbul ignore next: This situation cannot happen as the directory would
|
||||||
|
be rejected by _reolinkDirectoryMetadataGenerator if there was no start date
|
||||||
|
-- @preserve */
|
||||||
|
if (!parent?._metadata?.startDate || media.media_class !== MEDIA_CLASS_VIDEO) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title of the form "21:47:03 0:00:44"
|
||||||
|
const parts = media.title.split(/ +/);
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = parse(parts[0], 'HH:mm:ss', parent._metadata.startDate);
|
||||||
|
if (!isValidDate(startDate)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationMatch = parts[1].match(
|
||||||
|
/(?<hours>\d+):(?<minutes>\d+):(?<seconds>\d+)/,
|
||||||
|
);
|
||||||
|
const duration = durationMatch?.groups
|
||||||
|
? {
|
||||||
|
hours: Number(durationMatch.groups.hours),
|
||||||
|
minutes: Number(durationMatch.groups.minutes),
|
||||||
|
seconds: Number(durationMatch.groups.seconds),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cameraID: cameraID,
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: duration ? add(startDate, duration) : startDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _reolinkDirectoryMetadataGenerator(
|
||||||
|
cameraID: string,
|
||||||
|
media: BrowseMedia,
|
||||||
|
): BrowseMediaMetadata | null {
|
||||||
|
// Title of the form: "2024/9/29"
|
||||||
|
const parsedDate = parse(media.title, 'yyyy/M/d', new Date());
|
||||||
|
|
||||||
|
return isValidDate(parsedDate)
|
||||||
|
? {
|
||||||
|
cameraID: cameraID,
|
||||||
|
startDate: startOfDay(parsedDate),
|
||||||
|
endDate: endOfDay(parsedDate),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createCamera(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameraConfig: CameraConfig,
|
||||||
|
): Promise<Camera> {
|
||||||
|
const camera = new ReolinkCamera(cameraConfig, this, {
|
||||||
|
capabilities: new Capabilities(
|
||||||
|
{
|
||||||
|
'favorite-events': false,
|
||||||
|
'favorite-recordings': false,
|
||||||
|
clips: true,
|
||||||
|
live: true,
|
||||||
|
menu: true,
|
||||||
|
recordings: false,
|
||||||
|
seek: false,
|
||||||
|
snapshots: false,
|
||||||
|
substream: true,
|
||||||
|
ptz: getPTZCapabilitiesFromCameraConfig(cameraConfig) ?? undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
disable: cameraConfig.capabilities?.disable,
|
||||||
|
disableExcept: cameraConfig.capabilities?.disable_except,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
eventCallback: this._eventCallback,
|
||||||
|
});
|
||||||
|
return await camera.initialize({
|
||||||
|
entityRegistryManager: this._entityRegistryManager,
|
||||||
|
hass,
|
||||||
|
stateWatcher: this._stateWatcher,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _getMatchingDirectories(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
camera: ReolinkCamera,
|
||||||
|
matchOptions?: {
|
||||||
|
start?: Date;
|
||||||
|
end?: Date;
|
||||||
|
} | null,
|
||||||
|
engineOptions?: EngineOptions,
|
||||||
|
): Promise<RichBrowseMedia<BrowseMediaMetadata>[] | null> {
|
||||||
|
const cameraConfig = camera.getConfig();
|
||||||
|
const entity = camera.getEntity();
|
||||||
|
const configID = entity?.config_entry_id;
|
||||||
|
|
||||||
|
if (camera.getChannel() === null || !configID) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this._browseMediaManager.walkBrowseMedias(
|
||||||
|
hass,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
targets: [
|
||||||
|
`media-source://reolink/RES|${configID}|${camera.getChannel()}|` +
|
||||||
|
`${cameraConfig.reolink?.media_resolution === 'low' ? 'sub' : 'main'}`,
|
||||||
|
],
|
||||||
|
concurrency: Infinity,
|
||||||
|
metadataGenerator: (
|
||||||
|
media: BrowseMedia,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
_parent?: RichBrowseMedia<BrowseMediaMetadata>,
|
||||||
|
) => this._reolinkDirectoryMetadataGenerator(camera.getID(), media),
|
||||||
|
matcher: (media: RichBrowseMedia<BrowseMediaMetadata>) =>
|
||||||
|
media.can_expand &&
|
||||||
|
isMediaWithinDates(media, matchOptions?.start, matchOptions?.end),
|
||||||
|
sorter: (media: RichBrowseMedia<BrowseMediaMetadata>[]) =>
|
||||||
|
sortMediaByStartDate(media),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
...(engineOptions?.useCache !== false && { cache: this._directoryCache }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getEvents(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
store: CameraManagerReadOnlyConfigStore,
|
||||||
|
query: EventQuery,
|
||||||
|
engineOptions?: EngineOptions,
|
||||||
|
): Promise<EventQueryResultsMap | null> {
|
||||||
|
// Reolink does not support these query types and they will never match.
|
||||||
|
if (
|
||||||
|
query.favorite ||
|
||||||
|
query.tags?.size ||
|
||||||
|
query.what?.size ||
|
||||||
|
query.where?.size ||
|
||||||
|
query.hasSnapshot
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output: EventQueryResultsMap = new Map();
|
||||||
|
const getEventsForCamera = async (cameraID: string): Promise<void> => {
|
||||||
|
const perCameraQuery = { ...query, cameraIDs: new Set([cameraID]) };
|
||||||
|
const cachedResult =
|
||||||
|
engineOptions?.useCache ?? true ? this._requestCache.get(perCameraQuery) : null;
|
||||||
|
if (cachedResult) {
|
||||||
|
output.set(perCameraQuery, cachedResult as EventQueryResults);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const camera = store.getCamera(cameraID);
|
||||||
|
const directories =
|
||||||
|
camera && camera instanceof ReolinkCamera
|
||||||
|
? await this._getMatchingDirectories(
|
||||||
|
hass,
|
||||||
|
camera,
|
||||||
|
perCameraQuery,
|
||||||
|
engineOptions,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const limit = perCameraQuery.limit ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT;
|
||||||
|
let media: RichBrowseMedia<BrowseMediaMetadata>[] = [];
|
||||||
|
|
||||||
|
if (directories?.length) {
|
||||||
|
media = await this._browseMediaManager.walkBrowseMedias(
|
||||||
|
hass,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
targets: directories,
|
||||||
|
concurrency: 1,
|
||||||
|
metadataGenerator: (
|
||||||
|
media: BrowseMedia,
|
||||||
|
parent?: RichBrowseMedia<BrowseMediaMetadata>,
|
||||||
|
) => this._reolinkFileMetadataGenerator(cameraID, media, parent),
|
||||||
|
earlyExit: (media) => media.length >= limit,
|
||||||
|
matcher: (media: RichBrowseMedia<BrowseMediaMetadata>) =>
|
||||||
|
!media.can_expand &&
|
||||||
|
isMediaWithinDates(media, perCameraQuery.start, perCameraQuery.end),
|
||||||
|
sorter: (media: RichBrowseMedia<BrowseMediaMetadata>[]) =>
|
||||||
|
sortMediaByStartDate(media),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
...(engineOptions?.useCache !== false && { cache: this._fileCache }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by most recent then slice at the query limit.
|
||||||
|
const sortedMedia = orderBy(
|
||||||
|
media,
|
||||||
|
(media: RichBrowseMedia<BrowseMediaMetadata>) => media._metadata?.startDate,
|
||||||
|
'desc',
|
||||||
|
).slice(0, limit);
|
||||||
|
|
||||||
|
const result: ReolinkEventQueryResults = {
|
||||||
|
type: QueryResultsType.Event,
|
||||||
|
engine: Engine.Reolink,
|
||||||
|
browseMedia: sortedMedia,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (engineOptions?.useCache ?? true) {
|
||||||
|
this._requestCache.set(
|
||||||
|
perCameraQuery,
|
||||||
|
{ ...result, cached: true },
|
||||||
|
result.expiry,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
output.set(perCameraQuery, result);
|
||||||
|
};
|
||||||
|
|
||||||
|
await allPromises(query.cameraIDs, (cameraID) => getEventsForCamera(cameraID));
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
public generateMediaFromEvents(
|
||||||
|
_hass: HomeAssistant,
|
||||||
|
_store: CameraManagerReadOnlyConfigStore,
|
||||||
|
_query: EventQuery,
|
||||||
|
results: QueryReturnType<EventQuery>,
|
||||||
|
): ViewMedia[] | null {
|
||||||
|
if (!ReolinkQueryResultsClassifier.isReolinkEventQueryResults(results)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return getViewMediaFromBrowseMediaArray(results.browseMedia);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getMediaMetadata(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
store: CameraManagerReadOnlyConfigStore,
|
||||||
|
query: MediaMetadataQuery,
|
||||||
|
engineOptions?: EngineOptions,
|
||||||
|
): Promise<MediaMetadataQueryResultsMap | null> {
|
||||||
|
const output: MediaMetadataQueryResultsMap = new Map();
|
||||||
|
const cachedResult =
|
||||||
|
engineOptions?.useCache ?? true ? this._requestCache.get(query) : null;
|
||||||
|
|
||||||
|
if (cachedResult) {
|
||||||
|
output.set(query, cachedResult as MediaMetadataQueryResults);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
const days: Set<string> = new Set();
|
||||||
|
const getDaysForCamera = async (cameraID: string): Promise<void> => {
|
||||||
|
const camera = store.getCamera(cameraID);
|
||||||
|
if (!camera || !(camera instanceof ReolinkCamera)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const directories = await this._getMatchingDirectories(
|
||||||
|
hass,
|
||||||
|
camera,
|
||||||
|
null,
|
||||||
|
engineOptions,
|
||||||
|
);
|
||||||
|
for (const dayDirectory of directories ?? []) {
|
||||||
|
/* istanbul ignore next: This situation cannot happen as the directory
|
||||||
|
will not match without metadata -- @preserve */
|
||||||
|
if (dayDirectory._metadata) {
|
||||||
|
days.add(formatDate(dayDirectory._metadata?.startDate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await allPromises(query.cameraIDs, (cameraID) => getDaysForCamera(cameraID));
|
||||||
|
|
||||||
|
const result: MediaMetadataQueryResults = {
|
||||||
|
type: QueryResultsType.MediaMetadata,
|
||||||
|
engine: Engine.Reolink,
|
||||||
|
metadata: {
|
||||||
|
...(days.size && { days: days }),
|
||||||
|
},
|
||||||
|
expiry: add(new Date(), { seconds: BROWSE_MEDIA_CACHE_SECONDS }),
|
||||||
|
cached: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (engineOptions?.useCache ?? true) {
|
||||||
|
this._requestCache.set(query, { ...result, cached: true }, result.expiry);
|
||||||
|
}
|
||||||
|
output.set(query, result);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCameraMetadata(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameraConfig: CameraConfig,
|
||||||
|
): CameraManagerCameraMetadata {
|
||||||
|
const metadata = super.getCameraMetadata(hass, cameraConfig);
|
||||||
|
return {
|
||||||
|
...metadata,
|
||||||
|
engineLogo: reolinkLogo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCameraEndpoints(
|
||||||
|
cameraConfig: CameraConfig,
|
||||||
|
context?: CameraEndpointsContext,
|
||||||
|
): CameraEndpoints | null {
|
||||||
|
const getUIEndpoint = (): CameraEndpoint | null => {
|
||||||
|
return cameraConfig.reolink?.url
|
||||||
|
? {
|
||||||
|
endpoint: cameraConfig.reolink.url,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
const ui = getUIEndpoint();
|
||||||
|
return {
|
||||||
|
...super.getCameraEndpoints(cameraConfig, context),
|
||||||
|
...(ui && { ui: ui }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { RichBrowseMedia } from '../../utils/ha/browse-media/types';
|
||||||
|
import { BrowseMediaMetadata } from '../browse-media/types';
|
||||||
|
import { Engine, EventQueryResults } from '../types';
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Reolink concrete query results
|
||||||
|
// ==============================
|
||||||
|
|
||||||
|
export interface ReolinkEventQueryResults extends EventQueryResults {
|
||||||
|
engine: Engine.Reolink;
|
||||||
|
browseMedia: RichBrowseMedia<BrowseMediaMetadata>[];
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CapabilityKey } from '../types';
|
import { CapabilityKey } from '../types';
|
||||||
import { FrigateCardView } from '../config/types';
|
import { FrigateCardView, SSLCiphers } from '../config/types';
|
||||||
import { ViewMedia } from '../view/media';
|
import { ViewMedia } from '../view/media';
|
||||||
|
|
||||||
// ====
|
// ====
|
||||||
@@ -24,6 +24,7 @@ export enum Engine {
|
|||||||
Frigate = 'frigate',
|
Frigate = 'frigate',
|
||||||
Generic = 'generic',
|
Generic = 'generic',
|
||||||
MotionEye = 'motioneye',
|
MotionEye = 'motioneye',
|
||||||
|
Reolink = 'reolink',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DataQuery {
|
export interface DataQuery {
|
||||||
@@ -126,6 +127,13 @@ export interface CameraEndpoints {
|
|||||||
webrtcCard?: CameraEndpoint;
|
webrtcCard?: CameraEndpoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CameraProxyConfig {
|
||||||
|
dynamic: boolean;
|
||||||
|
media: boolean;
|
||||||
|
ssl_verification: boolean;
|
||||||
|
ssl_ciphers: SSLCiphers;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EngineOptions {
|
export interface EngineOptions {
|
||||||
useCache?: boolean;
|
useCache?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,35 +48,31 @@ export class FrigateCardNextPreviousControl extends LitElement {
|
|||||||
return html``;
|
return html``;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderIcon =
|
||||||
|
!this.thumbnail || ['chevrons', 'icons'].includes(this._controlConfig.style);
|
||||||
|
|
||||||
const classes = {
|
const classes = {
|
||||||
controls: true,
|
controls: true,
|
||||||
previous: this.direction === 'previous',
|
previous: this.direction === 'previous',
|
||||||
next: this.direction === 'next',
|
next: this.direction === 'next',
|
||||||
thumbnails: this._controlConfig.style === 'thumbnails',
|
thumbnails: !renderIcon,
|
||||||
icons: ['chevrons', 'icons'].includes(this._controlConfig.style),
|
icons: renderIcon,
|
||||||
button: ['chevrons', 'icons'].includes(this._controlConfig.style),
|
button: renderIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (['chevrons', 'icons'].includes(this._controlConfig.style)) {
|
if (renderIcon) {
|
||||||
let icon: string;
|
const icon =
|
||||||
if (this._controlConfig.style === 'chevrons') {
|
!this.thumbnail || !this.icon || this._controlConfig.style === 'chevrons'
|
||||||
icon = this.direction === 'previous' ? 'mdi:chevron-left' : 'mdi:chevron-right';
|
? this.direction === 'previous'
|
||||||
} else {
|
? 'mdi:chevron-left'
|
||||||
if (!this.icon) {
|
: 'mdi:chevron-right'
|
||||||
return html``;
|
: this.icon;
|
||||||
}
|
|
||||||
icon = this.icon;
|
|
||||||
}
|
|
||||||
|
|
||||||
return html` <ha-icon-button class="${classMap(classes)}" .label=${this.label}>
|
return html` <ha-icon-button class="${classMap(classes)}" .label=${this.label}>
|
||||||
<ha-icon icon=${icon}></ha-icon>
|
<ha-icon icon=${icon}></ha-icon>
|
||||||
</ha-icon-button>`;
|
</ha-icon-button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.thumbnail) {
|
|
||||||
return html``;
|
|
||||||
}
|
|
||||||
|
|
||||||
return renderTask(
|
return renderTask(
|
||||||
this,
|
this,
|
||||||
this._embedThumbnailTask,
|
this._embedThumbnailTask,
|
||||||
|
|||||||
+44
-29
@@ -11,11 +11,13 @@ import {
|
|||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { classMap } from 'lit/directives/class-map.js';
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
import { CameraManager } from '../camera-manager/manager.js';
|
import { CameraManager } from '../camera-manager/manager.js';
|
||||||
|
import { CameraManagerCameraMetadata } from '../camera-manager/types.js';
|
||||||
|
import { RemoveContextViewModifier } from '../card-controller/view/modifiers/remove-context.js';
|
||||||
import { ViewManagerEpoch } from '../card-controller/view/types.js';
|
import { ViewManagerEpoch } from '../card-controller/view/types.js';
|
||||||
import { localize } from '../localize/localize.js';
|
import { localize } from '../localize/localize.js';
|
||||||
import thumbnailDetailsStyle from '../scss/thumbnail-details.scss';
|
import thumbnailDetailsStyle from '../scss/thumbnail-details.scss';
|
||||||
import thumbnailFeatureEventStyle from '../scss/thumbnail-feature-event.scss';
|
import thumbnailFeatureTextStyle from '../scss/thumbnail-feature-text.scss';
|
||||||
import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.scss';
|
import thumbnailFeatureThumbnailStyle from '../scss/thumbnail-feature-thumbnail.scss';
|
||||||
import thumbnailStyle from '../scss/thumbnail.scss';
|
import thumbnailStyle from '../scss/thumbnail.scss';
|
||||||
import type { ExtendedHomeAssistant } from '../types.js';
|
import type { ExtendedHomeAssistant } from '../types.js';
|
||||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||||
@@ -31,13 +33,12 @@ import { createFetchThumbnailTask, FetchThumbnailTaskArgs } from '../utils/thumb
|
|||||||
import { ViewMediaClassifier } from '../view/media-classifier.js';
|
import { ViewMediaClassifier } from '../view/media-classifier.js';
|
||||||
import { EventViewMedia, RecordingViewMedia, ViewMedia } from '../view/media.js';
|
import { EventViewMedia, RecordingViewMedia, ViewMedia } from '../view/media.js';
|
||||||
import { dispatchFrigateCardErrorEvent } from './message.js';
|
import { dispatchFrigateCardErrorEvent } from './message.js';
|
||||||
import { RemoveContextViewModifier } from '../card-controller/view/modifiers/remove-context.js';
|
|
||||||
|
|
||||||
// The minimum width of a thumbnail with details enabled.
|
// The minimum width of a thumbnail with details enabled.
|
||||||
export const THUMBNAIL_DETAILS_WIDTH_MIN = 300;
|
export const THUMBNAIL_DETAILS_WIDTH_MIN = 300;
|
||||||
|
|
||||||
@customElement('frigate-card-thumbnail-feature-event')
|
@customElement('frigate-card-thumbnail-feature-thumbnail')
|
||||||
export class FrigateCardThumbnailFeatureEvent extends LitElement {
|
export class FrigateCardThumbnailFeatureThumbnail extends LitElement {
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public thumbnail?: string;
|
public thumbnail?: string;
|
||||||
|
|
||||||
@@ -101,14 +102,15 @@ export class FrigateCardThumbnailFeatureEvent extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (!this._embedThumbnailTask) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const imageOff = html`<ha-icon
|
const imageOff = html`<ha-icon
|
||||||
icon="mdi:image-off"
|
icon="mdi:image-off"
|
||||||
title=${localize('thumbnail.no_thumbnail')}
|
title=${localize('thumbnail.no_thumbnail')}
|
||||||
></ha-icon> `;
|
></ha-icon> `;
|
||||||
|
|
||||||
|
if (!this._embedThumbnailTask) {
|
||||||
|
return imageOff;
|
||||||
|
}
|
||||||
|
|
||||||
return html`${this.thumbnail
|
return html`${this.thumbnail
|
||||||
? renderTask(
|
? renderTask(
|
||||||
this,
|
this,
|
||||||
@@ -121,31 +123,41 @@ export class FrigateCardThumbnailFeatureEvent extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static get styles(): CSSResult {
|
static get styles(): CSSResult {
|
||||||
return unsafeCSS(thumbnailFeatureEventStyle);
|
return unsafeCSS(thumbnailFeatureThumbnailStyle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@customElement('frigate-card-thumbnail-feature-recording')
|
@customElement('frigate-card-thumbnail-feature-text')
|
||||||
export class FrigateCardThumbnailFeatureRecording extends LitElement {
|
export class FrigateCardThumbnailFeatureText extends LitElement {
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public date?: Date;
|
public date?: Date;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraTitle?: string;
|
public cameraMetadata?: CameraManagerCameraMetadata;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public showCameraTitle?: boolean;
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (!this.date) {
|
if (!this.date) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return html`
|
return html`
|
||||||
<div class="title">${format(this.date, 'HH:mm')}</div>
|
${this.cameraMetadata?.engineLogo
|
||||||
<div class="subtitle">${format(this.date, 'MMM do')}</div>
|
? html`<img class="background" src="${this.cameraMetadata.engineLogo}" />`
|
||||||
${this.cameraTitle ? html`<div class="camera">${this.cameraTitle}</div>` : html``}
|
: ''}
|
||||||
|
<div class="content">
|
||||||
|
<div class="title">${format(this.date, 'HH:mm')}</div>
|
||||||
|
<div class="subtitle">${format(this.date, 'MMM do')}</div>
|
||||||
|
${this.showCameraTitle && this.cameraMetadata?.title
|
||||||
|
? html`<div class="camera">${this.cameraMetadata.title}</div>`
|
||||||
|
: html``}
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
static get styles(): CSSResult {
|
static get styles(): CSSResult {
|
||||||
return unsafeCSS(thumbnailFeatureRecordingStyle);
|
return unsafeCSS(thumbnailFeatureTextStyle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,25 +417,28 @@ export class FrigateCardThumbnail extends LitElement {
|
|||||||
this.media.getID() &&
|
this.media.getID() &&
|
||||||
mediaCapabilities?.canDownload;
|
mediaCapabilities?.canDownload;
|
||||||
|
|
||||||
const cameraTitle = this.cameraManager.getCameraMetadata(
|
const cameraMetadata = this.cameraManager.getCameraMetadata(
|
||||||
this.media.getCameraID(),
|
this.media.getCameraID(),
|
||||||
)?.title;
|
);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
${ViewMediaClassifier.isEvent(this.media)
|
${ViewMediaClassifier.isEvent(this.media) && thumbnail
|
||||||
? html`<frigate-card-thumbnail-feature-event
|
? html`<frigate-card-thumbnail-feature-thumbnail
|
||||||
aria-label="${title ?? ''}"
|
aria-label="${title ?? ''}"
|
||||||
title=${title}
|
title=${title}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
|
.date=${this.media.getStartTime() ?? undefined}
|
||||||
.thumbnail=${thumbnail ?? undefined}
|
.thumbnail=${thumbnail ?? undefined}
|
||||||
></frigate-card-thumbnail-feature-event>`
|
></frigate-card-thumbnail-feature-thumbnail>`
|
||||||
: ViewMediaClassifier.isRecording(this.media)
|
: ViewMediaClassifier.isEvent(this.media) ||
|
||||||
? html`<frigate-card-thumbnail-feature-recording
|
ViewMediaClassifier.isRecording(this.media)
|
||||||
|
? html`<frigate-card-thumbnail-feature-text
|
||||||
aria-label="${title ?? ''}"
|
aria-label="${title ?? ''}"
|
||||||
title="${title ?? ''}"
|
title="${title ?? ''}"
|
||||||
.cameraTitle=${this.details ? undefined : cameraTitle}
|
.cameraMetadata=${cameraMetadata}
|
||||||
|
.showCameraTitle=${!this.details}
|
||||||
.date=${this.media.getStartTime() ?? undefined}
|
.date=${this.media.getStartTime() ?? undefined}
|
||||||
></frigate-card-thumbnail-feature-recording>`
|
></frigate-card-thumbnail-feature-text>`
|
||||||
: html``}
|
: html``}
|
||||||
${shouldShowFavoriteControl
|
${shouldShowFavoriteControl
|
||||||
? html` <ha-icon
|
? html` <ha-icon
|
||||||
@@ -450,13 +465,13 @@ export class FrigateCardThumbnail extends LitElement {
|
|||||||
${this.details && ViewMediaClassifier.isEvent(this.media)
|
${this.details && ViewMediaClassifier.isEvent(this.media)
|
||||||
? html`<frigate-card-thumbnail-details-event
|
? html`<frigate-card-thumbnail-details-event
|
||||||
.media=${this.media ?? undefined}
|
.media=${this.media ?? undefined}
|
||||||
.cameraTitle=${cameraTitle}
|
.cameraTitle=${cameraMetadata?.title}
|
||||||
.seek=${this.seek}
|
.seek=${this.seek}
|
||||||
></frigate-card-thumbnail-details-event>`
|
></frigate-card-thumbnail-details-event>`
|
||||||
: this.details && ViewMediaClassifier.isRecording(this.media)
|
: this.details && ViewMediaClassifier.isRecording(this.media)
|
||||||
? html`<frigate-card-thumbnail-details-recording
|
? html`<frigate-card-thumbnail-details-recording
|
||||||
.media=${this.media ?? undefined}
|
.media=${this.media ?? undefined}
|
||||||
.cameraTitle=${cameraTitle}
|
.cameraTitle=${cameraMetadata?.title}
|
||||||
.seek=${this.seek}
|
.seek=${this.seek}
|
||||||
></frigate-card-thumbnail-details-recording>`
|
></frigate-card-thumbnail-details-recording>`
|
||||||
: html``}
|
: html``}
|
||||||
@@ -516,7 +531,7 @@ declare global {
|
|||||||
'frigate-card-thumbnail': FrigateCardThumbnail;
|
'frigate-card-thumbnail': FrigateCardThumbnail;
|
||||||
'frigate-card-thumbnail-details-recording': FrigateCardThumbnailDetailsRecording;
|
'frigate-card-thumbnail-details-recording': FrigateCardThumbnailDetailsRecording;
|
||||||
'frigate-card-thumbnail-details-event': FrigateCardThumbnailDetailsEvent;
|
'frigate-card-thumbnail-details-event': FrigateCardThumbnailDetailsEvent;
|
||||||
'frigate-card-thumbnail-feature-recording': FrigateCardThumbnailFeatureRecording;
|
'frigate-card-thumbnail-feature-text': FrigateCardThumbnailFeatureText;
|
||||||
'frigate-card-thumbnail-feature-event': FrigateCardThumbnailFeatureEvent;
|
'frigate-card-thumbnail-feature-thumbnail': FrigateCardThumbnailFeatureThumbnail;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
TemplateResult,
|
TemplateResult,
|
||||||
unsafeCSS,
|
unsafeCSS,
|
||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import { guard } from 'lit/directives/guard.js';
|
import { guard } from 'lit/directives/guard.js';
|
||||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||||
import { CameraManager } from '../../camera-manager/manager.js';
|
import { CameraManager } from '../../camera-manager/manager.js';
|
||||||
@@ -16,11 +16,24 @@ import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-
|
|||||||
import { CardWideConfig, ViewerConfig } from '../../config/types.js';
|
import { CardWideConfig, ViewerConfig } from '../../config/types.js';
|
||||||
import '../../patches/ha-hls-player.js';
|
import '../../patches/ha-hls-player.js';
|
||||||
import viewerProviderStyle from '../../scss/viewer-provider.scss';
|
import viewerProviderStyle from '../../scss/viewer-provider.scss';
|
||||||
import { ExtendedHomeAssistant, FrigateCardMediaPlayer } from '../../types.js';
|
import {
|
||||||
|
ExtendedHomeAssistant,
|
||||||
|
FrigateCardMediaPlayer,
|
||||||
|
ResolvedMedia,
|
||||||
|
} from '../../types.js';
|
||||||
import { mayHaveAudio } from '../../utils/audio.js';
|
import { mayHaveAudio } from '../../utils/audio.js';
|
||||||
import { aspectRatioToString } from '../../utils/basic.js';
|
import { aspectRatioToString, errorToConsole } from '../../utils/basic.js';
|
||||||
import { canonicalizeHAURL } from '../../utils/ha/index.js';
|
import {
|
||||||
|
canonicalizeHAURL,
|
||||||
|
homeAssistantSignPath,
|
||||||
|
isHARelativeURL,
|
||||||
|
} from '../../utils/ha/index.js';
|
||||||
import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media.js';
|
import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media.js';
|
||||||
|
import {
|
||||||
|
addDynamicProxyURL,
|
||||||
|
getWebProxiedURL,
|
||||||
|
shouldUseWebProxy,
|
||||||
|
} from '../../utils/ha/web-proxy.js';
|
||||||
import {
|
import {
|
||||||
dispatchMediaLoadedEvent,
|
dispatchMediaLoadedEvent,
|
||||||
dispatchMediaPauseEvent,
|
dispatchMediaPauseEvent,
|
||||||
@@ -77,6 +90,9 @@ export class FrigateCardViewerProvider
|
|||||||
protected _refVideoProvider: Ref<HTMLVideoElement> = createRef();
|
protected _refVideoProvider: Ref<HTMLVideoElement> = createRef();
|
||||||
protected _refImageProvider: Ref<HTMLImageElement> = createRef();
|
protected _refImageProvider: Ref<HTMLImageElement> = createRef();
|
||||||
|
|
||||||
|
@state()
|
||||||
|
protected _url: string | null = null;
|
||||||
|
|
||||||
public async play(): Promise<void> {
|
public async play(): Promise<void> {
|
||||||
await playMediaMutingIfNecessary(
|
await playMediaMutingIfNecessary(
|
||||||
this,
|
this,
|
||||||
@@ -191,21 +207,77 @@ export class FrigateCardViewerProvider
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected willUpdate(changedProps: PropertyValues): void {
|
protected async _setURL(): Promise<void> {
|
||||||
const mediaContentID = this.media ? this.media.getContentID() : null;
|
const mediaContentID = this.media?.getContentID();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(changedProps.has('load') ||
|
!this.media ||
|
||||||
changedProps.has('media') ||
|
!mediaContentID ||
|
||||||
changedProps.has('viewerConfig') ||
|
!this.hass ||
|
||||||
changedProps.has('resolvedMediaCache') ||
|
(this.viewerConfig?.lazy_load && !this.load)
|
||||||
changedProps.has('hass')) &&
|
|
||||||
this.hass &&
|
|
||||||
mediaContentID &&
|
|
||||||
!this.resolvedMediaCache?.has(mediaContentID) &&
|
|
||||||
(!this.viewerConfig?.lazy_load || this.load)
|
|
||||||
) {
|
) {
|
||||||
resolveMedia(this.hass, mediaContentID, this.resolvedMediaCache).then(() => {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedMedia: ResolvedMedia | null =
|
||||||
|
this.resolvedMediaCache?.get(mediaContentID) ?? null;
|
||||||
|
if (!resolvedMedia) {
|
||||||
|
resolvedMedia = await resolveMedia(
|
||||||
|
this.hass,
|
||||||
|
mediaContentID,
|
||||||
|
this.resolvedMediaCache,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolvedMedia) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsignedURL = resolvedMedia.url;
|
||||||
|
if (isHARelativeURL(unsignedURL)) {
|
||||||
|
// No need to proxy or sign local resolved URLs.
|
||||||
|
this._url = canonicalizeHAURL(this.hass, unsignedURL);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const camera = this.cameraManager?.getStore().getCamera(this.media.getCameraID());
|
||||||
|
const proxyConfig = camera?.getProxyConfig();
|
||||||
|
|
||||||
|
if (proxyConfig && shouldUseWebProxy(this.hass, proxyConfig, 'media')) {
|
||||||
|
if (proxyConfig.dynamic) {
|
||||||
|
// Don't use URL() parsing, since that will strip the port number if
|
||||||
|
// it's the default, just need to strip any hash part of the URL.
|
||||||
|
const urlWithoutQSorHash = unsignedURL.split(/#/)[0];
|
||||||
|
await addDynamicProxyURL(this.hass, urlWithoutQSorHash, {
|
||||||
|
sslVerification: proxyConfig.ssl_verification,
|
||||||
|
sslCiphers: proxyConfig.ssl_ciphers,
|
||||||
|
|
||||||
|
// The link may need to be opened multiple times.
|
||||||
|
openLimit: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this._url = await homeAssistantSignPath(
|
||||||
|
this.hass,
|
||||||
|
getWebProxiedURL(unsignedURL),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
errorToConsole(e as Error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this._url = unsignedURL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
|
if (
|
||||||
|
changedProps.has('load') ||
|
||||||
|
changedProps.has('media') ||
|
||||||
|
changedProps.has('viewerConfig') ||
|
||||||
|
changedProps.has('resolvedMediaCache') ||
|
||||||
|
changedProps.has('hass')
|
||||||
|
) {
|
||||||
|
this._setURL().then(() => {
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -262,13 +334,7 @@ export class FrigateCardViewerProvider
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaContentID = this.media.getContentID();
|
if (!this._url) {
|
||||||
const resolvedMedia = mediaContentID
|
|
||||||
? this.resolvedMediaCache?.get(mediaContentID)
|
|
||||||
: null;
|
|
||||||
if (!resolvedMedia) {
|
|
||||||
// Media will be resolved with the call in willUpdate() then this will be
|
|
||||||
// re-rendered.
|
|
||||||
return renderProgressIndicator({
|
return renderProgressIndicator({
|
||||||
cardWideConfig: this.cardWideConfig,
|
cardWideConfig: this.cardWideConfig,
|
||||||
});
|
});
|
||||||
@@ -288,7 +354,7 @@ export class FrigateCardViewerProvider
|
|||||||
muted
|
muted
|
||||||
playsinline
|
playsinline
|
||||||
title="${this.media.getTitle() ?? ''}"
|
title="${this.media.getTitle() ?? ''}"
|
||||||
url=${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}
|
url=${this._url}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
?controls=${this.viewerConfig.controls.builtin}
|
?controls=${this.viewerConfig.controls.builtin}
|
||||||
>
|
>
|
||||||
@@ -325,16 +391,13 @@ export class FrigateCardViewerProvider
|
|||||||
@play=${() => dispatchMediaPlayEvent(this)}
|
@play=${() => dispatchMediaPlayEvent(this)}
|
||||||
@pause=${() => dispatchMediaPauseEvent(this)}
|
@pause=${() => dispatchMediaPauseEvent(this)}
|
||||||
>
|
>
|
||||||
<source
|
<source src=${this._url} type="video/mp4" />
|
||||||
src=${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}
|
|
||||||
type="video/mp4"
|
|
||||||
/>
|
|
||||||
</video>
|
</video>
|
||||||
`
|
`
|
||||||
: html`<img
|
: html`<img
|
||||||
${ref(this._refImageProvider)}
|
${ref(this._refImageProvider)}
|
||||||
aria-label="${this.media.getTitle() ?? ''}"
|
aria-label="${this.media.getTitle() ?? ''}"
|
||||||
src="${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}"
|
src="${this._url}"
|
||||||
title="${this.media.getTitle() ?? ''}"
|
title="${this.media.getTitle() ?? ''}"
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
if (this.viewerConfig?.snapshot_click_plays_clip) {
|
if (this.viewerConfig?.snapshot_click_plays_clip) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import viewsStyle from '../scss/views.scss';
|
|||||||
import { ExtendedHomeAssistant } from '../types.js';
|
import { ExtendedHomeAssistant } from '../types.js';
|
||||||
import { DeviceRegistryManager } from '../utils/ha/registry/device/index.js';
|
import { DeviceRegistryManager } from '../utils/ha/registry/device/index.js';
|
||||||
import { ResolvedMediaCache } from '../utils/ha/resolved-media.js';
|
import { ResolvedMediaCache } from '../utils/ha/resolved-media.js';
|
||||||
|
import './surround.js';
|
||||||
|
|
||||||
// As a special case: The diagnostics view is not dynamically loaded in case
|
// As a special case: The diagnostics view is not dynamically loaded in case
|
||||||
// something goes wrong.
|
// something goes wrong.
|
||||||
|
|||||||
+37
-1
@@ -1321,7 +1321,7 @@ const castSchema = z.object({
|
|||||||
// Camera Configuration
|
// Camera Configuration
|
||||||
// *************************************************************************
|
// *************************************************************************
|
||||||
|
|
||||||
const ENGINES = ['auto', 'frigate', 'generic', 'motioneye'] as const;
|
const ENGINES = ['auto', 'frigate', 'generic', 'motioneye', 'reolink'] as const;
|
||||||
|
|
||||||
const cameraConfigDefault = {
|
const cameraConfigDefault = {
|
||||||
dependencies: {
|
dependencies: {
|
||||||
@@ -1343,6 +1343,9 @@ const cameraConfigDefault = {
|
|||||||
file_pattern: '%H-%M-%S' as const,
|
file_pattern: '%H-%M-%S' as const,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
reolink: {
|
||||||
|
media_resolution: 'low' as const,
|
||||||
|
},
|
||||||
ptz: ptzCameraConfigDefaults,
|
ptz: ptzCameraConfigDefaults,
|
||||||
triggers: {
|
triggers: {
|
||||||
motion: false,
|
motion: false,
|
||||||
@@ -1350,8 +1353,31 @@ const cameraConfigDefault = {
|
|||||||
events: [...CAMERA_TRIGGER_EVENT_TYPES],
|
events: [...CAMERA_TRIGGER_EVENT_TYPES],
|
||||||
entities: [],
|
entities: [],
|
||||||
},
|
},
|
||||||
|
proxy: {
|
||||||
|
media: 'auto' as const,
|
||||||
|
dynamic: true,
|
||||||
|
ssl_verification: 'auto' as const,
|
||||||
|
ssl_ciphers: 'auto' as const,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SSL_CIPHERS = ['default', 'insecure', 'intermediate', 'modern'] as const;
|
||||||
|
export type SSLCiphers = (typeof SSL_CIPHERS)[number];
|
||||||
|
|
||||||
|
const proxyConfigSchema = z.object({
|
||||||
|
media: z.boolean().or(z.literal('auto')).default(cameraConfigDefault.proxy.media),
|
||||||
|
dynamic: z.boolean().default(cameraConfigDefault.proxy.dynamic),
|
||||||
|
ssl_verification: z
|
||||||
|
.boolean()
|
||||||
|
.or(z.literal('auto'))
|
||||||
|
.default(cameraConfigDefault.proxy.ssl_verification),
|
||||||
|
ssl_ciphers: z
|
||||||
|
.enum(SSL_CIPHERS)
|
||||||
|
.or(z.literal('auto'))
|
||||||
|
.default(cameraConfigDefault.proxy.ssl_ciphers),
|
||||||
|
});
|
||||||
|
export type ProxyConfig = z.infer<typeof proxyConfigSchema>;
|
||||||
|
|
||||||
export const cameraConfigSchema = z
|
export const cameraConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
camera_entity: z.string().optional(),
|
camera_entity: z.string().optional(),
|
||||||
@@ -1431,6 +1457,14 @@ export const cameraConfigSchema = z
|
|||||||
.default(cameraConfigDefault.motioneye.movies),
|
.default(cameraConfigDefault.motioneye.movies),
|
||||||
})
|
})
|
||||||
.default(cameraConfigDefault.motioneye),
|
.default(cameraConfigDefault.motioneye),
|
||||||
|
reolink: z
|
||||||
|
.object({
|
||||||
|
url: z.string().optional(),
|
||||||
|
media_resolution: z
|
||||||
|
.enum(['high', 'low'])
|
||||||
|
.default(cameraConfigDefault.reolink.media_resolution),
|
||||||
|
})
|
||||||
|
.default(cameraConfigDefault.reolink),
|
||||||
|
|
||||||
// Live provider options.
|
// Live provider options.
|
||||||
live_provider: z.enum(LIVE_PROVIDERS).default(cameraConfigDefault.live_provider),
|
live_provider: z.enum(LIVE_PROVIDERS).default(cameraConfigDefault.live_provider),
|
||||||
@@ -1449,6 +1483,8 @@ export const cameraConfigSchema = z
|
|||||||
layout: mediaLayoutConfigSchema.optional(),
|
layout: mediaLayoutConfigSchema.optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|
||||||
|
proxy: proxyConfigSchema.default(cameraConfigDefault.proxy),
|
||||||
})
|
})
|
||||||
.default(cameraConfigDefault);
|
.default(cameraConfigDefault);
|
||||||
export type CameraConfig = z.infer<typeof cameraConfigSchema>;
|
export type CameraConfig = z.infer<typeof cameraConfigSchema>;
|
||||||
|
|||||||
@@ -79,6 +79,16 @@ export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_RIGHT =
|
|||||||
`${CONF_CAMERAS}.#.dimensions.layout.view_box.right` as const;
|
`${CONF_CAMERAS}.#.dimensions.layout.view_box.right` as const;
|
||||||
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_ZOOM_FACTOR =
|
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_ZOOM_FACTOR =
|
||||||
`${CONF_CAMERAS}.#.dimensions.layout.zoom` as const;
|
`${CONF_CAMERAS}.#.dimensions.layout.zoom` as const;
|
||||||
|
export const CONF_CAMERAS_ARRAY_PROXY_DYNAMIC =
|
||||||
|
`${CONF_CAMERAS}.#.proxy.dynamic` as const;
|
||||||
|
export const CONF_CAMERAS_ARRAY_PROXY_MEDIA = `${CONF_CAMERAS}.#.proxy.media` as const;
|
||||||
|
export const CONF_CAMERAS_ARRAY_PROXY_SSL_CIPHERS =
|
||||||
|
`${CONF_CAMERAS}.#.proxy.ssl_ciphers` as const;
|
||||||
|
export const CONF_CAMERAS_ARRAY_PROXY_SSL_VERIFICATION =
|
||||||
|
`${CONF_CAMERAS}.#.proxy.ssl_verification` as const;
|
||||||
|
export const CONF_CAMERAS_ARRAY_REOLINK_MEDIA_RESOLUTION =
|
||||||
|
`${CONF_CAMERAS}.#.reolink.media_resolution` as const;
|
||||||
|
export const CONF_CAMERAS_ARRAY_REOLINK_URL = `${CONF_CAMERAS}.#.reolink.url` as const;
|
||||||
export const CONF_CAMERAS_ARRAY_TRIGGERS_MOTION =
|
export const CONF_CAMERAS_ARRAY_TRIGGERS_MOTION =
|
||||||
`${CONF_CAMERAS}.#.triggers.motion` as const;
|
`${CONF_CAMERAS}.#.triggers.motion` as const;
|
||||||
export const CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY =
|
export const CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY =
|
||||||
|
|||||||
+177
-41
@@ -32,10 +32,9 @@ import {
|
|||||||
THUMBNAIL_WIDTH_MIN,
|
THUMBNAIL_WIDTH_MIN,
|
||||||
} from './config/types.js';
|
} from './config/types.js';
|
||||||
import {
|
import {
|
||||||
CONF_CAMERAS,
|
|
||||||
CONF_CAMERAS_ARRAY_CAMERA_ENTITY,
|
CONF_CAMERAS_ARRAY_CAMERA_ENTITY,
|
||||||
CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE,
|
|
||||||
CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE_EXCEPT,
|
CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE_EXCEPT,
|
||||||
|
CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE,
|
||||||
CONF_CAMERAS_ARRAY_CAST_DASHBOARD_DASHBOARD_PATH,
|
CONF_CAMERAS_ARRAY_CAST_DASHBOARD_DASHBOARD_PATH,
|
||||||
CONF_CAMERAS_ARRAY_CAST_DASHBOARD_VIEW_PATH,
|
CONF_CAMERAS_ARRAY_CAST_DASHBOARD_VIEW_PATH,
|
||||||
CONF_CAMERAS_ARRAY_CAST_METHOD,
|
CONF_CAMERAS_ARRAY_CAST_METHOD,
|
||||||
@@ -61,8 +60,8 @@ import {
|
|||||||
CONF_CAMERAS_ARRAY_GO2RTC_STREAM,
|
CONF_CAMERAS_ARRAY_GO2RTC_STREAM,
|
||||||
CONF_CAMERAS_ARRAY_ICON,
|
CONF_CAMERAS_ARRAY_ICON,
|
||||||
CONF_CAMERAS_ARRAY_ID,
|
CONF_CAMERAS_ARRAY_ID,
|
||||||
CONF_CAMERAS_ARRAY_IMAGE_ENTITY,
|
|
||||||
CONF_CAMERAS_ARRAY_IMAGE_ENTITY_PARAMETERS,
|
CONF_CAMERAS_ARRAY_IMAGE_ENTITY_PARAMETERS,
|
||||||
|
CONF_CAMERAS_ARRAY_IMAGE_ENTITY,
|
||||||
CONF_CAMERAS_ARRAY_IMAGE_MODE,
|
CONF_CAMERAS_ARRAY_IMAGE_MODE,
|
||||||
CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS,
|
CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS,
|
||||||
CONF_CAMERAS_ARRAY_IMAGE_URL,
|
CONF_CAMERAS_ARRAY_IMAGE_URL,
|
||||||
@@ -72,6 +71,12 @@ import {
|
|||||||
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN,
|
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN,
|
||||||
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_FILE_PATTERN,
|
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_FILE_PATTERN,
|
||||||
CONF_CAMERAS_ARRAY_MOTIONEYE_URL,
|
CONF_CAMERAS_ARRAY_MOTIONEYE_URL,
|
||||||
|
CONF_CAMERAS_ARRAY_PROXY_DYNAMIC,
|
||||||
|
CONF_CAMERAS_ARRAY_PROXY_MEDIA,
|
||||||
|
CONF_CAMERAS_ARRAY_PROXY_SSL_CIPHERS,
|
||||||
|
CONF_CAMERAS_ARRAY_PROXY_SSL_VERIFICATION,
|
||||||
|
CONF_CAMERAS_ARRAY_REOLINK_MEDIA_RESOLUTION,
|
||||||
|
CONF_CAMERAS_ARRAY_REOLINK_URL,
|
||||||
CONF_CAMERAS_ARRAY_TITLE,
|
CONF_CAMERAS_ARRAY_TITLE,
|
||||||
CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES,
|
CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES,
|
||||||
CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS,
|
CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS,
|
||||||
@@ -79,11 +84,12 @@ import {
|
|||||||
CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY,
|
CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY,
|
||||||
CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY,
|
CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY,
|
||||||
CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL,
|
CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL,
|
||||||
CONF_DIMENSIONS_ASPECT_RATIO,
|
CONF_CAMERAS,
|
||||||
CONF_DIMENSIONS_ASPECT_RATIO_MODE,
|
CONF_DIMENSIONS_ASPECT_RATIO_MODE,
|
||||||
|
CONF_DIMENSIONS_ASPECT_RATIO,
|
||||||
CONF_DIMENSIONS_HEIGHT,
|
CONF_DIMENSIONS_HEIGHT,
|
||||||
CONF_IMAGE_ENTITY,
|
|
||||||
CONF_IMAGE_ENTITY_PARAMETERS,
|
CONF_IMAGE_ENTITY_PARAMETERS,
|
||||||
|
CONF_IMAGE_ENTITY,
|
||||||
CONF_IMAGE_MODE,
|
CONF_IMAGE_MODE,
|
||||||
CONF_IMAGE_REFRESH_SECONDS,
|
CONF_IMAGE_REFRESH_SECONDS,
|
||||||
CONF_IMAGE_URL,
|
CONF_IMAGE_URL,
|
||||||
@@ -194,15 +200,14 @@ import {
|
|||||||
CONF_TIMELINE_WINDOW_SECONDS,
|
CONF_TIMELINE_WINDOW_SECONDS,
|
||||||
CONF_VIEW_CAMERA_SELECT,
|
CONF_VIEW_CAMERA_SELECT,
|
||||||
CONF_VIEW_DARK_MODE,
|
CONF_VIEW_DARK_MODE,
|
||||||
CONF_VIEW_DEFAULT,
|
|
||||||
CONF_VIEW_DEFAULT_CYCLE_CAMERA,
|
CONF_VIEW_DEFAULT_CYCLE_CAMERA,
|
||||||
CONF_VIEW_DEFAULT_RESET,
|
|
||||||
CONF_VIEW_DEFAULT_RESET_AFTER_INTERACTION,
|
CONF_VIEW_DEFAULT_RESET_AFTER_INTERACTION,
|
||||||
CONF_VIEW_DEFAULT_RESET_ENTITIES,
|
CONF_VIEW_DEFAULT_RESET_ENTITIES,
|
||||||
CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS,
|
CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS,
|
||||||
CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE,
|
CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE,
|
||||||
|
CONF_VIEW_DEFAULT_RESET,
|
||||||
|
CONF_VIEW_DEFAULT,
|
||||||
CONF_VIEW_INTERACTION_SECONDS,
|
CONF_VIEW_INTERACTION_SECONDS,
|
||||||
CONF_VIEW_KEYBOARD_SHORTCUTS,
|
|
||||||
CONF_VIEW_KEYBOARD_SHORTCUTS_ENABLED,
|
CONF_VIEW_KEYBOARD_SHORTCUTS_ENABLED,
|
||||||
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_DOWN,
|
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_DOWN,
|
||||||
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_HOME,
|
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_HOME,
|
||||||
@@ -211,14 +216,15 @@ import {
|
|||||||
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_UP,
|
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_UP,
|
||||||
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_IN,
|
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_IN,
|
||||||
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_OUT,
|
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_OUT,
|
||||||
CONF_VIEW_TRIGGERS,
|
CONF_VIEW_KEYBOARD_SHORTCUTS,
|
||||||
CONF_VIEW_TRIGGERS_ACTIONS,
|
|
||||||
CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE,
|
CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE,
|
||||||
CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER,
|
CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER,
|
||||||
CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER,
|
CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER,
|
||||||
|
CONF_VIEW_TRIGGERS_ACTIONS,
|
||||||
CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
|
CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
|
||||||
CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS,
|
CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS,
|
||||||
CONF_VIEW_TRIGGERS_UNTRIGGER_SECONDS,
|
CONF_VIEW_TRIGGERS_UNTRIGGER_SECONDS,
|
||||||
|
CONF_VIEW_TRIGGERS,
|
||||||
MEDIA_CHUNK_SIZE_MAX,
|
MEDIA_CHUNK_SIZE_MAX,
|
||||||
} from './const.js';
|
} from './const.js';
|
||||||
import { localize } from './localize/localize.js';
|
import { localize } from './localize/localize.js';
|
||||||
@@ -244,6 +250,8 @@ const MENU_CAMERAS_GO2RTC = 'cameras.go2rtc';
|
|||||||
const MENU_CAMERAS_IMAGE = 'cameras.image';
|
const MENU_CAMERAS_IMAGE = 'cameras.image';
|
||||||
const MENU_CAMERAS_LIVE_PROVIDER = 'cameras.live_provider';
|
const MENU_CAMERAS_LIVE_PROVIDER = 'cameras.live_provider';
|
||||||
const MENU_CAMERAS_MOTIONEYE = 'cameras.motioneye';
|
const MENU_CAMERAS_MOTIONEYE = 'cameras.motioneye';
|
||||||
|
const MENU_CAMERAS_PROXY = 'cameras.proxy';
|
||||||
|
const MENU_CAMERAS_REOLINK = 'cameras.reolink';
|
||||||
const MENU_CAMERAS_TRIGGERS = 'cameras.triggers';
|
const MENU_CAMERAS_TRIGGERS = 'cameras.triggers';
|
||||||
const MENU_CAMERAS_WEBRTC_CARD = 'cameras.webrtc_card';
|
const MENU_CAMERAS_WEBRTC_CARD = 'cameras.webrtc_card';
|
||||||
const MENU_LIVE_CONTROLS = 'live.controls';
|
const MENU_LIVE_CONTROLS = 'live.controls';
|
||||||
@@ -281,7 +289,7 @@ interface EditorOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface EditorSelectOption {
|
interface EditorSelectOption {
|
||||||
value: string;
|
value: unknown;
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,6 +828,74 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected _proxyMedia: EditorSelectOption[] = [
|
||||||
|
{ value: '', label: '' },
|
||||||
|
{
|
||||||
|
value: 'auto',
|
||||||
|
label: localize('config.cameras.proxy.media.auto'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: true,
|
||||||
|
label: localize('config.cameras.proxy.media.true'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: false,
|
||||||
|
label: localize('config.cameras.proxy.media.false'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
protected _proxySSLCiphers: EditorSelectOption[] = [
|
||||||
|
{ value: '', label: '' },
|
||||||
|
{
|
||||||
|
value: 'auto',
|
||||||
|
label: localize('config.cameras.proxy.ssl_ciphers.auto'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'default',
|
||||||
|
label: localize('config.cameras.proxy.ssl_ciphers.default'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'insecure',
|
||||||
|
label: localize('config.cameras.proxy.ssl_ciphers.insecure'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'intermediate',
|
||||||
|
label: localize('config.cameras.proxy.ssl_ciphers.intermediate'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'modern',
|
||||||
|
label: localize('config.cameras.proxy.ssl_ciphers.modern'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
protected _proxySSLVerification: EditorSelectOption[] = [
|
||||||
|
{ value: '', label: '' },
|
||||||
|
{
|
||||||
|
value: 'auto',
|
||||||
|
label: localize('config.cameras.proxy.ssl_verification.auto'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: true,
|
||||||
|
label: localize('config.cameras.proxy.ssl_verification.true'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: false,
|
||||||
|
label: localize('config.cameras.proxy.ssl_verification.false'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
protected _reolinkMediaResolution: EditorSelectOption[] = [
|
||||||
|
{ value: '', label: '' },
|
||||||
|
{
|
||||||
|
value: 'high',
|
||||||
|
label: localize('config.cameras.reolink.media_resolution.high'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'low',
|
||||||
|
label: localize('config.cameras.reolink.media_resolution.low'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
protected _statusBarStyles: EditorSelectOption[] = [
|
protected _statusBarStyles: EditorSelectOption[] = [
|
||||||
{ value: '', label: '' },
|
{ value: '', label: '' },
|
||||||
{ value: 'hover', label: localize('config.status_bar.styles.hover') },
|
{ value: 'hover', label: localize('config.status_bar.styles.hover') },
|
||||||
@@ -942,7 +1018,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
*/
|
*/
|
||||||
protected _renderOptionSelector(
|
protected _renderOptionSelector(
|
||||||
configPath: string,
|
configPath: string,
|
||||||
options: string[] | { value: string; label: string }[] = [],
|
options: string[] | { value: unknown; label: string }[] = [],
|
||||||
params?: {
|
params?: {
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -1980,36 +2056,55 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
cameraIndex,
|
cameraIndex,
|
||||||
'config.cameras.motioneye.editor_label',
|
'config.cameras.motioneye.editor_label',
|
||||||
'motioneye',
|
'motioneye',
|
||||||
html`
|
html` ${this._renderStringInput(
|
||||||
${this._renderStringInput(
|
getArrayConfigPath(CONF_CAMERAS_ARRAY_MOTIONEYE_URL, cameraIndex),
|
||||||
getArrayConfigPath(CONF_CAMERAS_ARRAY_MOTIONEYE_URL, cameraIndex),
|
)}
|
||||||
)}
|
${this._renderStringInput(
|
||||||
${this._renderStringInput(
|
getArrayConfigPath(
|
||||||
getArrayConfigPath(
|
CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_DIRECTORY_PATTERN,
|
||||||
CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_DIRECTORY_PATTERN,
|
cameraIndex,
|
||||||
cameraIndex,
|
),
|
||||||
|
)}
|
||||||
|
${this._renderStringInput(
|
||||||
|
getArrayConfigPath(
|
||||||
|
CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_FILE_PATTERN,
|
||||||
|
cameraIndex,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
${this._renderStringInput(
|
||||||
|
getArrayConfigPath(
|
||||||
|
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN,
|
||||||
|
cameraIndex,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
${this._renderStringInput(
|
||||||
|
getArrayConfigPath(
|
||||||
|
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_FILE_PATTERN,
|
||||||
|
cameraIndex,
|
||||||
|
),
|
||||||
|
)}`,
|
||||||
|
)}
|
||||||
|
${this._putInSubmenu(
|
||||||
|
MENU_CAMERAS_REOLINK,
|
||||||
|
cameraIndex,
|
||||||
|
'config.cameras.reolink.editor_label',
|
||||||
|
'reolink',
|
||||||
|
html` ${this._renderStringInput(
|
||||||
|
getArrayConfigPath(CONF_CAMERAS_ARRAY_REOLINK_URL, cameraIndex),
|
||||||
|
)}
|
||||||
|
${this._renderOptionSelector(
|
||||||
|
getArrayConfigPath(
|
||||||
|
CONF_CAMERAS_ARRAY_REOLINK_MEDIA_RESOLUTION,
|
||||||
|
cameraIndex,
|
||||||
|
),
|
||||||
|
this._reolinkMediaResolution,
|
||||||
|
{
|
||||||
|
label: localize(
|
||||||
|
'config.cameras.reolink.media_resolution.editor_label',
|
||||||
),
|
),
|
||||||
)}
|
},
|
||||||
${this._renderStringInput(
|
)}`,
|
||||||
getArrayConfigPath(
|
)}`,
|
||||||
CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_FILE_PATTERN,
|
|
||||||
cameraIndex,
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
${this._renderStringInput(
|
|
||||||
getArrayConfigPath(
|
|
||||||
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN,
|
|
||||||
cameraIndex,
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
${this._renderStringInput(
|
|
||||||
getArrayConfigPath(
|
|
||||||
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_FILE_PATTERN,
|
|
||||||
cameraIndex,
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
`,
|
|
||||||
)} `,
|
|
||||||
)}
|
)}
|
||||||
${this._putInSubmenu(
|
${this._putInSubmenu(
|
||||||
MENU_CAMERAS_LIVE_PROVIDER,
|
MENU_CAMERAS_LIVE_PROVIDER,
|
||||||
@@ -2239,6 +2334,47 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
)}
|
)}
|
||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
|
${this._putInSubmenu(
|
||||||
|
MENU_CAMERAS_PROXY,
|
||||||
|
cameraIndex,
|
||||||
|
'config.cameras.proxy.editor_label',
|
||||||
|
'mdi:arrow-decision',
|
||||||
|
html`
|
||||||
|
${this._renderOptionSelector(
|
||||||
|
getArrayConfigPath(CONF_CAMERAS_ARRAY_PROXY_MEDIA, cameraIndex),
|
||||||
|
this._proxyMedia,
|
||||||
|
{
|
||||||
|
label: localize('config.cameras.proxy.media.editor_label'),
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
${this._renderSwitch(
|
||||||
|
getArrayConfigPath(CONF_CAMERAS_ARRAY_PROXY_DYNAMIC, cameraIndex),
|
||||||
|
this._defaults.cameras.proxy.dynamic,
|
||||||
|
)}
|
||||||
|
${this._renderOptionSelector(
|
||||||
|
getArrayConfigPath(
|
||||||
|
CONF_CAMERAS_ARRAY_PROXY_SSL_VERIFICATION,
|
||||||
|
cameraIndex,
|
||||||
|
),
|
||||||
|
this._proxySSLVerification,
|
||||||
|
{
|
||||||
|
label: localize(
|
||||||
|
'config.cameras.proxy.ssl_verification.editor_label',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
${this._renderOptionSelector(
|
||||||
|
getArrayConfigPath(
|
||||||
|
CONF_CAMERAS_ARRAY_PROXY_SSL_CIPHERS,
|
||||||
|
cameraIndex,
|
||||||
|
),
|
||||||
|
this._proxySSLCiphers,
|
||||||
|
{
|
||||||
|
label: localize('config.cameras.proxy.ssl_ciphers.editor_label'),
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
`,
|
||||||
|
)}
|
||||||
</div>`
|
</div>`
|
||||||
: ``}
|
: ``}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -127,6 +127,39 @@
|
|||||||
},
|
},
|
||||||
"url": "MotionEye UI URL"
|
"url": "MotionEye UI URL"
|
||||||
},
|
},
|
||||||
|
"proxy": {
|
||||||
|
"dynamic": "Dynamic proxying",
|
||||||
|
"editor_label": "Camera proxying",
|
||||||
|
"media": {
|
||||||
|
"auto": "Media proxying automatically configured",
|
||||||
|
"editor_label": "Media proxying",
|
||||||
|
"false": "Media proxying disabled",
|
||||||
|
"true": "Media proxying enabled"
|
||||||
|
},
|
||||||
|
"ssl_ciphers": {
|
||||||
|
"auto": "SSL ciphers automatically configured",
|
||||||
|
"default": "Default SSL ciphers",
|
||||||
|
"editor_label": "SSL ciphers",
|
||||||
|
"insecure": "Insecure SSL ciphers",
|
||||||
|
"intermediate": "Intermediate SSL ciphers",
|
||||||
|
"modern": "Modern SSL ciphers"
|
||||||
|
},
|
||||||
|
"ssl_verification": {
|
||||||
|
"auto": "SSL verification automatically configured",
|
||||||
|
"editor_label": "SSL verification",
|
||||||
|
"false": "SSL verification disabled",
|
||||||
|
"true": "SSL verification enabled"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reolink": {
|
||||||
|
"editor_label": "Reolink options",
|
||||||
|
"media_resolution": {
|
||||||
|
"editor_label": "Media Resolution",
|
||||||
|
"high": "High Resolution",
|
||||||
|
"low": "Low Resolution"
|
||||||
|
},
|
||||||
|
"url": "Reolink UI URL"
|
||||||
|
},
|
||||||
"title": "Title for this camera (Autodetected from entity)",
|
"title": "Title for this camera (Autodetected from entity)",
|
||||||
"triggers": {
|
"triggers": {
|
||||||
"editor_label": "Trigger options",
|
"editor_label": "Trigger options",
|
||||||
@@ -575,6 +608,7 @@
|
|||||||
"error": {
|
"error": {
|
||||||
"awaiting_live": "Waiting for live stream to load ...",
|
"awaiting_live": "Waiting for live stream to load ...",
|
||||||
"camera_initialization": "Camera initialization failed",
|
"camera_initialization": "Camera initialization failed",
|
||||||
|
"camera_initialization_reolink": "Could not initialize Reolink camera",
|
||||||
"could_not_render_elements": "Could not render picture elements",
|
"could_not_render_elements": "Could not render picture elements",
|
||||||
"could_not_resolve": "Could not resolve media URL",
|
"could_not_resolve": "Could not resolve media URL",
|
||||||
"diagnostics": "Card diagnostics. Please review for confidential information prior to sharing",
|
"diagnostics": "Card diagnostics. Please review for confidential information prior to sharing",
|
||||||
@@ -587,8 +621,8 @@
|
|||||||
"failed_sign": "Could not sign Home Assistant URL",
|
"failed_sign": "Could not sign Home Assistant URL",
|
||||||
"image_load_error": "The image could not be loaded",
|
"image_load_error": "The image could not be loaded",
|
||||||
"invalid_configuration": "Invalid configuration",
|
"invalid_configuration": "Invalid configuration",
|
||||||
"invalid_configuration_override": "Invalid override configuration",
|
|
||||||
"invalid_configuration_no_hint": "No location hint available (bad or missing type?)",
|
"invalid_configuration_no_hint": "No location hint available (bad or missing type?)",
|
||||||
|
"invalid_configuration_override": "Invalid override configuration",
|
||||||
"invalid_elements_config": "Invalid picture elements configuration",
|
"invalid_elements_config": "Invalid picture elements configuration",
|
||||||
"invalid_response": "Received invalid response from Home Assistant for request",
|
"invalid_response": "Received invalid response from Home Assistant for request",
|
||||||
"jsmpeg_no_player": "Could not start JSMPEG player",
|
"jsmpeg_no_player": "Could not start JSMPEG player",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ div.title {
|
|||||||
|
|
||||||
div.details {
|
div.details {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
:host {
|
:host {
|
||||||
display: flex;
|
display: block;
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
aspect-ratio: 1 / 1;
|
|
||||||
overflow: hidden;
|
|
||||||
max-width: var(--frigate-card-thumbnail-size);
|
max-width: var(--frigate-card-thumbnail-size);
|
||||||
max-height: var(--frigate-card-thumbnail-size);
|
max-height: var(--frigate-card-thumbnail-size);
|
||||||
padding: 10px;
|
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
|
||||||
border: 1px solid var(--secondary-color);
|
border: 1px solid var(--secondary-color);
|
||||||
background-color: var(--secondary-background-color);
|
background-color: var(--secondary-background-color);
|
||||||
@@ -16,8 +11,41 @@
|
|||||||
--frigate-card-css-border-radius,
|
--frigate-card-css-border-radius,
|
||||||
var(--ha-card-border-radius, 4px)
|
var(--ha-card-border-radius, 4px)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 10px;
|
||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
// Ensure img.background is relative to this.
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.background {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
|
||||||
|
padding: 10%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
opacity: 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.content {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
color: var(--primary-text-color);
|
color: var(--primary-text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import frigateSVG from '../camera-manager/frigate/assets/frigate.svg';
|
import frigateSVG from '../camera-manager/frigate/assets/frigate.svg';
|
||||||
import motioneyeSVG from '../camera-manager/motioneye/assets/motioneye.svg';
|
import motioneyeSVG from '../camera-manager/motioneye/assets/motioneye.svg';
|
||||||
|
import reolinkSVG from '../camera-manager/reolink/assets/reolink.svg';
|
||||||
|
|
||||||
export const getCustomIconURL = (icon?: string): string | null => {
|
export const getCustomIconURL = (icon?: string): string | null => {
|
||||||
switch (icon) {
|
switch (icon) {
|
||||||
@@ -7,6 +8,8 @@ export const getCustomIconURL = (icon?: string): string | null => {
|
|||||||
return frigateSVG;
|
return frigateSVG;
|
||||||
case 'motioneye':
|
case 'motioneye':
|
||||||
return motioneyeSVG;
|
return motioneyeSVG;
|
||||||
|
case 'reolink':
|
||||||
|
return reolinkSVG;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
|||||||
import pkg from '../../package.json';
|
import pkg from '../../package.json';
|
||||||
import { RawFrigateCardConfig } from '../config/types';
|
import { RawFrigateCardConfig } from '../config/types';
|
||||||
import { getLanguage } from '../localize/localize';
|
import { getLanguage } from '../localize/localize';
|
||||||
|
import { getIntegrationManifest } from './ha/integration';
|
||||||
|
import { IntegrationManifest } from './ha/integration/types';
|
||||||
import { DeviceRegistryManager } from './ha/registry/device';
|
import { DeviceRegistryManager } from './ha/registry/device';
|
||||||
|
import { HASS_WEB_PROXY_DOMAIN } from './ha/web-proxy';
|
||||||
|
|
||||||
type FrigateVersions = Record<string, string>;
|
type FrigateDevices = Record<string, string>;
|
||||||
|
|
||||||
interface GitDiagnostics {
|
interface GitDiagnostics {
|
||||||
build_version?: string;
|
build_version?: string;
|
||||||
@@ -12,6 +15,11 @@ interface GitDiagnostics {
|
|||||||
commit_date?: string;
|
commit_date?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IntegrationDiagnostics {
|
||||||
|
detected: boolean;
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const getReleaseVersion = (): string => {
|
export const getReleaseVersion = (): string => {
|
||||||
const releaseVersion = '__FRIGATE_CARD_RELEASE_VERSION__';
|
const releaseVersion = '__FRIGATE_CARD_RELEASE_VERSION__';
|
||||||
|
|
||||||
@@ -36,11 +44,39 @@ export interface Diagnostics {
|
|||||||
timezone: string;
|
timezone: string;
|
||||||
git: GitDiagnostics;
|
git: GitDiagnostics;
|
||||||
|
|
||||||
frigate_versions?: FrigateVersions;
|
|
||||||
ha_version?: string;
|
ha_version?: string;
|
||||||
config?: RawFrigateCardConfig;
|
config?: RawFrigateCardConfig;
|
||||||
|
|
||||||
|
integrations: {
|
||||||
|
frigate: IntegrationDiagnostics & {
|
||||||
|
devices?: FrigateDevices;
|
||||||
|
};
|
||||||
|
hass_web_proxy: IntegrationDiagnostics;
|
||||||
|
reolink: IntegrationDiagnostics;
|
||||||
|
motioneye: IntegrationDiagnostics;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getIntegrationDiagnostics = async (
|
||||||
|
integration: string,
|
||||||
|
hass?: HomeAssistant,
|
||||||
|
): Promise<IntegrationDiagnostics> => {
|
||||||
|
let manifest: IntegrationManifest | null = null;
|
||||||
|
|
||||||
|
if (hass) {
|
||||||
|
try {
|
||||||
|
manifest = await getIntegrationManifest(hass, integration);
|
||||||
|
} catch (e) {
|
||||||
|
// Silently ignore integrations not being found.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
detected: !!manifest,
|
||||||
|
...(manifest?.version && { version: manifest.version }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const getDiagnostics = async (
|
export const getDiagnostics = async (
|
||||||
hass?: HomeAssistant,
|
hass?: HomeAssistant,
|
||||||
deviceRegistryManager?: DeviceRegistryManager,
|
deviceRegistryManager?: DeviceRegistryManager,
|
||||||
@@ -69,9 +105,6 @@ export const getDiagnostics = async (
|
|||||||
card_version: getReleaseVersion(),
|
card_version: getReleaseVersion(),
|
||||||
browser: navigator.userAgent,
|
browser: navigator.userAgent,
|
||||||
date: new Date(),
|
date: new Date(),
|
||||||
...(frigateVersionMap.size && {
|
|
||||||
frigate_versions: Object.fromEntries(frigateVersionMap),
|
|
||||||
}),
|
|
||||||
lang: getLanguage(),
|
lang: getLanguage(),
|
||||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
git: {
|
git: {
|
||||||
@@ -80,6 +113,17 @@ export const getDiagnostics = async (
|
|||||||
...(pkg['gitDate'] && { commit_date: pkg['gitDate'] }),
|
...(pkg['gitDate'] && { commit_date: pkg['gitDate'] }),
|
||||||
},
|
},
|
||||||
...(hass && { ha_version: hass.config.version }),
|
...(hass && { ha_version: hass.config.version }),
|
||||||
|
integrations: {
|
||||||
|
reolink: await getIntegrationDiagnostics('reolink', hass),
|
||||||
|
frigate: {
|
||||||
|
...(await getIntegrationDiagnostics('frigate', hass)),
|
||||||
|
...(frigateVersionMap.size && {
|
||||||
|
devices: Object.fromEntries(frigateVersionMap),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
hass_web_proxy: await getIntegrationDiagnostics(HASS_WEB_PROXY_DOMAIN, hass),
|
||||||
|
motioneye: await getIntegrationDiagnostics('motioneye', hass),
|
||||||
|
},
|
||||||
...(rawConfig && { config: rawConfig }),
|
...(rawConfig && { config: rawConfig }),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||||
import { add } from 'date-fns';
|
import { add } from 'date-fns';
|
||||||
import { homeAssistantWSRequest } from '..';
|
import chunk from 'lodash-es/chunk';
|
||||||
|
import orderBy from 'lodash-es/orderBy';
|
||||||
|
import { BrowseMediaMetadata } from '../../../camera-manager/browse-media/types';
|
||||||
import { MemoryRequestCache } from '../../../camera-manager/cache';
|
import { MemoryRequestCache } from '../../../camera-manager/cache';
|
||||||
import { allPromises } from '../../basic';
|
import { allPromises } from '../../basic';
|
||||||
|
import { homeAssistantWSRequest } from '../ws-request';
|
||||||
import {
|
import {
|
||||||
|
BROWSE_MEDIA_CACHE_SECONDS,
|
||||||
BrowseMedia,
|
BrowseMedia,
|
||||||
browseMediaSchema,
|
browseMediaSchema,
|
||||||
BROWSE_MEDIA_CACHE_SECONDS,
|
|
||||||
RichBrowseMedia,
|
RichBrowseMedia,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
@@ -19,10 +22,19 @@ type RichMetadataGenerator<M> = (
|
|||||||
export type BrowseMediaTarget<M> = string | RichBrowseMedia<M>;
|
export type BrowseMediaTarget<M> = string | RichBrowseMedia<M>;
|
||||||
type RichBrowseMediaPredicate<M> = (media: RichBrowseMedia<M>) => boolean;
|
type RichBrowseMediaPredicate<M> = (media: RichBrowseMedia<M>) => boolean;
|
||||||
|
|
||||||
|
export const sortMediaByStartDate = (
|
||||||
|
media: RichBrowseMedia<BrowseMediaMetadata>[],
|
||||||
|
): RichBrowseMedia<BrowseMediaMetadata>[] => {
|
||||||
|
return orderBy(media, (media) => media._metadata?.startDate, 'desc');
|
||||||
|
};
|
||||||
|
|
||||||
export interface BrowseMediaStep<M> {
|
export interface BrowseMediaStep<M> {
|
||||||
// The targets to start the media walk from.
|
// The targets to start the media walk from.
|
||||||
targets: BrowseMediaTarget<M>[];
|
targets: BrowseMediaTarget<M>[];
|
||||||
|
|
||||||
|
// How many children to process concurrently. Default is infinite.
|
||||||
|
concurrency?: number;
|
||||||
|
|
||||||
// All children of the target have the metadata generator applied to them
|
// All children of the target have the metadata generator applied to them
|
||||||
// first.
|
// first.
|
||||||
metadataGenerator?: RichMetadataGenerator<M>;
|
metadataGenerator?: RichMetadataGenerator<M>;
|
||||||
@@ -31,6 +43,12 @@ export interface BrowseMediaStep<M> {
|
|||||||
// output.
|
// output.
|
||||||
matcher: RichBrowseMediaPredicate<M>;
|
matcher: RichBrowseMediaPredicate<M>;
|
||||||
|
|
||||||
|
// Children (once past the matcher) will be sorted before the next step.
|
||||||
|
sorter?: (media: RichBrowseMedia<M>[]) => RichBrowseMedia<M>[];
|
||||||
|
|
||||||
|
// Whether to exit the walk early with the given output.
|
||||||
|
earlyExit?: (media: RichBrowseMedia<M>[]) => boolean;
|
||||||
|
|
||||||
// advance will be called to generate a next step (or null if the child should
|
// advance will be called to generate a next step (or null if the child should
|
||||||
// just be included straight through to the output with no further steps).
|
// just be included straight through to the output with no further steps).
|
||||||
advance?: BrowseMediaStepAdvancer<M>;
|
advance?: BrowseMediaStepAdvancer<M>;
|
||||||
@@ -39,23 +57,18 @@ export interface BrowseMediaStep<M> {
|
|||||||
type BrowseMediaStepAdvancer<M> = (media: RichBrowseMedia<M>[]) => BrowseMediaStep<M>[];
|
type BrowseMediaStepAdvancer<M> = (media: RichBrowseMedia<M>[]) => BrowseMediaStep<M>[];
|
||||||
|
|
||||||
export class BrowseMediaManager<M> {
|
export class BrowseMediaManager<M> {
|
||||||
protected _cache: BrowseMediaCache<M>;
|
|
||||||
|
|
||||||
constructor(cache: BrowseMediaCache<M>) {
|
|
||||||
this._cache = cache;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walk down a browse media tree according to instructions included in `steps`.
|
// Walk down a browse media tree according to instructions included in `steps`.
|
||||||
public async walkBrowseMedias(
|
public async walkBrowseMedias(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
steps: BrowseMediaStep<M>[] | null,
|
steps: BrowseMediaStep<M>[] | null,
|
||||||
options?: {
|
options?: {
|
||||||
useCache?: boolean;
|
cache?: BrowseMediaCache<M>;
|
||||||
},
|
},
|
||||||
): Promise<RichBrowseMedia<M>[]> {
|
): Promise<RichBrowseMedia<M>[]> {
|
||||||
if (!steps || !steps.length) {
|
if (!steps || !steps.length) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
await allPromises(
|
await allPromises(
|
||||||
steps,
|
steps,
|
||||||
@@ -68,60 +81,57 @@ export class BrowseMediaManager<M> {
|
|||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
step: BrowseMediaStep<M>,
|
step: BrowseMediaStep<M>,
|
||||||
options?: {
|
options?: {
|
||||||
useCache?: boolean;
|
cache?: BrowseMediaCache<M>;
|
||||||
},
|
},
|
||||||
): Promise<RichBrowseMedia<M>[]> {
|
): Promise<RichBrowseMedia<M>[]> {
|
||||||
const media = await allPromises(
|
let output: RichBrowseMedia<M>[] = [];
|
||||||
step.targets,
|
|
||||||
async (target) =>
|
|
||||||
await this._browseMedia(hass, target, {
|
|
||||||
useCache: options?.useCache,
|
|
||||||
metadataGenerator: step.metadataGenerator,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const newTargets: RichBrowseMedia<M>[] = [];
|
for (const targetChunk of chunk(step.targets, step.concurrency ?? Infinity)) {
|
||||||
for (const parent of media) {
|
const mediaChunk = await allPromises(
|
||||||
for (const child of parent.children ?? []) {
|
targetChunk,
|
||||||
if (step.matcher(child)) {
|
async (target) =>
|
||||||
newTargets.push(child);
|
await this._browseMedia(hass, target, {
|
||||||
|
cache: options?.cache,
|
||||||
|
matcher: step.matcher,
|
||||||
|
metadataGenerator: step.metadataGenerator,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const parent of mediaChunk) {
|
||||||
|
for (const child of parent.children ?? []) {
|
||||||
|
if (step.matcher(child)) {
|
||||||
|
output.push(child);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const nextSteps = step.advance ? step.advance(newTargets) : null;
|
if (step.sorter) {
|
||||||
if (!nextSteps || !nextSteps.length) {
|
output = step.sorter(output);
|
||||||
return newTargets;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const targetsIncludedInNextSteps = new Set(
|
if (step.earlyExit && step.earlyExit(output)) {
|
||||||
nextSteps.map((nextStep) => nextStep.targets).flat(),
|
break;
|
||||||
);
|
|
||||||
const finished: RichBrowseMedia<M>[] = [];
|
|
||||||
|
|
||||||
// Any new target that doesn't have a proposed 'next step' is assumed to be
|
|
||||||
// ready to return.
|
|
||||||
for (const target of newTargets) {
|
|
||||||
if (!targetsIncludedInNextSteps.has(target)) {
|
|
||||||
finished.push(target);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const downstream = await this.walkBrowseMedias(hass, nextSteps, options);
|
const nextSteps = step.advance ? step.advance(output) : null;
|
||||||
return finished.concat(downstream);
|
if (!nextSteps?.length) {
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
return await this.walkBrowseMedias(hass, nextSteps, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async _browseMedia(
|
protected async _browseMedia(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
target: string | RichBrowseMedia<M>,
|
target: string | RichBrowseMedia<M>,
|
||||||
options?: {
|
options?: {
|
||||||
useCache?: boolean;
|
cache?: BrowseMediaCache<M>;
|
||||||
|
matcher?: RichBrowseMediaPredicate<M>;
|
||||||
metadataGenerator?: RichMetadataGenerator<M>;
|
metadataGenerator?: RichMetadataGenerator<M>;
|
||||||
},
|
},
|
||||||
): Promise<RichBrowseMedia<M>> {
|
): Promise<RichBrowseMedia<M>> {
|
||||||
const mediaContentID = typeof target === 'object' ? target.media_content_id : target;
|
const mediaContentID = typeof target === 'object' ? target.media_content_id : target;
|
||||||
const cachedResult =
|
const cachedResult = options?.cache ? options.cache.get(mediaContentID) : null;
|
||||||
options?.useCache ?? true ? this._cache.get(mediaContentID) : null;
|
|
||||||
if (cachedResult) {
|
if (cachedResult) {
|
||||||
return cachedResult;
|
return cachedResult;
|
||||||
}
|
}
|
||||||
@@ -130,11 +140,11 @@ export class BrowseMediaManager<M> {
|
|||||||
type: 'media_source/browse_media',
|
type: 'media_source/browse_media',
|
||||||
media_content_id: mediaContentID,
|
media_content_id: mediaContentID,
|
||||||
};
|
};
|
||||||
const browseMedia = (await homeAssistantWSRequest(
|
const browseMedia = await homeAssistantWSRequest<RichBrowseMedia<M>>(
|
||||||
hass,
|
hass,
|
||||||
browseMediaSchema,
|
browseMediaSchema,
|
||||||
request,
|
request,
|
||||||
)) as RichBrowseMedia<M>;
|
);
|
||||||
|
|
||||||
if (options?.metadataGenerator) {
|
if (options?.metadataGenerator) {
|
||||||
for (const child of browseMedia.children ?? []) {
|
for (const child of browseMedia.children ?? []) {
|
||||||
@@ -146,8 +156,8 @@ export class BrowseMediaManager<M> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.useCache ?? true) {
|
if (options?.cache) {
|
||||||
this._cache.set(
|
options.cache.set(
|
||||||
mediaContentID,
|
mediaContentID,
|
||||||
browseMedia,
|
browseMedia,
|
||||||
add(new Date(), { seconds: BROWSE_MEDIA_CACHE_SECONDS }),
|
add(new Date(), { seconds: BROWSE_MEDIA_CACHE_SECONDS }),
|
||||||
|
|||||||
+13
-53
@@ -3,73 +3,25 @@ import {
|
|||||||
computeStateDomain,
|
computeStateDomain,
|
||||||
HomeAssistant,
|
HomeAssistant,
|
||||||
} from '@dermotduffy/custom-card-helpers';
|
} from '@dermotduffy/custom-card-helpers';
|
||||||
import { HassEntity, MessageBase } from 'home-assistant-js-websocket';
|
import { HassEntity } from 'home-assistant-js-websocket';
|
||||||
import { StyleInfo } from 'lit/directives/style-map.js';
|
import { StyleInfo } from 'lit/directives/style-map.js';
|
||||||
import { ZodSchema } from 'zod';
|
|
||||||
import { localize } from '../../localize/localize.js';
|
|
||||||
import {
|
import {
|
||||||
CardHelpers,
|
CardHelpers,
|
||||||
ExtendedHomeAssistant,
|
ExtendedHomeAssistant,
|
||||||
FrigateCardError,
|
|
||||||
LovelaceCardWithEditor,
|
LovelaceCardWithEditor,
|
||||||
SignedPath,
|
SignedPath,
|
||||||
signedPathSchema,
|
signedPathSchema,
|
||||||
StateParameters,
|
StateParameters,
|
||||||
} from '../../types.js';
|
} from '../../types.js';
|
||||||
import { domainIcon } from '../icons/domain-icon.js';
|
import { domainIcon } from '../icons/domain-icon.js';
|
||||||
import { getParseErrorKeys } from '../zod.js';
|
import { homeAssistantWSRequest } from './ws-request.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* Make a HomeAssistant websocket request. May throw.
|
|
||||||
* @param hass The HomeAssistant object to send the request with.
|
|
||||||
* @param schema The expected Zod schema of the response.
|
|
||||||
* @param request The request to make.
|
|
||||||
* @returns The parsed valid response or null on malformed.
|
|
||||||
*/
|
|
||||||
export async function homeAssistantWSRequest<T>(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
schema: ZodSchema<T>,
|
|
||||||
request: MessageBase,
|
|
||||||
passthrough = false,
|
|
||||||
): Promise<T> {
|
|
||||||
let response;
|
|
||||||
try {
|
|
||||||
response = await hass.callWS<T>(request);
|
|
||||||
} catch (e) {
|
|
||||||
if (!(e instanceof Error)) {
|
|
||||||
throw new FrigateCardError(localize('error.failed_response'), {
|
|
||||||
request: request,
|
|
||||||
response: e,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response) {
|
|
||||||
throw new FrigateCardError(localize('error.empty_response'), {
|
|
||||||
request: request,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Some endpoints on the integration pass through JSON directly from Frigate
|
|
||||||
// These end up wrapped in a string and must be unwrapped first
|
|
||||||
const parseResult = passthrough
|
|
||||||
? schema.safeParse(JSON.parse(response))
|
|
||||||
: schema.safeParse(response);
|
|
||||||
if (!parseResult.success) {
|
|
||||||
throw new FrigateCardError(localize('error.invalid_response'), {
|
|
||||||
request: request,
|
|
||||||
response: response,
|
|
||||||
invalid_keys: getParseErrorKeys<T>(parseResult.error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return parseResult.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request that HA sign a path. May throw.
|
* Request that HA sign a path. May throw.
|
||||||
* @param hass The HomeAssistant object used to request the signature.
|
* @param hass The HomeAssistant object used to request the signature.
|
||||||
* @param path The path to sign.
|
* @param path The path to sign.
|
||||||
* @param expires An optional number of seconds to sign the path for.
|
* @param expires An optional number of seconds to sign the path for (by default
|
||||||
|
* HA will sign for 30 seconds).
|
||||||
* @returns The signed URL, or null if the response was malformed.
|
* @returns The signed URL, or null if the response was malformed.
|
||||||
*/
|
*/
|
||||||
export async function homeAssistantSignPath(
|
export async function homeAssistantSignPath(
|
||||||
@@ -379,17 +331,25 @@ export const isCardInPanel = (card: HTMLElement): boolean => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function isHARelativeURL(url?: string): boolean {
|
||||||
|
return !!url?.startsWith('/');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure URLs use the correct HA URL (relevant for Chromecast where the default
|
* Ensure URLs use the correct HA URL (relevant for Chromecast where the default
|
||||||
* location will be the Chromecast receiver, not HA).
|
* location will be the Chromecast receiver, not HA).
|
||||||
* @param url The media URL
|
* @param url The media URL
|
||||||
*/
|
*/
|
||||||
export function canonicalizeHAURL(hass: ExtendedHomeAssistant, url: string): string;
|
export function canonicalizeHAURL(hass: ExtendedHomeAssistant, url: string): string;
|
||||||
|
export function canonicalizeHAURL(
|
||||||
|
hass: ExtendedHomeAssistant,
|
||||||
|
url?: string,
|
||||||
|
): string | null;
|
||||||
export function canonicalizeHAURL(
|
export function canonicalizeHAURL(
|
||||||
hass: ExtendedHomeAssistant,
|
hass: ExtendedHomeAssistant,
|
||||||
url?: string,
|
url?: string,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (hass && url && url.startsWith('/')) {
|
if (isHARelativeURL(url)) {
|
||||||
return hass.hassUrl(url);
|
return hass.hassUrl(url);
|
||||||
}
|
}
|
||||||
return url ?? null;
|
return url ?? null;
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||||
|
import { homeAssistantWSRequest } from '../ws-request';
|
||||||
|
import { IntegrationManifest, integrationManifestSchema } from './types';
|
||||||
|
|
||||||
|
export const getIntegrationManifest = async (
|
||||||
|
hass: HomeAssistant,
|
||||||
|
integration: string,
|
||||||
|
): Promise<IntegrationManifest> => {
|
||||||
|
return await homeAssistantWSRequest(hass, integrationManifestSchema, {
|
||||||
|
type: 'manifest/get',
|
||||||
|
integration: integration,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const integrationManifestSchema = z
|
||||||
|
.object({
|
||||||
|
domain: z.string(),
|
||||||
|
version: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
export type IntegrationManifest = z.infer<typeof integrationManifestSchema>;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||||
import { homeAssistantWSRequest } from '../..';
|
import { homeAssistantWSRequest } from '../../ws-request';
|
||||||
import { errorToConsole } from '../../../basic';
|
import { errorToConsole } from '../../../basic';
|
||||||
import { RegistryCache } from '../cache';
|
import { RegistryCache } from '../cache';
|
||||||
import { Device, DeviceList, deviceListSchema } from './types';
|
import { Device, DeviceList, deviceListSchema } from './types';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||||
import { homeAssistantWSRequest } from '../..';
|
import { homeAssistantWSRequest } from '../../ws-request';
|
||||||
import { errorToConsole } from '../../../basic';
|
import { errorToConsole } from '../../../basic';
|
||||||
import { RegistryCache } from '../cache';
|
import { RegistryCache } from '../cache';
|
||||||
import { Entity, EntityList, entityListSchema, entitySchema } from './types.js';
|
import { Entity, EntityList, entityListSchema, entitySchema } from './types.js';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||||
import QuickLRU from 'quick-lru';
|
import QuickLRU from 'quick-lru';
|
||||||
import { homeAssistantWSRequest } from '.';
|
import { homeAssistantWSRequest } from './ws-request';
|
||||||
import { ResolvedMedia, resolvedMediaSchema } from '../../types.js';
|
import { ResolvedMedia, resolvedMediaSchema } from '../../types.js';
|
||||||
import { errorToConsole } from '../basic';
|
import { errorToConsole } from '../basic';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||||
|
import { CameraProxyConfig } from '../../camera-manager/types';
|
||||||
|
import { ExtendedHomeAssistant } from '../../types';
|
||||||
|
|
||||||
|
export const HASS_WEB_PROXY_DOMAIN = 'hass_web_proxy';
|
||||||
|
|
||||||
|
const hasWebProxyAvailable = (hass: HomeAssistant): boolean => {
|
||||||
|
return hass.config.components.includes(HASS_WEB_PROXY_DOMAIN);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getWebProxiedURL = (url: string, v?: number): string => {
|
||||||
|
return `/api/${HASS_WEB_PROXY_DOMAIN}/v${v ?? 0}/?url=${encodeURIComponent(url)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const shouldUseWebProxy = (
|
||||||
|
hass: HomeAssistant,
|
||||||
|
proxyConfig: CameraProxyConfig,
|
||||||
|
context: 'media' = 'media',
|
||||||
|
): boolean => {
|
||||||
|
return hasWebProxyAvailable(hass) && !!proxyConfig[context];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request that HA sign a path. May throw.
|
||||||
|
* @param hass The HomeAssistant object used to request the signature.
|
||||||
|
* @param path The path to sign.
|
||||||
|
* @param expires An optional number of seconds to sign the path for (by default
|
||||||
|
* HA will sign for 30 seconds).
|
||||||
|
* @returns The signed URL, or null if the response was malformed.
|
||||||
|
*/
|
||||||
|
export async function addDynamicProxyURL(
|
||||||
|
hass: ExtendedHomeAssistant,
|
||||||
|
url_pattern: string,
|
||||||
|
options?: {
|
||||||
|
urlID?: string;
|
||||||
|
sslVerification?: boolean;
|
||||||
|
sslCiphers?: string;
|
||||||
|
openLimit?: number;
|
||||||
|
ttl?: number;
|
||||||
|
allowUnauthenticated?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
await hass.callService(HASS_WEB_PROXY_DOMAIN, 'create_proxied_url', {
|
||||||
|
url_pattern: url_pattern,
|
||||||
|
...(options && {
|
||||||
|
url_id: options.urlID,
|
||||||
|
ssl_verification: options.sslVerification,
|
||||||
|
ssl_ciphers: options.sslCiphers,
|
||||||
|
open_limit: options.openLimit,
|
||||||
|
ttl: options.ttl,
|
||||||
|
allow_unauthenticated: options.allowUnauthenticated,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||||
|
import { MessageBase } from 'home-assistant-js-websocket';
|
||||||
|
import { ZodSchema } from 'zod';
|
||||||
|
import { localize } from '../../localize/localize';
|
||||||
|
import { FrigateCardError } from '../../types';
|
||||||
|
import { getParseErrorKeys } from '../zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a HomeAssistant websocket request. May throw.
|
||||||
|
* @param hass The HomeAssistant object to send the request with.
|
||||||
|
* @param schema The expected Zod schema of the response.
|
||||||
|
* @param request The request to make.
|
||||||
|
* @returns The parsed valid response or null on malformed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function homeAssistantWSRequest<T>(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
schema: ZodSchema<T>,
|
||||||
|
request: MessageBase,
|
||||||
|
passthrough = false,
|
||||||
|
): Promise<T> {
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await hass.callWS<T>(request);
|
||||||
|
} catch (e) {
|
||||||
|
if (!(e instanceof Error)) {
|
||||||
|
throw new FrigateCardError(localize('error.failed_response'), {
|
||||||
|
request: request,
|
||||||
|
response: e,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
throw new FrigateCardError(localize('error.empty_response'), {
|
||||||
|
request: request,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Some endpoints on the integration pass through JSON directly from Frigate
|
||||||
|
// These end up wrapped in a string and must be unwrapped first
|
||||||
|
const parseResult = passthrough
|
||||||
|
? schema.safeParse(JSON.parse(response))
|
||||||
|
: schema.safeParse(response);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new FrigateCardError(localize('error.invalid_response'), {
|
||||||
|
request: request,
|
||||||
|
response: response,
|
||||||
|
invalid_keys: getParseErrorKeys<T>(parseResult.error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return parseResult.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { BrowseMediaMetadata } from '../../../../src/camera-manager/browse-media/types';
|
||||||
|
import { getViewMediaFromBrowseMediaArray } from '../../../../src/camera-manager/browse-media/utils/browse-media-to-view-media';
|
||||||
|
import {
|
||||||
|
MEDIA_CLASS_IMAGE,
|
||||||
|
MEDIA_CLASS_VIDEO,
|
||||||
|
RichBrowseMedia,
|
||||||
|
} from '../../../../src/utils/ha/browse-media/types';
|
||||||
|
import { createBrowseMedia } from './test-utils';
|
||||||
|
|
||||||
|
const createBrowseMediaChildren = (
|
||||||
|
overrides: Partial<RichBrowseMedia<BrowseMediaMetadata>>[],
|
||||||
|
) => {
|
||||||
|
return overrides.map((override) => createBrowseMedia(override));
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('getViewMediaFromBrowseMediaArray', () => {
|
||||||
|
it('should ignore absent metadata', () => {
|
||||||
|
const children = createBrowseMediaChildren([{ _metadata: undefined }]);
|
||||||
|
expect(getViewMediaFromBrowseMediaArray(children)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore unknown media class', () => {
|
||||||
|
const children = createBrowseMediaChildren([{ media_class: 'UNKNOWN' }]);
|
||||||
|
expect(getViewMediaFromBrowseMediaArray(children)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate clip view media', () => {
|
||||||
|
const children = createBrowseMediaChildren([{ media_class: MEDIA_CLASS_VIDEO }]);
|
||||||
|
const viewMedia = getViewMediaFromBrowseMediaArray(children);
|
||||||
|
expect(viewMedia).toHaveLength(1);
|
||||||
|
expect(viewMedia?.[0].getMediaType()).toBe('clip');
|
||||||
|
expect(viewMedia?.[0].getID()).toBe('camera.test/2024-11-19 07:23:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate snapshot view media', () => {
|
||||||
|
const children = createBrowseMediaChildren([{ media_class: MEDIA_CLASS_IMAGE }]);
|
||||||
|
const viewMedia = getViewMediaFromBrowseMediaArray(children);
|
||||||
|
expect(viewMedia).toHaveLength(1);
|
||||||
|
expect(viewMedia?.[0].getMediaType()).toBe('snapshot');
|
||||||
|
expect(viewMedia?.[0].getID()).toBe('camera.test/2024-11-19 07:23:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should de-duplicate snapshot/clip duplicates', () => {
|
||||||
|
const children = createBrowseMediaChildren([
|
||||||
|
{ media_class: MEDIA_CLASS_IMAGE },
|
||||||
|
{ media_class: MEDIA_CLASS_IMAGE },
|
||||||
|
{ media_class: MEDIA_CLASS_VIDEO },
|
||||||
|
]);
|
||||||
|
const viewMedia = getViewMediaFromBrowseMediaArray(children);
|
||||||
|
expect(viewMedia).toHaveLength(1);
|
||||||
|
expect(viewMedia?.[0].getMediaType()).toBe('clip');
|
||||||
|
expect(viewMedia?.[0].getID()).toBe('camera.test/2024-11-19 07:23:00');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { BrowseMediaMetadata } from '../../../../src/camera-manager/browse-media/types';
|
||||||
|
import { RichBrowseMedia } from '../../../../src/utils/ha/browse-media/types';
|
||||||
|
|
||||||
|
export const createBrowseMedia = (
|
||||||
|
media?: Partial<RichBrowseMedia<BrowseMediaMetadata>>,
|
||||||
|
): RichBrowseMedia<BrowseMediaMetadata> => {
|
||||||
|
return {
|
||||||
|
title: 'Test Media',
|
||||||
|
media_class: 'video',
|
||||||
|
media_content_type: 'video/mp4',
|
||||||
|
media_content_id: 'test',
|
||||||
|
can_play: true,
|
||||||
|
can_expand: false,
|
||||||
|
thumbnail: null,
|
||||||
|
children: null,
|
||||||
|
_metadata: {
|
||||||
|
cameraID: 'camera.test',
|
||||||
|
startDate: new Date('2024-11-19T07:23:00'),
|
||||||
|
endDate: new Date('2025-11-19T07:24:00'),
|
||||||
|
},
|
||||||
|
...media,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { sub } from 'date-fns';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { isMediaWithinDates } from '../../../../src/camera-manager/browse-media/utils/within-dates';
|
||||||
|
import { createBrowseMedia } from './test-utils';
|
||||||
|
|
||||||
|
describe('isMediaWithinDates', () => {
|
||||||
|
const rangeStart = new Date('2024-11-19T07:00:00');
|
||||||
|
const rangeEnd = new Date('2024-11-19T08:00:00');
|
||||||
|
|
||||||
|
it('should never match media without metadata', () => {
|
||||||
|
const media = createBrowseMedia({ _metadata: undefined });
|
||||||
|
expect(isMediaWithinDates(media, rangeStart, rangeEnd)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should always match media without start or end date', () => {
|
||||||
|
expect(isMediaWithinDates(createBrowseMedia(), undefined, undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match without a start date in the range', () => {
|
||||||
|
expect(isMediaWithinDates(createBrowseMedia(), undefined, rangeEnd)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match without an end date in the range', () => {
|
||||||
|
expect(isMediaWithinDates(createBrowseMedia(), rangeStart, undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match when ranges overlap', () => {
|
||||||
|
expect(isMediaWithinDates(createBrowseMedia(), rangeStart, rangeEnd)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not match when ranges do not overlap', () => {
|
||||||
|
expect(
|
||||||
|
isMediaWithinDates(
|
||||||
|
createBrowseMedia(),
|
||||||
|
sub(rangeStart, { days: 1 }),
|
||||||
|
sub(rangeEnd, { days: 1 }),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { mock } from 'vitest-mock-extended';
|
import { mock } from 'vitest-mock-extended';
|
||||||
import { Camera } from '../../src/camera-manager/camera.js';
|
import { Camera } from '../../src/camera-manager/camera.js';
|
||||||
import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic.js';
|
import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic.js';
|
||||||
|
import { CameraProxyConfig } from '../../src/camera-manager/types.js';
|
||||||
import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
|
import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
|
||||||
|
import { ProxyConfig } from '../../src/config/types.js';
|
||||||
import {
|
import {
|
||||||
callStateWatcherCallback,
|
callStateWatcherCallback,
|
||||||
createCameraConfig,
|
createCameraConfig,
|
||||||
@@ -137,4 +139,124 @@ describe('Camera', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('should get proxy config', () => {
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
'when unspecified',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
dynamic: true,
|
||||||
|
media: false,
|
||||||
|
ssl_verification: true,
|
||||||
|
ssl_ciphers: 'default' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'when media set to true',
|
||||||
|
{ media: true },
|
||||||
|
{
|
||||||
|
dynamic: true,
|
||||||
|
media: true,
|
||||||
|
ssl_verification: true,
|
||||||
|
ssl_ciphers: 'default' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'when media set to false',
|
||||||
|
{ media: false as const },
|
||||||
|
{
|
||||||
|
dynamic: true,
|
||||||
|
media: false,
|
||||||
|
ssl_verification: true,
|
||||||
|
ssl_ciphers: 'default' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'when media set to auto',
|
||||||
|
{ media: 'auto' as const },
|
||||||
|
{
|
||||||
|
dynamic: true,
|
||||||
|
media: false,
|
||||||
|
ssl_verification: true,
|
||||||
|
ssl_ciphers: 'default' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'when ssl_verification is set to auto',
|
||||||
|
{ ssl_verification: 'auto' as const },
|
||||||
|
{
|
||||||
|
dynamic: true,
|
||||||
|
media: false,
|
||||||
|
ssl_verification: true,
|
||||||
|
ssl_ciphers: 'default' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'when ssl_verification is set to true',
|
||||||
|
{ ssl_verification: true },
|
||||||
|
{
|
||||||
|
dynamic: true,
|
||||||
|
media: false,
|
||||||
|
ssl_verification: true,
|
||||||
|
ssl_ciphers: 'default' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'when ssl_verification is set to false',
|
||||||
|
{ ssl_verification: false },
|
||||||
|
{
|
||||||
|
dynamic: true,
|
||||||
|
media: false,
|
||||||
|
ssl_verification: false,
|
||||||
|
ssl_ciphers: 'default' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'when ssl_ciphers is set to auto',
|
||||||
|
{ ssl_ciphers: 'auto' as const },
|
||||||
|
{
|
||||||
|
dynamic: true,
|
||||||
|
media: false,
|
||||||
|
ssl_verification: true,
|
||||||
|
ssl_ciphers: 'default' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'when ssl_ciphers is set to modern',
|
||||||
|
{ ssl_ciphers: 'modern' as const },
|
||||||
|
{
|
||||||
|
dynamic: true,
|
||||||
|
media: false,
|
||||||
|
ssl_verification: true,
|
||||||
|
ssl_ciphers: 'modern' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'when dynamic is set to false',
|
||||||
|
{ dynamic: false },
|
||||||
|
{
|
||||||
|
dynamic: false,
|
||||||
|
media: false,
|
||||||
|
ssl_verification: true,
|
||||||
|
ssl_ciphers: 'default' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])(
|
||||||
|
'%s',
|
||||||
|
(
|
||||||
|
_name: string,
|
||||||
|
proxyConfig: Partial<ProxyConfig>,
|
||||||
|
expectedResult: CameraProxyConfig,
|
||||||
|
) => {
|
||||||
|
const camera = new Camera(
|
||||||
|
createCameraConfig({
|
||||||
|
proxy: proxyConfig,
|
||||||
|
}),
|
||||||
|
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||||
|
);
|
||||||
|
expect(camera.getProxyConfig()).toEqual(expectedResult);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
createRegistryEntity,
|
createRegistryEntity,
|
||||||
createStateEntity,
|
createStateEntity,
|
||||||
} from '../test-utils';
|
} from '../test-utils';
|
||||||
|
import { ReolinkCameraManagerEngine } from '../../src/camera-manager/reolink/engine-reolink.js';
|
||||||
|
|
||||||
vi.mock('../../src/utils/ha/entity-registry');
|
vi.mock('../../src/utils/ha/entity-registry');
|
||||||
vi.mock('../../src/utils/ha/entity-registry/cache');
|
vi.mock('../../src/utils/ha/entity-registry/cache');
|
||||||
@@ -70,7 +71,7 @@ describe('getEngineForCamera()', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('should get a motionEye camera', () => {
|
describe('should get a motioneye camera', () => {
|
||||||
it('from manually set engine', async () => {
|
it('from manually set engine', async () => {
|
||||||
const config = createCameraConfig({ engine: 'motioneye' });
|
const config = createCameraConfig({ engine: 'motioneye' });
|
||||||
expect(await createFactory().getEngineForCamera(createHASS(), config)).toBe(
|
expect(await createFactory().getEngineForCamera(createHASS(), config)).toBe(
|
||||||
@@ -98,6 +99,34 @@ describe('getEngineForCamera()', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('should get a reolink camera', () => {
|
||||||
|
it('from manually set engine', async () => {
|
||||||
|
const config = createCameraConfig({ engine: 'reolink' });
|
||||||
|
expect(await createFactory().getEngineForCamera(createHASS(), config)).toBe(
|
||||||
|
Engine.Reolink,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('from auto detection', async () => {
|
||||||
|
const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' });
|
||||||
|
const entityRegistryManager = new EntityRegistryManager(
|
||||||
|
createEntityRegistryCache(),
|
||||||
|
);
|
||||||
|
|
||||||
|
entityRegistryManager.getEntity = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
createRegistryEntity({ entity_id: 'camera.foo', platform: 'reolink' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await createFactory({
|
||||||
|
entityRegistryManager: entityRegistryManager,
|
||||||
|
}).getEngineForCamera(createHASS(), config),
|
||||||
|
).toBe(Engine.Reolink);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('should get a generic camera', () => {
|
describe('should get a generic camera', () => {
|
||||||
it('from manually set engine', async () => {
|
it('from manually set engine', async () => {
|
||||||
const config = createCameraConfig({ engine: 'generic' });
|
const config = createCameraConfig({ engine: 'generic' });
|
||||||
@@ -233,4 +262,12 @@ describe('createEngine()', () => {
|
|||||||
}),
|
}),
|
||||||
).toBeInstanceOf(MotionEyeCameraManagerEngine);
|
).toBeInstanceOf(MotionEyeCameraManagerEngine);
|
||||||
});
|
});
|
||||||
|
it('should create reolink engine', async () => {
|
||||||
|
expect(
|
||||||
|
await createFactory().createEngine(Engine.Reolink, {
|
||||||
|
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||||
|
resolvedMediaCache: mock<ResolvedMediaCache>(),
|
||||||
|
}),
|
||||||
|
).toBeInstanceOf(ReolinkCameraManagerEngine);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ import {
|
|||||||
retainResultSchema,
|
retainResultSchema,
|
||||||
} from '../../../src/camera-manager/frigate/types';
|
} from '../../../src/camera-manager/frigate/types';
|
||||||
import { RecordingSegment } from '../../../src/camera-manager/types';
|
import { RecordingSegment } from '../../../src/camera-manager/types';
|
||||||
import { homeAssistantWSRequest } from '../../../src/utils/ha';
|
import { homeAssistantWSRequest } from '../../../src/utils/ha/ws-request';
|
||||||
import { createFrigateEvent, createHASS } from '../../test-utils';
|
import { createFrigateEvent, createHASS } from '../../test-utils';
|
||||||
|
|
||||||
vi.mock('../../../src/utils/ha');
|
vi.mock('../../../src/utils/ha/ws-request');
|
||||||
|
|
||||||
describe('frigate requests', () => {
|
describe('frigate requests', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
import { CameraManagerEngine } from '../../../src/camera-manager/engine';
|
||||||
|
import { ReolinkCamera } from '../../../src/camera-manager/reolink/camera';
|
||||||
|
import { CameraProxyConfig } from '../../../src/camera-manager/types';
|
||||||
|
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
|
||||||
|
import { ProxyConfig } from '../../../src/config/types';
|
||||||
|
import { EntityRegistryManager } from '../../../src/utils/ha/registry/entity';
|
||||||
|
import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils';
|
||||||
|
|
||||||
|
describe('ReolinkCamera', () => {
|
||||||
|
describe('should initialize config', () => {
|
||||||
|
describe('should detect channel', () => {
|
||||||
|
it('without a camera_entity', async () => {
|
||||||
|
const config = createCameraConfig();
|
||||||
|
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||||
|
|
||||||
|
expect(
|
||||||
|
async () =>
|
||||||
|
await camera.initialize({
|
||||||
|
hass: createHASS(),
|
||||||
|
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||||
|
stateWatcher: mock<StateWatcher>(),
|
||||||
|
}),
|
||||||
|
).rejects.toThrowError('Could not find camera entity');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('without a unique_id', async () => {
|
||||||
|
const config = createCameraConfig({
|
||||||
|
camera_entity: 'camera.office_reolink',
|
||||||
|
});
|
||||||
|
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||||
|
|
||||||
|
const entityRegistryManager = mock<EntityRegistryManager>();
|
||||||
|
const entity = createRegistryEntity({
|
||||||
|
platform: 'reolink',
|
||||||
|
});
|
||||||
|
entityRegistryManager.getEntity.mockResolvedValue(entity);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
async () =>
|
||||||
|
await camera.initialize({
|
||||||
|
hass: createHASS(),
|
||||||
|
entityRegistryManager: entityRegistryManager,
|
||||||
|
stateWatcher: mock<StateWatcher>(),
|
||||||
|
}),
|
||||||
|
).rejects.toThrowError('Could not initialize Reolink camera');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('without a channel in the unique_id', async () => {
|
||||||
|
const config = createCameraConfig({
|
||||||
|
camera_entity: 'camera.office_reolink',
|
||||||
|
});
|
||||||
|
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||||
|
|
||||||
|
const entityRegistryManager = mock<EntityRegistryManager>();
|
||||||
|
const entity = createRegistryEntity({
|
||||||
|
unique_id: 'invalid',
|
||||||
|
platform: 'reolink',
|
||||||
|
});
|
||||||
|
entityRegistryManager.getEntity.mockResolvedValue(entity);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
async () =>
|
||||||
|
await camera.initialize({
|
||||||
|
hass: createHASS(),
|
||||||
|
entityRegistryManager: entityRegistryManager,
|
||||||
|
stateWatcher: mock<StateWatcher>(),
|
||||||
|
}),
|
||||||
|
).rejects.toThrowError('Could not initialize Reolink camera');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('successfully with main camera', async () => {
|
||||||
|
const config = createCameraConfig({
|
||||||
|
camera_entity: 'camera.office_reolink',
|
||||||
|
});
|
||||||
|
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||||
|
|
||||||
|
const entityRegistryManager = mock<EntityRegistryManager>();
|
||||||
|
const entity = createRegistryEntity({
|
||||||
|
unique_id: '85270002TS7D4RUP_0_main',
|
||||||
|
platform: 'reolink',
|
||||||
|
});
|
||||||
|
entityRegistryManager.getEntity.mockResolvedValue(entity);
|
||||||
|
|
||||||
|
await camera.initialize({
|
||||||
|
hass: createHASS(),
|
||||||
|
entityRegistryManager: entityRegistryManager,
|
||||||
|
stateWatcher: mock<StateWatcher>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(camera.getChannel()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should get proxy config', () => {
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
'when unspecified',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
dynamic: true,
|
||||||
|
media: true,
|
||||||
|
ssl_verification: false,
|
||||||
|
ssl_ciphers: 'intermediate' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'when media set to on',
|
||||||
|
{ media: true },
|
||||||
|
{
|
||||||
|
dynamic: true,
|
||||||
|
media: true,
|
||||||
|
ssl_verification: false,
|
||||||
|
ssl_ciphers: 'intermediate' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'when media set to off',
|
||||||
|
{ media: false },
|
||||||
|
{
|
||||||
|
dynamic: true,
|
||||||
|
media: false,
|
||||||
|
ssl_verification: false,
|
||||||
|
ssl_ciphers: 'intermediate' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'when media set to auto',
|
||||||
|
{ media: 'auto' as const },
|
||||||
|
{
|
||||||
|
dynamic: true,
|
||||||
|
media: true,
|
||||||
|
ssl_verification: false,
|
||||||
|
ssl_ciphers: 'intermediate' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'when ssl_verification is set to auto',
|
||||||
|
{ ssl_verification: 'auto' as const },
|
||||||
|
{
|
||||||
|
dynamic: true,
|
||||||
|
media: true,
|
||||||
|
ssl_verification: false,
|
||||||
|
ssl_ciphers: 'intermediate' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'when ssl_verification is set to true',
|
||||||
|
{ ssl_verification: true },
|
||||||
|
{
|
||||||
|
dynamic: true,
|
||||||
|
media: true,
|
||||||
|
ssl_verification: true,
|
||||||
|
ssl_ciphers: 'intermediate' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'when ssl_verification is set to false',
|
||||||
|
{ ssl_verification: false },
|
||||||
|
{
|
||||||
|
dynamic: true,
|
||||||
|
media: true,
|
||||||
|
ssl_verification: false,
|
||||||
|
ssl_ciphers: 'intermediate' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'when ssl_ciphers is set to auto',
|
||||||
|
{ ssl_ciphers: 'auto' as const },
|
||||||
|
{
|
||||||
|
dynamic: true,
|
||||||
|
media: true,
|
||||||
|
ssl_verification: false,
|
||||||
|
ssl_ciphers: 'intermediate' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'when ssl_ciphers is set to modern',
|
||||||
|
{ ssl_ciphers: 'modern' as const },
|
||||||
|
{
|
||||||
|
dynamic: true,
|
||||||
|
media: true,
|
||||||
|
ssl_verification: false,
|
||||||
|
ssl_ciphers: 'modern' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'when dynamic is set to false',
|
||||||
|
{ dynamic: false },
|
||||||
|
{
|
||||||
|
dynamic: false,
|
||||||
|
media: true,
|
||||||
|
ssl_verification: false,
|
||||||
|
ssl_ciphers: 'intermediate' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])(
|
||||||
|
'%s',
|
||||||
|
(
|
||||||
|
_name: string,
|
||||||
|
proxyConfig: Partial<ProxyConfig>,
|
||||||
|
expectedResult: CameraProxyConfig,
|
||||||
|
) => {
|
||||||
|
const camera = new ReolinkCamera(
|
||||||
|
createCameraConfig({
|
||||||
|
proxy: proxyConfig,
|
||||||
|
}),
|
||||||
|
mock<CameraManagerEngine>(),
|
||||||
|
);
|
||||||
|
expect(camera.getProxyConfig()).toEqual(expectedResult);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -38,10 +38,19 @@ describe('config defaults', () => {
|
|||||||
file_pattern: '%H-%M-%S',
|
file_pattern: '%H-%M-%S',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
proxy: {
|
||||||
|
dynamic: true,
|
||||||
|
media: 'auto',
|
||||||
|
ssl_verification: 'auto',
|
||||||
|
ssl_ciphers: 'auto',
|
||||||
|
},
|
||||||
ptz: {
|
ptz: {
|
||||||
c2r_delay_between_calls_seconds: 0.2,
|
c2r_delay_between_calls_seconds: 0.2,
|
||||||
r2c_delay_between_calls_seconds: 0.5,
|
r2c_delay_between_calls_seconds: 0.5,
|
||||||
},
|
},
|
||||||
|
reolink: {
|
||||||
|
media_resolution: 'low',
|
||||||
|
},
|
||||||
triggers: {
|
triggers: {
|
||||||
events: ['events', 'clips', 'snapshots'],
|
events: ['events', 'clips', 'snapshots'],
|
||||||
entities: [],
|
entities: [],
|
||||||
|
|||||||
+1
-1
@@ -147,7 +147,7 @@ export const createRegistryEntity = (entity?: Partial<Entity>): Entity => {
|
|||||||
hidden_by: entity?.hidden_by ?? null,
|
hidden_by: entity?.hidden_by ?? null,
|
||||||
platform: entity?.platform ?? 'platform',
|
platform: entity?.platform ?? 'platform',
|
||||||
translation_key: entity?.translation_key ?? null,
|
translation_key: entity?.translation_key ?? null,
|
||||||
unique_id: entity?.unique_id ?? 'unique_id',
|
...(entity?.unique_id && { unique_id: entity?.unique_id }),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import frigateSVG from '../../src/camera-manager/frigate/assets/frigate.svg';
|
import frigateSVG from '../../src/camera-manager/frigate/assets/frigate.svg';
|
||||||
import motioneyeSVG from '../../src/camera-manager/motioneye/assets/motioneye.svg';
|
import motioneyeSVG from '../../src/camera-manager/motioneye/assets/motioneye.svg';
|
||||||
|
import reolinkSVG from '../../src/camera-manager/reolink/assets/reolink.svg';
|
||||||
import { getCustomIconURL } from '../../src/utils/custom-icons';
|
import { getCustomIconURL } from '../../src/utils/custom-icons';
|
||||||
|
|
||||||
describe('getCustomIconURL', () => {
|
describe('getCustomIconURL', () => {
|
||||||
@@ -12,6 +13,10 @@ describe('getCustomIconURL', () => {
|
|||||||
expect(getCustomIconURL('motioneye')).toBe(motioneyeSVG);
|
expect(getCustomIconURL('motioneye')).toBe(motioneyeSVG);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return reolink SVG for reolink icon', () => {
|
||||||
|
expect(getCustomIconURL('reolink')).toBe(reolinkSVG);
|
||||||
|
});
|
||||||
|
|
||||||
it('should return null for mdi icon', () => {
|
it('should return null for mdi icon', () => {
|
||||||
expect(getCustomIconURL('mdi:car')).toBeNull();
|
expect(getCustomIconURL('mdi:car')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getLanguage } from '../../src/localize/localize';
|
|||||||
import { getDiagnostics, getReleaseVersion } from '../../src/utils/diagnostics.js';
|
import { getDiagnostics, getReleaseVersion } from '../../src/utils/diagnostics.js';
|
||||||
import { DeviceRegistryManager } from '../../src/utils/ha/registry/device';
|
import { DeviceRegistryManager } from '../../src/utils/ha/registry/device';
|
||||||
import { createHASS, createRegistryDevice } from '../test-utils';
|
import { createHASS, createRegistryDevice } from '../test-utils';
|
||||||
|
import { homeAssistantWSRequest } from '../../src/utils/ha/ws-request';
|
||||||
|
|
||||||
vi.mock('../../package.json', () => ({
|
vi.mock('../../package.json', () => ({
|
||||||
default: {
|
default: {
|
||||||
@@ -16,6 +17,7 @@ vi.mock('../../package.json', () => ({
|
|||||||
vi.mock('../../src/utils/ha');
|
vi.mock('../../src/utils/ha');
|
||||||
vi.mock('../../src/localize/localize.js');
|
vi.mock('../../src/localize/localize.js');
|
||||||
vi.mock('../../src/utils/ha/registry/device/index.js');
|
vi.mock('../../src/utils/ha/registry/device/index.js');
|
||||||
|
vi.mock('../../src/utils/ha/ws-request.js');
|
||||||
|
|
||||||
describe('getReleaseVersion', () => {
|
describe('getReleaseVersion', () => {
|
||||||
it('should get release version', () => {
|
it('should get release version', () => {
|
||||||
@@ -30,6 +32,8 @@ describe('getDiagnostics', () => {
|
|||||||
hass.config = { version: '2023.9.0' } as HassConfig;
|
hass.config = { version: '2023.9.0' } as HassConfig;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(now);
|
vi.setSystemTime(now);
|
||||||
|
|
||||||
@@ -62,6 +66,11 @@ describe('getDiagnostics', () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
vi.mocked(homeAssistantWSRequest).mockResolvedValue({
|
||||||
|
domain: 'domain',
|
||||||
|
version: '0.0.1',
|
||||||
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await getDiagnostics(hass, deviceRegistryManager, {
|
await getDiagnostics(hass, deviceRegistryManager, {
|
||||||
cameras: [{ camera_entity: 'camera.office' }],
|
cameras: [{ camera_entity: 'camera.office' }],
|
||||||
@@ -72,15 +81,33 @@ describe('getDiagnostics', () => {
|
|||||||
config: {
|
config: {
|
||||||
cameras: [{ camera_entity: 'camera.office' }],
|
cameras: [{ camera_entity: 'camera.office' }],
|
||||||
},
|
},
|
||||||
frigate_versions: {
|
|
||||||
ac4e79d258449a83bc0cf6d47a021c46: '4.0.0/0.13.0-aded314',
|
|
||||||
b03e70c659d58ae2ce7f2dc76fed2929: '4.0.0/0.13.0-aded314',
|
|
||||||
},
|
|
||||||
git: {
|
git: {
|
||||||
build_date: 'Tue, 19 Sep 2023 04:59:27 GMT',
|
build_date: 'Tue, 19 Sep 2023 04:59:27 GMT',
|
||||||
commit_date: 'Wed, 6 Sep 2023 21:27:28 -0700',
|
commit_date: 'Wed, 6 Sep 2023 21:27:28 -0700',
|
||||||
hash: 'g4cf13b1',
|
hash: 'g4cf13b1',
|
||||||
},
|
},
|
||||||
|
integrations: {
|
||||||
|
frigate: {
|
||||||
|
detected: true,
|
||||||
|
devices: {
|
||||||
|
ac4e79d258449a83bc0cf6d47a021c46: '4.0.0/0.13.0-aded314',
|
||||||
|
b03e70c659d58ae2ce7f2dc76fed2929: '4.0.0/0.13.0-aded314',
|
||||||
|
},
|
||||||
|
version: '0.0.1',
|
||||||
|
},
|
||||||
|
hass_web_proxy: {
|
||||||
|
detected: true,
|
||||||
|
version: '0.0.1',
|
||||||
|
},
|
||||||
|
motioneye: {
|
||||||
|
detected: true,
|
||||||
|
version: '0.0.1',
|
||||||
|
},
|
||||||
|
reolink: {
|
||||||
|
detected: true,
|
||||||
|
version: '0.0.1',
|
||||||
|
},
|
||||||
|
},
|
||||||
date: now,
|
date: now,
|
||||||
lang: 'en',
|
lang: 'en',
|
||||||
ha_version: '2023.9.0',
|
ha_version: '2023.9.0',
|
||||||
@@ -118,6 +145,20 @@ describe('getDiagnostics', () => {
|
|||||||
commit_date: 'Wed, 6 Sep 2023 21:27:28 -0700',
|
commit_date: 'Wed, 6 Sep 2023 21:27:28 -0700',
|
||||||
hash: 'g4cf13b1',
|
hash: 'g4cf13b1',
|
||||||
},
|
},
|
||||||
|
integrations: {
|
||||||
|
frigate: {
|
||||||
|
detected: false,
|
||||||
|
},
|
||||||
|
hass_web_proxy: {
|
||||||
|
detected: false,
|
||||||
|
},
|
||||||
|
motioneye: {
|
||||||
|
detected: false,
|
||||||
|
},
|
||||||
|
reolink: {
|
||||||
|
detected: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
date: now,
|
date: now,
|
||||||
lang: 'en',
|
lang: 'en',
|
||||||
timezone: expect.anything(),
|
timezone: expect.anything(),
|
||||||
@@ -136,6 +177,20 @@ describe('getDiagnostics', () => {
|
|||||||
commit_date: 'Wed, 6 Sep 2023 21:27:28 -0700',
|
commit_date: 'Wed, 6 Sep 2023 21:27:28 -0700',
|
||||||
hash: 'g4cf13b1',
|
hash: 'g4cf13b1',
|
||||||
},
|
},
|
||||||
|
integrations: {
|
||||||
|
frigate: {
|
||||||
|
detected: false,
|
||||||
|
},
|
||||||
|
hass_web_proxy: {
|
||||||
|
detected: false,
|
||||||
|
},
|
||||||
|
motioneye: {
|
||||||
|
detected: false,
|
||||||
|
},
|
||||||
|
reolink: {
|
||||||
|
detected: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
ha_version: '2023.9.0',
|
ha_version: '2023.9.0',
|
||||||
date: now,
|
date: now,
|
||||||
lang: 'en',
|
lang: 'en',
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
canonicalizeHAURL,
|
||||||
getEntityIcon,
|
getEntityIcon,
|
||||||
hasHAConnectionStateChanged,
|
hasHAConnectionStateChanged,
|
||||||
|
isHARelativeURL,
|
||||||
} from '../../../src/utils/ha/index.js';
|
} from '../../../src/utils/ha/index.js';
|
||||||
import { createHASS, createStateEntity } from '../../test-utils.js';
|
import { createHASS, createStateEntity } from '../../test-utils.js';
|
||||||
|
|
||||||
@@ -67,3 +69,32 @@ describe('getEntityIcon', () => {
|
|||||||
expect(getEntityIcon(createHASS(), 'camera.test')).toBe('mdi:video');
|
expect(getEntityIcon(createHASS(), 'camera.test')).toBe('mdi:video');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isHARelativeURL', () => {
|
||||||
|
it('should return true when URL is HA relative', () => {
|
||||||
|
expect(isHARelativeURL('/api/foo')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when URL is not HA relative', () => {
|
||||||
|
expect(isHARelativeURL('http://localhost/api/foo')).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('canonicalizeHAURL', () => {
|
||||||
|
it('should return canonicalized HA url', () => {
|
||||||
|
const hass = createHASS();
|
||||||
|
vi.mocked(hass.hassUrl).mockReturnValue('http://localhost:8123/foo/bar');
|
||||||
|
|
||||||
|
expect(canonicalizeHAURL(hass, '/api/foo')).toBe('http://localhost:8123/foo/bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return untouched URL when not HA relative', () => {
|
||||||
|
expect(canonicalizeHAURL(createHASS(), 'http://localhost:8123/foo/bar')).toBe(
|
||||||
|
'http://localhost:8123/foo/bar',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null without a URL', () => {
|
||||||
|
expect(canonicalizeHAURL(createHASS())).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getIntegrationManifest } from '../../../../src/utils/ha/integration';
|
||||||
|
import { integrationManifestSchema } from '../../../../src/utils/ha/integration/types';
|
||||||
|
import { homeAssistantWSRequest } from '../../../../src/utils/ha/ws-request';
|
||||||
|
import { createHASS } from '../../../test-utils';
|
||||||
|
|
||||||
|
vi.mock('../../../../src/utils/ha/ws-request');
|
||||||
|
|
||||||
|
describe('getIntegrationManifest', () => {
|
||||||
|
it('should get integration manifest', async () => {
|
||||||
|
const hass = createHASS();
|
||||||
|
await getIntegrationManifest(hass, 'INTEGRATION');
|
||||||
|
expect(homeAssistantWSRequest).toHaveBeenCalledWith(
|
||||||
|
hass,
|
||||||
|
integrationManifestSchema,
|
||||||
|
{ type: 'manifest/get', integration: 'INTEGRATION' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { homeAssistantWSRequest } from '../../../../../src/utils/ha';
|
|
||||||
import { createHASS, createRegistryDevice } from '../../../../test-utils.js';
|
|
||||||
import {
|
import {
|
||||||
createDeviceRegistryCache,
|
createDeviceRegistryCache,
|
||||||
DeviceRegistryManager,
|
DeviceRegistryManager,
|
||||||
} from '../../../../../src/utils/ha/registry/device';
|
} from '../../../../../src/utils/ha/registry/device';
|
||||||
|
import { homeAssistantWSRequest } from '../../../../../src/utils/ha/ws-request';
|
||||||
|
import { createHASS, createRegistryDevice } from '../../../../test-utils.js';
|
||||||
|
|
||||||
vi.mock('../../../../../src/utils/ha');
|
vi.mock('../../../../../src/utils/ha/ws-request');
|
||||||
vi.spyOn(global.console, 'warn').mockImplementation(() => true);
|
vi.spyOn(global.console, 'warn').mockImplementation(() => true);
|
||||||
|
|
||||||
describe('DeviceRegistryManager', () => {
|
describe('DeviceRegistryManager', () => {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { homeAssistantWSRequest } from '../../../../../src/utils/ha';
|
|
||||||
import {
|
import {
|
||||||
createEntityRegistryCache,
|
createEntityRegistryCache,
|
||||||
EntityRegistryManager,
|
EntityRegistryManager,
|
||||||
} from '../../../../../src/utils/ha/registry/entity';
|
} from '../../../../../src/utils/ha/registry/entity';
|
||||||
|
import { homeAssistantWSRequest } from '../../../../../src/utils/ha/ws-request';
|
||||||
import { createHASS, createRegistryEntity } from '../../../../test-utils.js';
|
import { createHASS, createRegistryEntity } from '../../../../test-utils.js';
|
||||||
|
|
||||||
vi.mock('../../../../../src/utils/ha');
|
vi.mock('../../../../../src/utils/ha/ws-request');
|
||||||
vi.spyOn(global.console, 'warn').mockImplementation(() => true);
|
vi.spyOn(global.console, 'warn').mockImplementation(() => true);
|
||||||
|
|
||||||
describe('EntityRegistryManager', () => {
|
describe('EntityRegistryManager', () => {
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CameraProxyConfig } from '../../../src/camera-manager/types.js';
|
||||||
|
import {
|
||||||
|
addDynamicProxyURL,
|
||||||
|
getWebProxiedURL,
|
||||||
|
shouldUseWebProxy,
|
||||||
|
} from '../../../src/utils/ha/web-proxy.js';
|
||||||
|
import { createHASS } from '../../test-utils.js';
|
||||||
|
|
||||||
|
describe('getWebProxiedURL', () => {
|
||||||
|
it('should return proxied URL with v != 0', () => {
|
||||||
|
expect(getWebProxiedURL('http://example.com', 2)).toBe(
|
||||||
|
'/api/hass_web_proxy/v2/?url=http%3A%2F%2Fexample.com',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return proxied URL with default v', () => {
|
||||||
|
expect(getWebProxiedURL('http://example.com')).toBe(
|
||||||
|
'/api/hass_web_proxy/v0/?url=http%3A%2F%2Fexample.com',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shouldUseWebProxy', () => {
|
||||||
|
const createProxyConfig = (
|
||||||
|
config: Partial<CameraProxyConfig> = {},
|
||||||
|
): CameraProxyConfig => ({
|
||||||
|
media: true,
|
||||||
|
ssl_verification: true,
|
||||||
|
ssl_ciphers: 'default',
|
||||||
|
dynamic: true,
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false without a the proxy installed', () => {
|
||||||
|
const hass = createHASS();
|
||||||
|
hass.config.components = [];
|
||||||
|
|
||||||
|
expect(shouldUseWebProxy(hass, createProxyConfig())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return when proxy config does not want proxying', () => {
|
||||||
|
const hass = createHASS();
|
||||||
|
hass.config.components = ['hass_web_proxy'];
|
||||||
|
|
||||||
|
const proxyConfig = createProxyConfig({ media: false });
|
||||||
|
expect(shouldUseWebProxy(hass, proxyConfig, 'media')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return when proxy config does want proxying', () => {
|
||||||
|
const hass = createHASS();
|
||||||
|
hass.config.components = ['hass_web_proxy'];
|
||||||
|
|
||||||
|
const proxyConfig = createProxyConfig({ media: true });
|
||||||
|
expect(shouldUseWebProxy(hass, proxyConfig, 'media')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addDynamicProxyURL', () => {
|
||||||
|
it('should add dynamic proxy URL', async () => {
|
||||||
|
const hass = createHASS();
|
||||||
|
|
||||||
|
await addDynamicProxyURL(hass, 'http://example.com', {
|
||||||
|
urlID: 'id',
|
||||||
|
sslVerification: true,
|
||||||
|
sslCiphers: 'modern',
|
||||||
|
openLimit: 5,
|
||||||
|
ttl: 60,
|
||||||
|
allowUnauthenticated: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hass.callService).toHaveBeenCalledWith(
|
||||||
|
'hass_web_proxy',
|
||||||
|
'create_proxied_url',
|
||||||
|
{
|
||||||
|
url_pattern: 'http://example.com',
|
||||||
|
url_id: 'id',
|
||||||
|
ssl_verification: true,
|
||||||
|
ssl_ciphers: 'modern',
|
||||||
|
open_limit: 5,
|
||||||
|
ttl: 60,
|
||||||
|
allow_unauthenticated: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,10 +4,12 @@ import { defineConfig } from 'vitest/config';
|
|||||||
const FULL_COVERAGE_FILES_RELATIVE = [
|
const FULL_COVERAGE_FILES_RELATIVE = [
|
||||||
'camera-manager/*.ts',
|
'camera-manager/*.ts',
|
||||||
'camera-manager/browse-media/camera.ts',
|
'camera-manager/browse-media/camera.ts',
|
||||||
|
'camera-manager/browse-media/utils/*.ts',
|
||||||
'camera-manager/frigate/camera.ts',
|
'camera-manager/frigate/camera.ts',
|
||||||
'camera-manager/frigate/requests.ts',
|
'camera-manager/frigate/requests.ts',
|
||||||
'camera-manager/frigate/util.ts',
|
'camera-manager/frigate/util.ts',
|
||||||
'camera-manager/generic/*.ts',
|
'camera-manager/generic/*.ts',
|
||||||
|
'camera-manager/reolink/*.ts',
|
||||||
'camera-manager/utils/*.ts',
|
'camera-manager/utils/*.ts',
|
||||||
'card-controller/**/*.ts',
|
'card-controller/**/*.ts',
|
||||||
'components-lib/**/!(timeline-source.ts)',
|
'components-lib/**/!(timeline-source.ts)',
|
||||||
@@ -24,6 +26,7 @@ const FULL_COVERAGE_FILES_RELATIVE = [
|
|||||||
'utils/download.ts',
|
'utils/download.ts',
|
||||||
'utils/embla/**/*.ts',
|
'utils/embla/**/*.ts',
|
||||||
'utils/endpoint.ts',
|
'utils/endpoint.ts',
|
||||||
|
'utils/ha/integration/**/*.ts',
|
||||||
'utils/ha/registry/**/*.ts',
|
'utils/ha/registry/**/*.ts',
|
||||||
'utils/ha/types.ts',
|
'utils/ha/types.ts',
|
||||||
'utils/initializer.ts',
|
'utils/initializer.ts',
|
||||||
|
|||||||
Reference in New Issue
Block a user