From dada65e008bcf0012576ac5009d46a744d44d365 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 25 Nov 2024 20:19:12 -0800 Subject: [PATCH] feat: Add experimental reolink media support (#1694) * feat: Add experimental rich reolink support. * Formatting fix --- docs/configuration/cameras/README.md | 132 +-- docs/configuration/cameras/engine.md | 65 +- docs/configuration/cameras/live-provider.md | 53 + .../common/experimental-warning.md | 1 + docs/images/icons/reolink.svg | 53 + docs/images/proxy.png | Bin 0 -> 91534 bytes docs/usage/custom-icons.md | 1 + scripts/docs-update-icons.sh | 2 + scripts/docs-update-images.sh | 21 +- .../browse-media/engine-browse-media.ts | 128 +- src/camera-manager/browse-media/media.ts | 4 +- .../utils/browse-media-to-view-media.ts | 49 + .../browse-media/utils/within-dates.ts | 50 + src/camera-manager/camera.ts | 14 +- src/camera-manager/engine-factory.ts | 21 +- src/camera-manager/frigate/requests.ts | 2 +- src/camera-manager/motioneye/camera.ts | 13 + .../motioneye/engine-motioneye.ts | 58 +- src/camera-manager/reolink/assets/reolink.svg | 53 + src/camera-manager/reolink/camera.ts | 64 + src/camera-manager/reolink/engine-reolink.ts | 381 ++++++ src/camera-manager/reolink/types.ts | 12 + src/camera-manager/types.ts | 10 +- src/components/next-prev-control.ts | 30 +- src/components/thumbnail.ts | 73 +- src/components/viewer/provider.ts | 123 +- src/components/views.ts | 1 + src/config/types.ts | 38 +- src/const.ts | 10 + src/editor.ts | 218 +++- src/localize/languages/en.json | 36 +- src/scss/thumbnail-details.scss | 1 + ...rding.scss => thumbnail-feature-text.scss} | 44 +- ....scss => thumbnail-feature-thumbnail.scss} | 0 src/utils/custom-icons.ts | 3 + src/utils/diagnostics.ts | 54 +- .../ha/browse-media/browse-media-manager.ts | 104 +- src/utils/ha/index.ts | 66 +- src/utils/ha/integration/index.ts | 13 + src/utils/ha/integration/types.ts | 9 + src/utils/ha/registry/device/index.ts | 2 +- src/utils/ha/registry/entity/index.ts | 2 +- src/utils/ha/resolved-media.ts | 2 +- src/utils/ha/web-proxy.ts | 54 + src/utils/ha/ws-request.ts | 53 + .../utils/browse-media-to-view-media.test.ts | 55 + .../browse-media/utils/test-utils.ts | 23 + .../browse-media/utils/within-dates.test.ts | 40 + tests/camera-manager/camera.test.ts | 122 ++ tests/camera-manager/engine-factory.test.ts | 39 +- tests/camera-manager/frigate/requests.test.ts | 4 +- tests/camera-manager/reolink/camera.test.ts | 216 ++++ .../reolink/engine-reolink.test.ts | 1033 +++++++++++++++++ tests/config/types.test.ts | 9 + tests/test-utils.ts | 2 +- tests/utils/custom-icons.test.ts | 5 + tests/utils/diagnostics.test.ts | 63 +- tests/utils/ha/index.test.ts | 33 +- tests/utils/ha/integration/index.test.ts | 19 + tests/utils/ha/registry/device/index.test.ts | 6 +- tests/utils/ha/registry/entity/index.test.ts | 4 +- tests/utils/ha/web-proxy.test.ts | 86 ++ vite.config.ts | 3 + 63 files changed, 3402 insertions(+), 483 deletions(-) create mode 100644 docs/configuration/common/experimental-warning.md create mode 100644 docs/images/icons/reolink.svg create mode 100644 docs/images/proxy.png create mode 100644 src/camera-manager/browse-media/utils/browse-media-to-view-media.ts create mode 100644 src/camera-manager/browse-media/utils/within-dates.ts create mode 100644 src/camera-manager/motioneye/camera.ts create mode 100644 src/camera-manager/reolink/assets/reolink.svg create mode 100644 src/camera-manager/reolink/camera.ts create mode 100644 src/camera-manager/reolink/engine-reolink.ts create mode 100644 src/camera-manager/reolink/types.ts rename src/scss/{thumbnail-feature-recording.scss => thumbnail-feature-text.scss} (65%) rename src/scss/{thumbnail-feature-event.scss => thumbnail-feature-thumbnail.scss} (100%) create mode 100644 src/utils/ha/integration/index.ts create mode 100644 src/utils/ha/integration/types.ts create mode 100644 src/utils/ha/web-proxy.ts create mode 100644 src/utils/ha/ws-request.ts create mode 100644 tests/camera-manager/browse-media/utils/browse-media-to-view-media.test.ts create mode 100644 tests/camera-manager/browse-media/utils/test-utils.ts create mode 100644 tests/camera-manager/browse-media/utils/within-dates.test.ts create mode 100644 tests/camera-manager/reolink/camera.test.ts create mode 100644 tests/camera-manager/reolink/engine-reolink.test.ts create mode 100644 tests/utils/ha/integration/index.test.ts create mode 100644 tests/utils/ha/web-proxy.test.ts diff --git a/docs/configuration/cameras/README.md b/docs/configuration/cameras/README.md index 19b2f83a..d42f1b55 100644 --- a/docs/configuration/cameras/README.md +++ b/docs/configuration/cameras/README.md @@ -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: + # [...] +``` + +![Camera Proxying](../../images/proxy.png 'Camera Proxying :size=400') + +Not all [engines](./engine.md) benefit from proxying: + +| Engine | Purpose of proxying | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `frigate` | The Frigate integration already comes with a built-in proxy, so this functionality does not serve any purpose for `frigate`. | +| `reolink`, `motioneEye` | May be used to fetch videos in cases where the browser may not be able to access the camera/NVR, or the camera/NVR may use a self-signed SSL certificate that your browser would otherwise reject due to [mixed content](https://developer.mozilla.org/en-US/docs/Web/Security/Mixed_content). | +| `generic` | `generic` cameras do not have media, so proxying currently would serve no purpose. | + +Regardless of the parameters, the integration will never attempt to proxy +content if the +[hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration) +is not detected. + +Proxying parameters: + +| Option | Default | Description | +| ------------------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `media` | `auto` | Whether or not to proxy media items. `true` to proxy, `false` to not proxy, or `auto` to allow the camera engine to decide whether to proxy or not. | +| `dynamic` | `true` | Whether to dynamically (at the time) request proxying of the required media item, or rely on statically user-configured pre-existing proxying. See the [hass-web-proxy-integration documentation](https://github.com/dermotduffy/hass-web-proxy-integration). | +| `ssl_verification` | `auto` | Whether to verify the validity of SSL certificates. If `true` always verifies, if `false` never verifies and if `auto` the [engine](./engine.md) decides the best setting for that camera ecosystem. | +| `ssl_ciphers` | `auto` | Whether to use `default`, `intermediate`, `insecure` or `modern` SSL ciphers. See the [Home Assistant code](https://github.com/home-assistant/core/blob/dev/homeassistant/util/ssl.py) for the precise list of SSL ciphers each implies. If `auto` the [engine](./engine.md) decides the best setting for that camera ecosystem. | + ## `triggers` 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 ``` diff --git a/docs/configuration/cameras/engine.md b/docs/configuration/cameras/engine.md index dcf80892..a90cef2a 100644 --- a/docs/configuration/cameras/engine.md +++ b/docs/configuration/cameras/engine.md @@ -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 +``` diff --git a/docs/configuration/cameras/live-provider.md b/docs/configuration/cameras/live-provider.md index 2b44420f..6fc7cff7 100644 --- a/docs/configuration/cameras/live-provider.md +++ b/docs/configuration/cameras/live-provider.md @@ -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 +``` diff --git a/docs/configuration/common/experimental-warning.md b/docs/configuration/common/experimental-warning.md new file mode 100644 index 00000000..8afcc40a --- /dev/null +++ b/docs/configuration/common/experimental-warning.md @@ -0,0 +1 @@ +!> This functionality is experimental. It may be broken, slow or change without warning. diff --git a/docs/images/icons/reolink.svg b/docs/images/icons/reolink.svg new file mode 100644 index 00000000..ff9abe6c --- /dev/null +++ b/docs/images/icons/reolink.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + diff --git a/docs/images/proxy.png b/docs/images/proxy.png new file mode 100644 index 0000000000000000000000000000000000000000..6f16a626731a2d3b09692a919268127d85f7f3a7 GIT binary patch literal 91534 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 literal 0 HcmV?d00001 diff --git a/docs/usage/custom-icons.md b/docs/usage/custom-icons.md index fbec7f71..558380dc 100644 --- a/docs/usage/custom-icons.md +++ b/docs/usage/custom-icons.md @@ -9,3 +9,4 @@ builtin custom items not found in the traditional [Material Design Icons | ----------- | --------------------------------------------------------------------------- | | `frigate` | ![Frigate Icon](../images/icons/frigate.svg ':size=24 :class=svg-icon') | | `motioneye` | ![motionEye Icon](../images/icons/motioneye.svg ':size=24 :class=svg-icon') | +| `reolink` | ![Reolink Icon](../images/icons/reolink.svg ':size=24 :class=svg-icon') | diff --git a/scripts/docs-update-icons.sh b/scripts/docs-update-icons.sh index 68c8ec95..32dc1399 100755 --- a/scripts/docs-update-icons.sh +++ b/scripts/docs-update-icons.sh @@ -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" diff --git a/scripts/docs-update-images.sh b/scripts/docs-update-images.sh index 5f35fbde..06e4c4ac 100755 --- a/scripts/docs-update-images.sh +++ b/scripts/docs-update-images.sh @@ -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" diff --git a/src/camera-manager/browse-media/engine-browse-media.ts b/src/camera-manager/browse-media/engine-browse-media.ts index da1a7fe4..e8ad9aa7 100644 --- a/src/camera-manager/browse-media/engine-browse-media.ts +++ b/src/camera-manager/browse-media/engine-browse-media.ts @@ -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, - 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[], -): ViewMedia[] | null => { - const lookup: Map = 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 { - 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, diff --git a/src/camera-manager/browse-media/media.ts b/src/camera-manager/browse-media/media.ts index 16965357..5deb4842 100644 --- a/src/camera-manager/browse-media/media.ts +++ b/src/camera-manager/browse-media/media.ts @@ -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, cameraID: string, - ): BrowseMediaEventViewMedia | null { + ): BrowseMediaEventViewMedia { return new BrowseMediaEventViewMedia(mediaType, cameraID, browseMedia); } } diff --git a/src/camera-manager/browse-media/utils/browse-media-to-view-media.ts b/src/camera-manager/browse-media/utils/browse-media-to-view-media.ts new file mode 100644 index 00000000..3b33f638 --- /dev/null +++ b/src/camera-manager/browse-media/utils/browse-media-to-view-media.ts @@ -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[], +): ViewMedia[] | null => { + const lookup: Map = 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()]; +}; diff --git a/src/camera-manager/browse-media/utils/within-dates.ts b/src/camera-manager/browse-media/utils/within-dates.ts new file mode 100644 index 00000000..ed31950b --- /dev/null +++ b/src/camera-manager/browse-media/utils/within-dates.ts @@ -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, + 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; +}; diff --git a/src/camera-manager/camera.ts b/src/camera-manager/camera.ts index c22b731a..b93c84d7 100644 --- a/src/camera-manager/camera.ts +++ b/src/camera-manager/camera.ts @@ -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(), diff --git a/src/camera-manager/engine-factory.ts b/src/camera-manager/engine-factory.ts index 47438c75..d34830b7 100644 --- a/src/camera-manager/engine-factory.ts +++ b/src/camera-manager/engine-factory.ts @@ -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()), + 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; } diff --git a/src/camera-manager/frigate/requests.ts b/src/camera-manager/frigate/requests.ts index c378c5e4..57703d98 100644 --- a/src/camera-manager/frigate/requests.ts +++ b/src/camera-manager/frigate/requests.ts @@ -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, diff --git a/src/camera-manager/motioneye/camera.ts b/src/camera-manager/motioneye/camera.ts new file mode 100644 index 00000000..1b120554 --- /dev/null +++ b/src/camera-manager/motioneye/camera.ts @@ -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, + }; + } +} diff --git a/src/camera-manager/motioneye/engine-motioneye.ts b/src/camera-manager/motioneye/engine-motioneye.ts index 49af14b2..1d3eae28 100644 --- a/src/camera-manager/motioneye/engine-motioneye.ts +++ b/src/camera-manager/motioneye/engine-motioneye.ts @@ -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 = { const MOTIONEYE_REPL_REGEXP = new RegExp(/(%Y|%m|%d|%H|%M|%S)/g); export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine { + protected _directoryCache = new MemoryRequestCache(); + protected _fileCache = new MemoryRequestCache(); + public getEngineType(): Engine { return Engine.MotionEye; } + public async createCamera( + hass: HomeAssistant, + cameraConfig: CameraConfig, + ): Promise { + 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) => !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. diff --git a/src/camera-manager/reolink/assets/reolink.svg b/src/camera-manager/reolink/assets/reolink.svg new file mode 100644 index 00000000..ff9abe6c --- /dev/null +++ b/src/camera-manager/reolink/assets/reolink.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + diff --git a/src/camera-manager/reolink/camera.ts b/src/camera-manager/reolink/camera.ts new file mode 100644 index 00000000..09cc301e --- /dev/null +++ b/src/camera-manager/reolink/camera.ts @@ -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 { + await super.initialize(options); + this._initializeChannel(); + return this; + } + + protected _initializeChannel(): void { + const uniqueID = this._entity?.unique_id; + const match = uniqueID ? String(uniqueID).match(/(.*)_(?\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, + }; + } +} diff --git a/src/camera-manager/reolink/engine-reolink.ts b/src/camera-manager/reolink/engine-reolink.ts new file mode 100644 index 00000000..e209748b --- /dev/null +++ b/src/camera-manager/reolink/engine-reolink.ts @@ -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(); + protected _fileCache = new MemoryRequestCache(); + + public getEngineType(): Engine { + return Engine.Reolink; + } + + protected _reolinkFileMetadataGenerator( + cameraID: string, + media: BrowseMedia, + parent?: RichBrowseMedia, + ): 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( + /(?\d+):(?\d+):(?\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 { + 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[] | 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, + ) => this._reolinkDirectoryMetadataGenerator(camera.getID(), media), + matcher: (media: RichBrowseMedia) => + media.can_expand && + isMediaWithinDates(media, matchOptions?.start, matchOptions?.end), + sorter: (media: RichBrowseMedia[]) => + sortMediaByStartDate(media), + }, + ], + { + ...(engineOptions?.useCache !== false && { cache: this._directoryCache }), + }, + ); + } + + public async getEvents( + hass: HomeAssistant, + store: CameraManagerReadOnlyConfigStore, + query: EventQuery, + engineOptions?: EngineOptions, + ): Promise { + // 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 => { + 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[] = []; + + if (directories?.length) { + media = await this._browseMediaManager.walkBrowseMedias( + hass, + [ + { + targets: directories, + concurrency: 1, + metadataGenerator: ( + media: BrowseMedia, + parent?: RichBrowseMedia, + ) => this._reolinkFileMetadataGenerator(cameraID, media, parent), + earlyExit: (media) => media.length >= limit, + matcher: (media: RichBrowseMedia) => + !media.can_expand && + isMediaWithinDates(media, perCameraQuery.start, perCameraQuery.end), + sorter: (media: RichBrowseMedia[]) => + sortMediaByStartDate(media), + }, + ], + { + ...(engineOptions?.useCache !== false && { cache: this._fileCache }), + }, + ); + } + + // Sort by most recent then slice at the query limit. + const sortedMedia = orderBy( + media, + (media: RichBrowseMedia) => 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, + ): ViewMedia[] | null { + if (!ReolinkQueryResultsClassifier.isReolinkEventQueryResults(results)) { + return null; + } + return getViewMediaFromBrowseMediaArray(results.browseMedia); + } + + public async getMediaMetadata( + hass: HomeAssistant, + store: CameraManagerReadOnlyConfigStore, + query: MediaMetadataQuery, + engineOptions?: EngineOptions, + ): Promise { + 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 = new Set(); + const getDaysForCamera = async (cameraID: string): Promise => { + 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 }), + }; + } +} diff --git a/src/camera-manager/reolink/types.ts b/src/camera-manager/reolink/types.ts new file mode 100644 index 00000000..c4e732d7 --- /dev/null +++ b/src/camera-manager/reolink/types.ts @@ -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[]; +} diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts index 425749ec..c80def31 100644 --- a/src/camera-manager/types.ts +++ b/src/camera-manager/types.ts @@ -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; } diff --git a/src/components/next-prev-control.ts b/src/components/next-prev-control.ts index 8d989f2a..d3f7d08c 100644 --- a/src/components/next-prev-control.ts +++ b/src/components/next-prev-control.ts @@ -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` `; } - if (!this.thumbnail) { - return html``; - } - return renderTask( this, this._embedThumbnailTask, diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index 6a34dce4..a24c1ec8 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -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` `; + 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` -
${format(this.date, 'HH:mm')}
-
${format(this.date, 'MMM do')}
- ${this.cameraTitle ? html`
${this.cameraTitle}
` : html``} + ${this.cameraMetadata?.engineLogo + ? html`` + : ''} +
+
${format(this.date, 'HH:mm')}
+
${format(this.date, 'MMM do')}
+ ${this.showCameraTitle && this.cameraMetadata?.title + ? html`
${this.cameraMetadata.title}
` + : html``} +
`; } 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`` - : ViewMediaClassifier.isRecording(this.media) - ? html`` + : ViewMediaClassifier.isEvent(this.media) || + ViewMediaClassifier.isRecording(this.media) + ? html`` + >` : html``} ${shouldShowFavoriteControl ? html` ` : this.details && ViewMediaClassifier.isRecording(this.media) ? html`` : 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; } } diff --git a/src/components/viewer/provider.ts b/src/components/viewer/provider.ts index f3e81903..b706ea3a 100644 --- a/src/components/viewer/provider.ts +++ b/src/components/viewer/provider.ts @@ -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 = createRef(); protected _refImageProvider: Ref = createRef(); + @state() + protected _url: string | null = null; + public async play(): Promise { 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 { + 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)} > - + ` : html` { if (this.viewerConfig?.snapshot_click_plays_clip) { diff --git a/src/components/views.ts b/src/components/views.ts index 1d43a3d7..09f18ace 100644 --- a/src/components/views.ts +++ b/src/components/views.ts @@ -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. diff --git a/src/config/types.ts b/src/config/types.ts index 16e1cef2..b2f8c416 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -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; + 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; diff --git a/src/const.ts b/src/const.ts index cf504b42..f25e45ec 100644 --- a/src/const.ts +++ b/src/const.ts @@ -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 = diff --git a/src/editor.ts b/src/editor.ts index a29e7968..4605c7c8 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -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'), + }, + )} + `, + )} ` : ``} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 1e190523..789dcc7f 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -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", diff --git a/src/scss/thumbnail-details.scss b/src/scss/thumbnail-details.scss index fe16c652..99b6a066 100644 --- a/src/scss/thumbnail-details.scss +++ b/src/scss/thumbnail-details.scss @@ -17,6 +17,7 @@ div.title { div.details { flex: 1; + justify-content: space-evenly; display: flex; flex-direction: column; diff --git a/src/scss/thumbnail-feature-recording.scss b/src/scss/thumbnail-feature-text.scss similarity index 65% rename from src/scss/thumbnail-feature-recording.scss rename to src/scss/thumbnail-feature-text.scss index 1ba8675d..e0c71ce4 100644 --- a/src/scss/thumbnail-feature-recording.scss +++ b/src/scss/thumbnail-feature-text.scss @@ -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); } diff --git a/src/scss/thumbnail-feature-event.scss b/src/scss/thumbnail-feature-thumbnail.scss similarity index 100% rename from src/scss/thumbnail-feature-event.scss rename to src/scss/thumbnail-feature-thumbnail.scss diff --git a/src/utils/custom-icons.ts b/src/utils/custom-icons.ts index 92514686..2054dc89 100644 --- a/src/utils/custom-icons.ts +++ b/src/utils/custom-icons.ts @@ -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; } diff --git a/src/utils/diagnostics.ts b/src/utils/diagnostics.ts index 9c1defa8..b8483a8b 100644 --- a/src/utils/diagnostics.ts +++ b/src/utils/diagnostics.ts @@ -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; +type FrigateDevices = Record; 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 => { + 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 }), }; }; diff --git a/src/utils/ha/browse-media/browse-media-manager.ts b/src/utils/ha/browse-media/browse-media-manager.ts index 86a5dc1c..e5f1ce4a 100644 --- a/src/utils/ha/browse-media/browse-media-manager.ts +++ b/src/utils/ha/browse-media/browse-media-manager.ts @@ -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 = ( export type BrowseMediaTarget = string | RichBrowseMedia; type RichBrowseMediaPredicate = (media: RichBrowseMedia) => boolean; +export const sortMediaByStartDate = ( + media: RichBrowseMedia[], +): RichBrowseMedia[] => { + return orderBy(media, (media) => media._metadata?.startDate, 'desc'); +}; + export interface BrowseMediaStep { // The targets to start the media walk from. targets: BrowseMediaTarget[]; + // 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; @@ -31,6 +43,12 @@ export interface BrowseMediaStep { // output. matcher: RichBrowseMediaPredicate; + // Children (once past the matcher) will be sorted before the next step. + sorter?: (media: RichBrowseMedia[]) => RichBrowseMedia[]; + + // Whether to exit the walk early with the given output. + earlyExit?: (media: RichBrowseMedia[]) => 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; @@ -39,23 +57,18 @@ export interface BrowseMediaStep { type BrowseMediaStepAdvancer = (media: RichBrowseMedia[]) => BrowseMediaStep[]; export class BrowseMediaManager { - protected _cache: BrowseMediaCache; - - constructor(cache: BrowseMediaCache) { - this._cache = cache; - } - // Walk down a browse media tree according to instructions included in `steps`. public async walkBrowseMedias( hass: HomeAssistant, steps: BrowseMediaStep[] | null, options?: { - useCache?: boolean; + cache?: BrowseMediaCache; }, ): Promise[]> { if (!steps || !steps.length) { return []; } + return ( await allPromises( steps, @@ -68,60 +81,57 @@ export class BrowseMediaManager { hass: HomeAssistant, step: BrowseMediaStep, options?: { - useCache?: boolean; + cache?: BrowseMediaCache; }, ): Promise[]> { - const media = await allPromises( - step.targets, - async (target) => - await this._browseMedia(hass, target, { - useCache: options?.useCache, - metadataGenerator: step.metadataGenerator, - }), - ); + let output: RichBrowseMedia[] = []; - const newTargets: RichBrowseMedia[] = []; - 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[] = []; - - // 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, options?: { - useCache?: boolean; + cache?: BrowseMediaCache; + matcher?: RichBrowseMediaPredicate; metadataGenerator?: RichMetadataGenerator; }, ): Promise> { 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 { type: 'media_source/browse_media', media_content_id: mediaContentID, }; - const browseMedia = (await homeAssistantWSRequest( + const browseMedia = await homeAssistantWSRequest>( hass, browseMediaSchema, request, - )) as RichBrowseMedia; + ); if (options?.metadataGenerator) { for (const child of browseMedia.children ?? []) { @@ -146,8 +156,8 @@ export class BrowseMediaManager { } } - if (options?.useCache ?? true) { - this._cache.set( + if (options?.cache) { + options.cache.set( mediaContentID, browseMedia, add(new Date(), { seconds: BROWSE_MEDIA_CACHE_SECONDS }), diff --git a/src/utils/ha/index.ts b/src/utils/ha/index.ts index bb505f29..7079f585 100644 --- a/src/utils/ha/index.ts +++ b/src/utils/ha/index.ts @@ -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( - hass: HomeAssistant, - schema: ZodSchema, - request: MessageBase, - passthrough = false, -): Promise { - let response; - try { - response = await hass.callWS(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(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; diff --git a/src/utils/ha/integration/index.ts b/src/utils/ha/integration/index.ts new file mode 100644 index 00000000..084d2a1c --- /dev/null +++ b/src/utils/ha/integration/index.ts @@ -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 => { + return await homeAssistantWSRequest(hass, integrationManifestSchema, { + type: 'manifest/get', + integration: integration, + }); +}; diff --git a/src/utils/ha/integration/types.ts b/src/utils/ha/integration/types.ts new file mode 100644 index 00000000..137334d2 --- /dev/null +++ b/src/utils/ha/integration/types.ts @@ -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; diff --git a/src/utils/ha/registry/device/index.ts b/src/utils/ha/registry/device/index.ts index ac3eb783..5cbb0ca0 100644 --- a/src/utils/ha/registry/device/index.ts +++ b/src/utils/ha/registry/device/index.ts @@ -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'; diff --git a/src/utils/ha/registry/entity/index.ts b/src/utils/ha/registry/entity/index.ts index d7a18274..b5dc69f3 100644 --- a/src/utils/ha/registry/entity/index.ts +++ b/src/utils/ha/registry/entity/index.ts @@ -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'; diff --git a/src/utils/ha/resolved-media.ts b/src/utils/ha/resolved-media.ts index 2824f9bb..0bd8b553 100644 --- a/src/utils/ha/resolved-media.ts +++ b/src/utils/ha/resolved-media.ts @@ -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'; diff --git a/src/utils/ha/web-proxy.ts b/src/utils/ha/web-proxy.ts new file mode 100644 index 00000000..f22cadff --- /dev/null +++ b/src/utils/ha/web-proxy.ts @@ -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 { + 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, + }), + }); +} diff --git a/src/utils/ha/ws-request.ts b/src/utils/ha/ws-request.ts new file mode 100644 index 00000000..1a4bb569 --- /dev/null +++ b/src/utils/ha/ws-request.ts @@ -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( + hass: HomeAssistant, + schema: ZodSchema, + request: MessageBase, + passthrough = false, +): Promise { + let response; + try { + response = await hass.callWS(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(parseResult.error), + }); + } + return parseResult.data; +} diff --git a/tests/camera-manager/browse-media/utils/browse-media-to-view-media.test.ts b/tests/camera-manager/browse-media/utils/browse-media-to-view-media.test.ts new file mode 100644 index 00000000..0e90656a --- /dev/null +++ b/tests/camera-manager/browse-media/utils/browse-media-to-view-media.test.ts @@ -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>[], +) => { + 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'); + }); +}); diff --git a/tests/camera-manager/browse-media/utils/test-utils.ts b/tests/camera-manager/browse-media/utils/test-utils.ts new file mode 100644 index 00000000..ab649b28 --- /dev/null +++ b/tests/camera-manager/browse-media/utils/test-utils.ts @@ -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 => { + 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, + }; +}; diff --git a/tests/camera-manager/browse-media/utils/within-dates.test.ts b/tests/camera-manager/browse-media/utils/within-dates.test.ts new file mode 100644 index 00000000..b56183e3 --- /dev/null +++ b/tests/camera-manager/browse-media/utils/within-dates.test.ts @@ -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); + }); +}); diff --git a/tests/camera-manager/camera.test.ts b/tests/camera-manager/camera.test.ts index 6d08f403..f64a09de 100644 --- a/tests/camera-manager/camera.test.ts +++ b/tests/camera-manager/camera.test.ts @@ -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, + expectedResult: CameraProxyConfig, + ) => { + const camera = new Camera( + createCameraConfig({ + proxy: proxyConfig, + }), + new GenericCameraManagerEngine(mock()), + ); + expect(camera.getProxyConfig()).toEqual(expectedResult); + }, + ); + }); }); diff --git a/tests/camera-manager/engine-factory.test.ts b/tests/camera-manager/engine-factory.test.ts index 7a38e62c..7fc114f2 100644 --- a/tests/camera-manager/engine-factory.test.ts +++ b/tests/camera-manager/engine-factory.test.ts @@ -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(), + resolvedMediaCache: mock(), + }), + ).toBeInstanceOf(ReolinkCameraManagerEngine); + }); }); diff --git a/tests/camera-manager/frigate/requests.test.ts b/tests/camera-manager/frigate/requests.test.ts index 89873775..8015aac4 100644 --- a/tests/camera-manager/frigate/requests.test.ts +++ b/tests/camera-manager/frigate/requests.test.ts @@ -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(() => { diff --git a/tests/camera-manager/reolink/camera.test.ts b/tests/camera-manager/reolink/camera.test.ts new file mode 100644 index 00000000..d737eb3d --- /dev/null +++ b/tests/camera-manager/reolink/camera.test.ts @@ -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()); + + expect( + async () => + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: mock(), + stateWatcher: mock(), + }), + ).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()); + + const entityRegistryManager = mock(); + const entity = createRegistryEntity({ + platform: 'reolink', + }); + entityRegistryManager.getEntity.mockResolvedValue(entity); + + expect( + async () => + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: entityRegistryManager, + stateWatcher: mock(), + }), + ).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()); + + const entityRegistryManager = mock(); + const entity = createRegistryEntity({ + unique_id: 'invalid', + platform: 'reolink', + }); + entityRegistryManager.getEntity.mockResolvedValue(entity); + + expect( + async () => + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: entityRegistryManager, + stateWatcher: mock(), + }), + ).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()); + + const entityRegistryManager = mock(); + const entity = createRegistryEntity({ + unique_id: '85270002TS7D4RUP_0_main', + platform: 'reolink', + }); + entityRegistryManager.getEntity.mockResolvedValue(entity); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: entityRegistryManager, + stateWatcher: mock(), + }); + + 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, + expectedResult: CameraProxyConfig, + ) => { + const camera = new ReolinkCamera( + createCameraConfig({ + proxy: proxyConfig, + }), + mock(), + ); + expect(camera.getProxyConfig()).toEqual(expectedResult); + }, + ); + }); +}); diff --git a/tests/camera-manager/reolink/engine-reolink.test.ts b/tests/camera-manager/reolink/engine-reolink.test.ts new file mode 100644 index 00000000..011584fd --- /dev/null +++ b/tests/camera-manager/reolink/engine-reolink.test.ts @@ -0,0 +1,1033 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { BrowseMediaMetadata } from '../../../src/camera-manager/browse-media/types'; +import { RequestCache } from '../../../src/camera-manager/cache'; +import { + ReolinkCameraManagerEngine, + ReolinkQueryResultsClassifier, +} from '../../../src/camera-manager/reolink/engine-reolink'; +import { ReolinkEventQueryResults } from '../../../src/camera-manager/reolink/types'; +import { CameraManagerStore } from '../../../src/camera-manager/store'; +import { + Engine, + EventQuery, + QueryResultsType, + QueryReturnType, + QueryType, +} from '../../../src/camera-manager/types'; +import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; +import { BrowseMediaManager } from '../../../src/utils/ha/browse-media/browse-media-manager'; +import { + BrowseMedia, + browseMediaSchema, +} from '../../../src/utils/ha/browse-media/types'; +import { EntityRegistryManager } from '../../../src/utils/ha/registry/entity'; +import { ResolvedMediaCache } from '../../../src/utils/ha/resolved-media'; +import { homeAssistantWSRequest } from '../../../src/utils/ha/ws-request'; +import { + createCamera, + createCameraConfig, + createHASS, + createRegistryEntity, + createStore, +} from '../../test-utils'; + +vi.mock('../../../src/utils/ha/ws-request'); + +const TEST_DIRECTORIES: BrowseMedia = { + title: 'Back Yard Low res.', + media_class: 'channel', + media_content_type: 'playlist', + media_content_id: 'media-source://reolink/DAYS|01J8XHYTNH77WE3C654K03KX1F|0|sub', + children_media_class: 'directory', + can_play: false, + can_expand: true, + thumbnail: null, + children: [ + { + title: '2024/11/4', + media_class: 'directory', + media_content_type: 'playlist', + media_content_id: + 'media-source://reolink/DAY|01J8XHYTNH77WE3C654K03KX1F|0|sub|2024|11|4', + children_media_class: null, + can_play: false, + can_expand: true, + thumbnail: null, + }, + { + title: '2024/11/5', + media_class: 'directory', + media_content_type: 'playlist', + media_content_id: + 'media-source://reolink/DAY|01J8XHYTNH77WE3C654K03KX1F|0|sub|2024|11|5', + children_media_class: null, + can_play: false, + can_expand: true, + thumbnail: null, + }, + { + title: '2024/11/6', + media_class: 'directory', + media_content_type: 'playlist', + media_content_id: + 'media-source://reolink/DAY|01J8XHYTNH77WE3C654K03KX1F|0|sub|2024|11|6', + children_media_class: null, + can_play: false, + can_expand: true, + thumbnail: null, + }, + ], +}; + +const TEST_FILES: BrowseMedia = { + title: 'Back Yard Low res. 2024/11/4', + media_class: 'channel', + media_content_type: 'playlist', + media_content_id: 'media-source://reolink/FILES|01J8XHYTNH77WE3C654K03KX1F|0|sub', + children_media_class: 'video', + can_play: false, + can_expand: true, + thumbnail: null, + children: [ + { + title: '21:23:53 0:00:34', + media_class: 'video', + media_content_type: 'video', + media_content_id: + 'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_052353_211_S.mp4', + children_media_class: null, + can_play: true, + can_expand: false, + thumbnail: null, + }, + { + title: '21:29:05 0:00:41', + media_class: 'video', + media_content_type: 'video', + media_content_id: + 'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_052905_211_S.mp4', + children_media_class: null, + can_play: true, + can_expand: false, + thumbnail: null, + }, + { + title: '22:04:49 0:00:35', + media_class: 'video', + media_content_type: 'video', + media_content_id: + 'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_060449_211_S.mp4', + children_media_class: null, + can_play: true, + can_expand: false, + thumbnail: null, + }, + ], +}; + +const createEngine = (options?: { + browseMediaManager?: BrowseMediaManager; + entityRegistryManager?: EntityRegistryManager; +}): ReolinkCameraManagerEngine => { + return new ReolinkCameraManagerEngine( + options?.entityRegistryManager ?? mock(), + mock(), + options?.browseMediaManager ?? new BrowseMediaManager(), + new ResolvedMediaCache(), + new RequestCache(), + ); +}; + +const createPopulatedEngine = (): ReolinkCameraManagerEngine => { + const entityRegistryManager = mock(); + const entity = createRegistryEntity({ + unique_id: '85270002TS7D4RUP_0_main', + platform: 'reolink', + config_entry_id: '01J8XHYTNH77WE3C654K03KX1F', + }); + vi.mocked(entityRegistryManager.getEntity).mockResolvedValue(entity); + + return createEngine({ entityRegistryManager }); +}; + +const createStoreWithReolinkCamera = async ( + engine: ReolinkCameraManagerEngine, +): Promise => { + const store = new CameraManagerStore(); + const camera = await engine.createCamera( + createHASS(), + createCameraConfig({ camera_entity: 'camera.office', id: 'office' }), + ); + store.addCamera(camera); + return store; +}; + +describe('ReolinkQueryResultsClassifier', () => { + it('should correctly identify matching results', () => { + expect( + ReolinkQueryResultsClassifier.isReolinkEventQueryResults({ + type: QueryResultsType.Event, + engine: Engine.Reolink, + }), + ).toBeTruthy(); + }); + + it('should correctly identify non-matching results', () => { + expect( + ReolinkQueryResultsClassifier.isReolinkEventQueryResults({ + type: QueryResultsType.Event, + engine: Engine.MotionEye, + }), + ).toBeFalsy(); + }); +}); + +describe('ReolinkCameraManagerEngine', () => { + it('should get correct engine type', () => { + const engine = createEngine(); + expect(engine.getEngineType()).toBe('reolink'); + }); + + it('should create camera', async () => { + const entityRegistryManager = mock(); + const entity = createRegistryEntity({ + unique_id: '85270002TS7D4RUP_0_main', + platform: 'reolink', + }); + vi.mocked(entityRegistryManager.getEntity).mockResolvedValue(entity); + + const engine = createEngine({ entityRegistryManager }); + const config = createCameraConfig({ + camera_entity: 'camera.office', + unique_id: 'office', + }); + + const camera = await engine.createCamera(createHASS(), config); + + expect(camera.getConfig()).toBe(config); + expect(camera.getEngine()).toBe(engine); + expect(camera.getCapabilities()?.getRawCapabilities()).toEqual({ + 'favorite-events': false, + 'favorite-recordings': false, + clips: true, + live: true, + menu: true, + recordings: false, + seek: false, + snapshots: false, + substream: true, + }); + }); + + it('should get camera metadata', () => { + const cameraConfig = createCameraConfig({ + title: 'Office', + camera_entity: 'camera.office', + icon: 'mdi:camera', + }); + const engine = createEngine(); + expect(engine.getCameraMetadata(createHASS(), cameraConfig)).toEqual({ + engineLogo: '/src/camera-manager/reolink/assets/reolink.svg', + icon: 'mdi:camera', + title: 'Office', + }); + }); + + describe('should get camera endpoints', () => { + it('should return ui endpoint', () => { + const cameraConfig = createCameraConfig({ + reolink: { + url: 'http://path-to-reolink', + }, + }); + + const engine = createEngine(); + expect(engine.getCameraEndpoints(cameraConfig)).toEqual( + expect.objectContaining({ + ui: { endpoint: 'http://path-to-reolink' }, + }), + ); + }); + + it('should return go2rtc endpoint', () => { + const cameraConfig = createCameraConfig({ + go2rtc: { + url: 'http://path-to-go2rtc', + stream: 'stream', + }, + }); + + const engine = createEngine(); + + expect(engine.getCameraEndpoints(cameraConfig)).toEqual( + expect.objectContaining({ + go2rtc: { endpoint: 'http://path-to-go2rtc/api/ws?src=stream', sign: false }, + }), + ); + }); + }); + + describe('should get events', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('should get no event for unsupported features', () => { + it.each([ + ['with favorite', { favorite: true }], + ['with tags', { tags: new Set(['gate']) }], + ['with what', { what: new Set(['car']) }], + ['with where', { where: new Set(['office']) }], + ['with hasSnapshot', { hasSnapshot: true }], + ])('%s', async (_name: string, query: Partial) => { + const engine = createEngine(); + expect( + await engine.getEvents(createHASS(), createStore(), { + ...query, + cameraIDs: new Set(['office']), + type: QueryType.Event, + }), + ).toBeNull(); + }); + }); + + it('should get events successfully without cache', async () => { + const engine = createPopulatedEngine(); + const store = await createStoreWithReolinkCamera(engine); + + vi.mocked(homeAssistantWSRequest) + .mockResolvedValueOnce(TEST_DIRECTORIES) + .mockResolvedValueOnce(TEST_FILES); + + const events = await engine.getEvents( + createHASS(), + store, + { + type: QueryType.Event, + cameraIDs: new Set(['office']), + start: new Date('2024-11-04T21:00:00'), + end: new Date('2024-11-04T22:00:00'), + }, + { + useCache: false, + }, + ); + + expect(events).toEqual( + new Map([ + [ + { + cameraIDs: new Set(['office']), + end: new Date('2024-11-04T22:00:00'), + start: new Date('2024-11-04T21:00:00'), + type: 'event-query', + }, + { + browseMedia: [ + { + _metadata: { + cameraID: 'office', + endDate: new Date('2024-11-04T21:29:46'), + startDate: new Date('2024-11-04T21:29:05'), + }, + can_expand: false, + can_play: true, + children_media_class: null, + media_class: 'video', + media_content_id: + 'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_052905_211_S.mp4', + media_content_type: 'video', + thumbnail: null, + title: '21:29:05 0:00:41', + }, + { + _metadata: { + cameraID: 'office', + endDate: new Date('2024-11-04T21:24:27'), + startDate: new Date('2024-11-04T21:23:53'), + }, + can_expand: false, + can_play: true, + children_media_class: null, + media_class: 'video', + media_content_id: + 'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_052353_211_S.mp4', + media_content_type: 'video', + thumbnail: null, + title: '21:23:53 0:00:34', + }, + ], + engine: 'reolink', + type: 'event-results', + }, + ], + ]), + ); + }); + + it('should cache event requests', async () => { + const engine = createPopulatedEngine(); + const store = await createStoreWithReolinkCamera(engine); + + vi.mocked(homeAssistantWSRequest) + .mockResolvedValueOnce(TEST_DIRECTORIES) + .mockResolvedValueOnce(TEST_FILES); + + for (let i = 0; i < 10; i++) { + await engine.getEvents( + createHASS(), + store, + { + type: QueryType.Event, + cameraIDs: new Set(['office']), + start: new Date('2024-11-04T21:00:00'), + end: new Date('2024-11-04T22:00:00'), + }, + { + useCache: true, + }, + ); + } + + expect(homeAssistantWSRequest).toHaveBeenCalledTimes(2); + }); + + it('should request high resolution if configured', async () => { + const engine = createPopulatedEngine(); + const camera = await engine.createCamera( + createHASS(), + createCameraConfig({ + camera_entity: 'camera.office', + id: 'office', + reolink: { + media_resolution: 'high', + }, + }), + ); + + const store = new CameraManagerStore(); + store.addCamera(camera); + + vi.mocked(homeAssistantWSRequest) + .mockResolvedValueOnce(TEST_DIRECTORIES) + .mockResolvedValueOnce(TEST_FILES); + + const hass = createHASS(); + await engine.getEvents(hass, store, { + type: QueryType.Event, + cameraIDs: new Set(['office']), + start: new Date('2024-11-04T21:00:00'), + end: new Date('2024-11-04T22:00:00'), + }); + + expect(homeAssistantWSRequest).toHaveBeenCalledWith( + hass, + browseMediaSchema, + expect.objectContaining({ + media_content_id: + // Media source request will refer to 'main' not 'sub'. + 'media-source://reolink/RES|01J8XHYTNH77WE3C654K03KX1F|0|main', + }), + ); + }); + + describe('should ignore malformed directories', () => { + it('malformed directory title', async () => { + const engine = createPopulatedEngine(); + const store = await createStoreWithReolinkCamera(engine); + + vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce({ + title: 'Back Yard Low res.', + media_class: 'channel', + media_content_type: 'playlist', + media_content_id: + 'media-source://reolink/DAYS|01J8XHYTNH77WE3C654K03KX1F|0|sub', + children_media_class: 'directory', + can_play: false, + can_expand: true, + thumbnail: null, + children: [ + { + // Malformed date. + title: '__MALFORMED__', + media_class: 'directory', + media_content_type: 'playlist', + media_content_id: + 'media-source://reolink/DAY|01J8XHYTNH77WE3C654K03KX1F|0|sub|2024|11|4', + children_media_class: null, + can_play: false, + can_expand: true, + thumbnail: null, + }, + ], + }); + + const events = await engine.getEvents(createHASS(), store, { + type: QueryType.Event, + cameraIDs: new Set(['office']), + start: new Date('2024-11-04T21:00:00'), + end: new Date('2024-11-04T22:00:00'), + }); + + expect(events).toEqual( + new Map([ + [ + { + cameraIDs: new Set(['office']), + end: new Date('2024-11-04T22:00:00'), + start: new Date('2024-11-04T21:00:00'), + type: 'event-query', + }, + { + browseMedia: [], + engine: 'reolink', + type: 'event-results', + }, + ], + ]), + ); + }); + }); + + describe('should ignore invalid cameras', () => { + it('camera without a config entry id', async () => { + const entityRegistryManager = mock(); + const entity = createRegistryEntity({ + unique_id: '85270002TS7D4RUP_0_main', + platform: 'reolink', + + // Cannot fetch events without a config_entry_id. + config_entry_id: null, + }); + vi.mocked(entityRegistryManager.getEntity).mockResolvedValue(entity); + + const engine = createEngine({ entityRegistryManager }); + const store = await createStoreWithReolinkCamera(engine); + + const events = await engine.getEvents(createHASS(), store, { + type: QueryType.Event, + cameraIDs: new Set(['office']), + start: new Date('2024-11-04T21:00:00'), + end: new Date('2024-11-04T22:00:00'), + }); + + expect(events).toEqual( + new Map([ + [ + { + cameraIDs: new Set(['office']), + end: new Date('2024-11-04T22:00:00'), + start: new Date('2024-11-04T21:00:00'), + type: 'event-query', + }, + { + browseMedia: [], + engine: 'reolink', + type: 'event-results', + }, + ], + ]), + ); + }); + + it('should reject requests for non-reolink cameras', async () => { + const engine = createPopulatedEngine(); + + const store = new CameraManagerStore(); + store.addCamera(createCamera(createCameraConfig({ id: 'office' }), engine)); + + const hass = createHASS(); + const events = await engine.getEvents(hass, store, { + type: QueryType.Event, + cameraIDs: new Set(['office']), + }); + + expect(events).toEqual( + new Map([ + [ + { + cameraIDs: new Set(['office']), + type: 'event-query', + }, + { + browseMedia: [], + engine: 'reolink', + type: 'event-results', + }, + ], + ]), + ); + }); + }); + + describe('should ignore malformed media', () => { + it('malformed file title', async () => { + const engine = createPopulatedEngine(); + const store = await createStoreWithReolinkCamera(engine); + + vi.mocked(homeAssistantWSRequest) + .mockResolvedValueOnce(TEST_DIRECTORIES) + .mockResolvedValueOnce({ + title: 'Back Yard Low res. 2024/11/4', + media_class: 'channel', + media_content_type: 'playlist', + media_content_id: + 'media-source://reolink/FILES|01J8XHYTNH77WE3C654K03KX1F|0|sub', + children_media_class: 'video', + can_play: false, + can_expand: true, + thumbnail: null, + children: [ + { + // Title not matching format. + title: '21:23:53 0:00:34 _____MALFORMED_____', + media_class: 'video', + media_content_type: 'video', + media_content_id: + 'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_052353_211_S.mp4', + children_media_class: null, + can_play: true, + can_expand: false, + thumbnail: null, + }, + ], + }); + + const events = await engine.getEvents(createHASS(), store, { + type: QueryType.Event, + cameraIDs: new Set(['office']), + start: new Date('2024-11-04T21:00:00'), + end: new Date('2024-11-04T22:00:00'), + }); + + expect(events).toEqual( + new Map([ + [ + { + cameraIDs: new Set(['office']), + end: new Date('2024-11-04T22:00:00'), + start: new Date('2024-11-04T21:00:00'), + type: 'event-query', + }, + { + browseMedia: [], + engine: 'reolink', + type: 'event-results', + }, + ], + ]), + ); + }); + + it('malformed start time', async () => { + const engine = createPopulatedEngine(); + const store = await createStoreWithReolinkCamera(engine); + + vi.mocked(homeAssistantWSRequest) + .mockResolvedValueOnce(TEST_DIRECTORIES) + .mockResolvedValueOnce({ + title: 'Back Yard Low res. 2024/11/4', + media_class: 'channel', + media_content_type: 'playlist', + media_content_id: + 'media-source://reolink/FILES|01J8XHYTNH77WE3C654K03KX1F|0|sub', + children_media_class: 'video', + can_play: false, + can_expand: true, + thumbnail: null, + children: [ + { + // Invalid hour. + title: '29:23:53 0:00:34', + media_class: 'video', + media_content_type: 'video', + media_content_id: + 'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_052353_211_S.mp4', + children_media_class: null, + can_play: true, + can_expand: false, + thumbnail: null, + }, + ], + }); + + const events = await engine.getEvents(createHASS(), store, { + type: QueryType.Event, + cameraIDs: new Set(['office']), + start: new Date('2024-11-04T21:00:00'), + end: new Date('2024-11-04T22:00:00'), + }); + + expect(events).toEqual( + new Map([ + [ + { + cameraIDs: new Set(['office']), + end: new Date('2024-11-04T22:00:00'), + start: new Date('2024-11-04T21:00:00'), + type: 'event-query', + }, + { + browseMedia: [], + engine: 'reolink', + type: 'event-results', + }, + ], + ]), + ); + }); + + it('malformed duration', async () => { + const engine = createPopulatedEngine(); + const store = await createStoreWithReolinkCamera(engine); + + vi.mocked(homeAssistantWSRequest) + .mockResolvedValueOnce(TEST_DIRECTORIES) + .mockResolvedValueOnce({ + title: 'Back Yard Low res. 2024/11/4', + media_class: 'channel', + media_content_type: 'playlist', + media_content_id: + 'media-source://reolink/FILES|01J8XHYTNH77WE3C654K03KX1F|0|sub', + children_media_class: 'video', + can_play: false, + can_expand: true, + thumbnail: null, + children: [ + { + // Invalid duration. + title: '21:23:53 INVALID_DURATION', + media_class: 'video', + media_content_type: 'video', + media_content_id: + 'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_052353_211_S.mp4', + children_media_class: null, + can_play: true, + can_expand: false, + thumbnail: null, + }, + ], + }); + + const events = await engine.getEvents(createHASS(), store, { + type: QueryType.Event, + cameraIDs: new Set(['office']), + start: new Date('2024-11-04T21:00:00'), + end: new Date('2024-11-04T22:00:00'), + }); + + expect(events).toEqual( + new Map([ + [ + { + cameraIDs: new Set(['office']), + end: new Date('2024-11-04T22:00:00'), + start: new Date('2024-11-04T21:00:00'), + type: 'event-query', + }, + { + browseMedia: [ + { + _metadata: { + cameraID: 'office', + // End time and start time will match. + endDate: new Date('2024-11-04T21:23:53'), + startDate: new Date('2024-11-04T21:23:53'), + }, + can_expand: false, + can_play: true, + children_media_class: null, + media_class: 'video', + media_content_id: + 'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_052353_211_S.mp4', + media_content_type: 'video', + thumbnail: null, + title: '21:23:53 INVALID_DURATION', + }, + ], + engine: 'reolink', + type: 'event-results', + }, + ], + ]), + ); + }); + }); + }); + + describe('should generate media from events', () => { + it('should generate media successfully', () => { + const query: EventQuery = { + type: QueryType.Event, + cameraIDs: new Set(['office']), + start: new Date('2024-11-04T21:00:00'), + end: new Date('2024-11-04T22:00:00'), + }; + + const results: ReolinkEventQueryResults = { + browseMedia: [ + { + _metadata: { + cameraID: 'office', + endDate: new Date('2024-11-04T22:00:00'), + startDate: new Date('2024-11-04T21:00:00'), + }, + can_expand: false, + can_play: true, + children_media_class: null, + media_class: 'video', + media_content_id: + 'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_052353_211_S.mp4', + media_content_type: 'video', + thumbnail: null, + title: '21:23:53 1:00:00', + }, + ], + engine: Engine.Reolink, + type: QueryResultsType.Event, + }; + + const store = new CameraManagerStore(); + const engine = createEngine(); + const media = engine.generateMediaFromEvents(createHASS(), store, query, results); + expect(media?.length).toBe(1); + expect(media?.[0].getCameraID()).toBe('office'); + expect(media?.[0].getStartTime()).toEqual(new Date('2024-11-04T21:00:00')); + expect(media?.[0].getEndTime()).toEqual(new Date('2024-11-04T22:00:00')); + expect(media?.[0].getVideoContentType()).toBe('mp4'); + expect(media?.[0].getID()).toBe('office/2024-11-04 21:00:00'); + expect(media?.[0].getContentID()).toBe( + 'media-source://reolink/FILE|01J8XHYTNH77WE3C654K03KX1F|0|sub|Rec_20241105_052353_211_S.mp4', + ); + expect(media?.[0].getTitle()).toBe('2024-11-04 21:00'); + }); + + it('should reject non-reolink results', () => { + const query: EventQuery = { + type: QueryType.Event, + cameraIDs: new Set(['office']), + start: new Date('2024-11-04T21:00:00'), + end: new Date('2024-11-04T22:00:00'), + }; + + const results: QueryReturnType = { + engine: Engine.Frigate, + type: QueryResultsType.Event, + }; + + const store = new CameraManagerStore(); + const engine = createEngine(); + const media = engine.generateMediaFromEvents(createHASS(), store, query, results); + expect(media).toBeNull(); + }); + }); + + describe('should get media metadata', () => { + beforeAll(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-11-17T15:06:00')); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should get media metadata successfully', async () => { + const engine = createPopulatedEngine(); + const store = await createStoreWithReolinkCamera(engine); + + vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce(TEST_DIRECTORIES); + + const metadata = await engine.getMediaMetadata( + createHASS(), + store, + { + type: QueryType.MediaMetadata, + cameraIDs: new Set(['office']), + }, + { useCache: false }, + ); + + expect(metadata).toEqual( + new Map([ + [ + { + cameraIDs: new Set(['office']), + type: 'media-metadata', + }, + { + cached: false, + engine: 'reolink', + expiry: new Date('2024-11-17T15:07:00'), + metadata: { + days: new Set(['2024-11-06', '2024-11-05', '2024-11-04']), + }, + type: 'media-metadata-results', + }, + ], + ]), + ); + }); + + it('should cache media metadata', async () => { + const engine = createPopulatedEngine(); + const store = await createStoreWithReolinkCamera(engine); + + vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce(TEST_DIRECTORIES); + + for (let i = 0; i < 10; i++) { + await engine.getMediaMetadata( + createHASS(), + store, + { + type: QueryType.MediaMetadata, + cameraIDs: new Set(['office']), + }, + { useCache: true }, + ); + } + + expect(homeAssistantWSRequest).toHaveBeenCalledTimes(1); + }); + + describe('should ignore invalid cameras', () => { + it('should get no metdata for camera without a config entry id', async () => { + const entityRegistryManager = mock(); + const entity = createRegistryEntity({ + unique_id: '85270002TS7D4RUP_0_main', + platform: 'reolink', + + // Cannot fetch events without a config_entry_id. + config_entry_id: null, + }); + vi.mocked(entityRegistryManager.getEntity).mockResolvedValue(entity); + + const engine = createEngine({ entityRegistryManager }); + const store = await createStoreWithReolinkCamera(engine); + + const metadata = await engine.getMediaMetadata(createHASS(), store, { + type: QueryType.MediaMetadata, + cameraIDs: new Set(['office']), + }); + + expect(metadata).toEqual( + new Map([ + [ + { + cameraIDs: new Set(['office']), + type: 'media-metadata', + }, + { + cached: false, + engine: 'reolink', + expiry: new Date('2024-11-17T15:07:00'), + metadata: {}, + type: 'media-metadata-results', + }, + ], + ]), + ); + }); + + it('should get no metdata for non-reolink camera', async () => { + const engine = createPopulatedEngine(); + + const store = new CameraManagerStore(); + store.addCamera(createCamera(createCameraConfig({ id: 'office' }), engine)); + + const metadata = await engine.getMediaMetadata(createHASS(), store, { + type: QueryType.MediaMetadata, + cameraIDs: new Set(['office']), + }); + + expect(metadata).toEqual( + new Map([ + [ + { + cameraIDs: new Set(['office']), + type: 'media-metadata', + }, + { + cached: false, + engine: 'reolink', + expiry: new Date('2024-11-17T15:07:00'), + metadata: {}, + type: 'media-metadata-results', + }, + ], + ]), + ); + }); + }); + + describe('should ignore malformed directories', () => { + it('malformed directory title', async () => { + const engine = createPopulatedEngine(); + const store = await createStoreWithReolinkCamera(engine); + + vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce({ + title: 'Back Yard Low res.', + media_class: 'channel', + media_content_type: 'playlist', + media_content_id: + 'media-source://reolink/DAYS|01J8XHYTNH77WE3C654K03KX1F|0|sub', + children_media_class: 'directory', + can_play: false, + can_expand: true, + thumbnail: null, + children: [ + { + // Malformed date. + title: '__MALFORMED__', + media_class: 'directory', + media_content_type: 'playlist', + media_content_id: + 'media-source://reolink/DAY|01J8XHYTNH77WE3C654K03KX1F|0|sub|2024|11|4', + children_media_class: null, + can_play: false, + can_expand: true, + thumbnail: null, + }, + ], + }); + + const metadata = await engine.getMediaMetadata(createHASS(), store, { + type: QueryType.MediaMetadata, + cameraIDs: new Set(['office']), + }); + + expect(metadata).toEqual( + new Map([ + [ + { + cameraIDs: new Set(['office']), + type: 'media-metadata', + }, + { + cached: false, + engine: 'reolink', + expiry: new Date('2024-11-17T15:07:00'), + metadata: {}, + type: 'media-metadata-results', + }, + ], + ]), + ); + }); + }); + }); +}); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 23db7608..bb49ee2f 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -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: [], diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 33c6a525..7c73bcdd 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -147,7 +147,7 @@ export const createRegistryEntity = (entity?: Partial): 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 }), }; }; diff --git a/tests/utils/custom-icons.test.ts b/tests/utils/custom-icons.test.ts index 1bc10d9d..124761e5 100644 --- a/tests/utils/custom-icons.test.ts +++ b/tests/utils/custom-icons.test.ts @@ -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(); }); diff --git a/tests/utils/diagnostics.test.ts b/tests/utils/diagnostics.test.ts index 8f351f27..840f9dff 100644 --- a/tests/utils/diagnostics.test.ts +++ b/tests/utils/diagnostics.test.ts @@ -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', diff --git a/tests/utils/ha/index.test.ts b/tests/utils/ha/index.test.ts index 823f7ead..3bc08ff2 100644 --- a/tests/utils/ha/index.test.ts +++ b/tests/utils/ha/index.test.ts @@ -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(); + }); +}); diff --git a/tests/utils/ha/integration/index.test.ts b/tests/utils/ha/integration/index.test.ts new file mode 100644 index 00000000..2c25e5f3 --- /dev/null +++ b/tests/utils/ha/integration/index.test.ts @@ -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' }, + ); + }); +}); diff --git a/tests/utils/ha/registry/device/index.test.ts b/tests/utils/ha/registry/device/index.test.ts index 38a37f39..2464411f 100644 --- a/tests/utils/ha/registry/device/index.test.ts +++ b/tests/utils/ha/registry/device/index.test.ts @@ -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', () => { diff --git a/tests/utils/ha/registry/entity/index.test.ts b/tests/utils/ha/registry/entity/index.test.ts index 966b8cdd..bef5d447 100644 --- a/tests/utils/ha/registry/entity/index.test.ts +++ b/tests/utils/ha/registry/entity/index.test.ts @@ -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', () => { diff --git a/tests/utils/ha/web-proxy.test.ts b/tests/utils/ha/web-proxy.test.ts new file mode 100644 index 00000000..af3adb61 --- /dev/null +++ b/tests/utils/ha/web-proxy.test.ts @@ -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 => ({ + 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, + }, + ); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 2d1a74a6..3b2379ab 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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',