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 00000000..6f16a626 Binary files /dev/null and b/docs/images/proxy.png differ 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',