feat: Add experimental reolink media support (#1694)

* feat: Add experimental rich reolink support.

* Formatting fix
This commit is contained in:
Dermot Duffy
2024-11-25 20:19:12 -08:00
committed by GitHub
parent e898b73d63
commit dada65e008
63 changed files with 3402 additions and 483 deletions
+55 -77
View File
@@ -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:
# [...]
```
![Camera Proxying](../../images/proxy.png 'Camera Proxying :size=400')
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
``` ```
+59 -6
View File
@@ -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.
+53
View File
@@ -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

+1
View File
@@ -9,3 +9,4 @@ builtin custom items not found in the traditional [Material Design Icons
| ----------- | --------------------------------------------------------------------------- | | ----------- | --------------------------------------------------------------------------- |
| `frigate` | ![Frigate Icon](../images/icons/frigate.svg ':size=24 :class=svg-icon') | | `frigate` | ![Frigate Icon](../images/icons/frigate.svg ':size=24 :class=svg-icon') |
| `motioneye` | ![motionEye Icon](../images/icons/motioneye.svg ':size=24 :class=svg-icon') | | `motioneye` | ![motionEye Icon](../images/icons/motioneye.svg ':size=24 :class=svg-icon') |
| `reolink` | ![Reolink Icon](../images/icons/reolink.svg ':size=24 :class=svg-icon') |
+2
View File
@@ -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"
+13 -8
View File
@@ -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>,
+2 -2
View File
@@ -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;
};
+13 -1
View File
@@ -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(),
+18 -3
View File
@@ -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 -1
View File
@@ -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,
+13
View File
@@ -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

+64
View File
@@ -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 }),
};
}
}
+12
View File
@@ -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>[];
}
+9 -1
View File
@@ -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;
} }
+13 -17
View File
@@ -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
View File
@@ -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;
} }
} }
+93 -30
View File
@@ -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) {
+1
View File
@@ -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
View File
@@ -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>;
+10
View File
@@ -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
View File
@@ -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>
+35 -1
View File
@@ -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",
+1
View File
@@ -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);
} }
+3
View File
@@ -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;
} }
+49 -5
View File
@@ -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
View File
@@ -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;
+13
View File
@@ -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,
});
};
+9
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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';
+54
View File
@@ -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,
}),
});
}
+53
View File
@@ -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);
});
});
+122
View File
@@ -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);
},
);
});
}); });
+38 -1
View File
@@ -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(() => {
+216
View File
@@ -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
+9
View File
@@ -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
View File
@@ -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 }),
}; };
}; };
+5
View File
@@ -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();
}); });
+59 -4
View File
@@ -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',
+32 -1
View File
@@ -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();
});
});
+19
View File
@@ -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' },
);
});
});
+3 -3
View File
@@ -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', () => {
+2 -2
View File
@@ -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', () => {
+86
View File
@@ -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,
},
);
});
});
+3
View File
@@ -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',