feat: Add experimental reolink media support (#1694)
* feat: Add experimental rich reolink support. * Formatting fix
This commit is contained in:
@@ -28,6 +28,7 @@ cameras_global:
|
||||
| `icon` | Autodetected from `camera_entity` if that is specified. | The icon to use for this camera in the camera menu and in the next & previous controls when using the `icon` style. |
|
||||
| `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). |
|
||||
| `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. |
|
||||
| `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. |
|
||||
@@ -239,6 +240,50 @@ cameras:
|
||||
|
||||
`[action]` is any [perform-action](../actions/stock/README.md?id=perform-action) action.
|
||||
|
||||
## `proxy`
|
||||
|
||||
Configures whether and how the content is proxied via
|
||||
[hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration)
|
||||
(this must be installed separately). This allows fetching media **through** the
|
||||
Home Assistant process itself, allowing the card to access resources it
|
||||
otherwise would not be able to directly access. There are [security and
|
||||
performance
|
||||
implications](https://github.com/dermotduffy/hass-web-proxy-integration?tab=readme-ov-file#considerations)
|
||||
to consider before installing
|
||||
[hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration)
|
||||
and using this functionality.
|
||||
|
||||
```yaml
|
||||
cameras:
|
||||
- camera_entity: camera.office
|
||||
proxy:
|
||||
# [...]
|
||||
```
|
||||
|
||||

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