diff --git a/docs/configuration/cameras/README.md b/docs/configuration/cameras/README.md index 4b7975bc..0bbd548a 100644 --- a/docs/configuration/cameras/README.md +++ b/docs/configuration/cameras/README.md @@ -265,10 +265,10 @@ cameras: 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 +(this must be installed separately). This allows fetching media or live streams +**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) @@ -283,7 +283,9 @@ cameras: ![Camera Proxying](../../images/proxy.png 'Camera Proxying :size=400') -Not all [engines](./engine.md) benefit from proxying: +For live streams, only the `go2rtc` [live provider](./live-provider.md) currently supports live stream proxying. + +For media, not all [engines](./engine.md) benefit from proxying: | Engine | Purpose of proxying | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -300,6 +302,7 @@ Proxying parameters: | Option | Default | Description | | ------------------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `live` | `auto` | Whether or not to proxy live streams. `true` to proxy, `false` to not proxy, or `auto` to allow the camera engine to decide whether to proxy or not. Not all [live providers](./live-provider.md) support proxying live streams. | | `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. | @@ -431,6 +434,7 @@ cameras: preset: window - camera_entity: camera.needs_proxy proxy: + live: auto media: auto dynamic: true ssl_verification: auto diff --git a/docs/configuration/cameras/live-provider.md b/docs/configuration/cameras/live-provider.md index 727d2cf1..a20c8e06 100644 --- a/docs/configuration/cameras/live-provider.md +++ b/docs/configuration/cameras/live-provider.md @@ -4,15 +4,15 @@ The `live_provider` parameter determines what provides the live stream for a camera. Each provider offers different capabilities: -| Live Provider | Latency | Frame Rate | Loading Time | Installation | Description | -| ---------------------------------- | ------- | ---------- | ------------ | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `go2rtc` | Best | High | Better | Builtin | Uses [go2rtc](https://github.com/AlexxIT/go2rtc) to stream live feeds. This is supported by Frigate >= `0.12`. | -| `ha` (default HA configuration) | Poor | High | Better | Builtin | Use the built-in Home Assistant camera stream. | -| `ha` (Native WebRTC) | Best | High | Better | Builtin | Use the built-in Home Assistant camera streams -- can be configured to use [native WebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) offering a very low-latency feed direct to your browser. | -| `ha` (when configured with LL-HLS) | Better | High | Better | Builtin | Use the built-in Home Assistant camera streams -- can be configured to use an [LL-HLS](https://www.home-assistant.io/integrations/stream/#ll-hls) feed for lower latency. | -| `image` | Poor | Poor | Best | Builtin | Use refreshing snapshots of the built-in Home Assistant camera streams. | -| `jsmpeg` | Better | Low | Poor | Builtin | Use a the JSMPEG stream. | -| `webrtc-card` | Best | High | Better | Separate installation required | Embed's [AlexxIT's WebRTC Card](https://github.com/AlexxIT/WebRTC) to stream live feed, requires manual extra setup. See below. Not to be confused with native Home Assistant WebRTC (use the `ha` provider). | +| Live Provider | Latency | Frame Rate | Loading Time | Installation | Supports [Proxying](./README.md?id=proxy) | Description | +| ---------------------------------- | ------- | ---------- | ------------ | ------------------------------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `go2rtc` | Best | High | Better | Builtin | :white_check_mark: | Uses [go2rtc](https://github.com/AlexxIT/go2rtc) to stream live feeds. This is supported by Frigate >= `0.12`. | +| `ha` (default HA configuration) | Poor | High | Better | Builtin | :heavy_multiplication_x: | Use the built-in Home Assistant camera stream. | +| `ha` (Native WebRTC) | Best | High | Better | Builtin | :heavy_multiplication_x: | Use the built-in Home Assistant camera streams -- can be configured to use [native WebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) offering a very low-latency feed direct to your browser. | +| `ha` (when configured with LL-HLS) | Better | High | Better | Builtin | :heavy_multiplication_x: | Use the built-in Home Assistant camera streams -- can be configured to use an [LL-HLS](https://www.home-assistant.io/integrations/stream/#ll-hls) feed for lower latency. | +| `image` | Poor | Poor | Best | Builtin | :heavy_multiplication_x: | Use refreshing snapshots of the built-in Home Assistant camera streams. | +| `jsmpeg` | Better | Low | Poor | Builtin | :heavy_multiplication_x: | Use a the JSMPEG stream. | +| `webrtc-card` | Best | High | Better | Separate installation required | :heavy_multiplication_x: | Embed's [AlexxIT's WebRTC Card](https://github.com/AlexxIT/WebRTC) to stream live feed, requires manual extra setup. See below. Not to be confused with native Home Assistant WebRTC (use the `ha` provider). | ## `go2rtc` @@ -32,6 +32,8 @@ cameras: | `stream` | Determined by camera engine (e.g. `frigate` camera name). | A valid `go2rtc` stream name. | | `url` | Determined by camera engine (e.g. the `frigate` engine will automatically generate a URL for the go2rtc backend that runs in the Frigate container). | The root `go2rtc` URL the card should stream the video from. This is only needed for non-Frigate usecases, or advanced Frigate usecases. Example: `http://my-custom-go2rtc:1984` | +?> If `url` is manually set and `proxy.live` is set to `auto` on the camera (the default), the video stream will automatically be proxied via the Home Assistant process if the [hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration) is detected. See [proxying](./README.md?id=proxy). + ## `image` All configuration is under: diff --git a/docs/examples.md b/docs/examples.md index b9be1e47..6eca10e1 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -488,6 +488,27 @@ folders: regexp: 'Person.*' ``` +## `go2rtc` + +This example will use a custom `go2rtc` server, automatically proxying the video +stream via the Home Assistant process if +[hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration) +is detected. See [proxying](./configuration/cameras/README.md?id=proxy). + +```yaml +type: custom:advanced-camera-card +cameras: + - camera_entity: camera.office + live_provider: go2rtc + go2rtc: + # This would normally risk mixed content issues with browsers (accessing + # `http` content over a `https` connection to Home Assistant is often + # forbidden by browsers). In this scenario, since hass-web-proxy-integration + # has been installed separately by the user, the video will be automatically + # proxied. + url: http://my-custom-go2rtc +``` + ## Human interaction This example will automatically use a HD live substream when diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index da15cfca..5a6145c0 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -217,6 +217,22 @@ providers](./configuration/cameras/live-provider.md) such as `image` or `jsmpeg` The card will only show the fullscreen menu button when fullscreen can usefully be activated, which means for certain views on the iPhone it will be absent. +### Custom `go2rtc` server only works on Home Network + +This card runs in your browser, and (if configured) attempts to opens a direct +connection to a `go2rtc` server. If you are manually specifying a custom server +using the [`url`](./configuration/cameras/live-provider.md?id=go2rtc) option, +your browser may only be able to access that server when you're on the same +network, and/or when both Home Assistant and the `go2rtc` server are both +accessed over `http` or both over `https`. + +To automatically proxy the connection via the Home Assistant process instead +(ensuring that if you can access the card, you'll always have access to the +`go2rtc` server), optionally install +[hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration) +and your connection will be automatically proxied. See +[proxying](./configuration/cameras/README.md?id=proxy). + ### iOS App not updating after card version change Try resetting the app frontend cache: diff --git a/src/camera-manager/camera.ts b/src/camera-manager/camera.ts index 17b0a8f1..e2de8c84 100644 --- a/src/camera-manager/camera.ts +++ b/src/camera-manager/camera.ts @@ -77,8 +77,15 @@ export class Camera { public getProxyConfig(): CameraProxyConfig { return { - dynamic: this._config.proxy.dynamic, + live: + this._config.proxy.live === 'auto' + ? // Live is proxied if the live provider is go2rtc and if a go2rtc + // URL is manually set. + this._config.live_provider === 'go2rtc' && !!this._config.go2rtc?.url + : this._config.proxy.live, media: this._config.proxy.media === 'auto' ? false : this._config.proxy.media, + + dynamic: this._config.proxy.dynamic, ssl_verification: this._config.proxy.ssl_verification !== false, ssl_ciphers: this._config.proxy.ssl_ciphers === 'auto' diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts index 683db45d..e3534254 100644 --- a/src/camera-manager/types.ts +++ b/src/camera-manager/types.ts @@ -123,6 +123,7 @@ export interface CameraEndpoints { export interface CameraProxyConfig { dynamic: boolean; + live: boolean; media: boolean; ssl_verification: boolean; ssl_ciphers: SSLCiphers; diff --git a/src/components/live/carousel.ts b/src/components/live/carousel.ts index 56c4780c..e87b7e5d 100644 --- a/src/components/live/carousel.ts +++ b/src/components/live/carousel.ts @@ -17,7 +17,6 @@ import { MediaActionsController } from '../../components-lib/media-actions-contr import { MediaHeightController } from '../../components-lib/media-height-controller.js'; import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js'; import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js'; -import { CameraConfig } from '../../config/schema/cameras.js'; import { TransitionEffect } from '../../config/schema/common/transition-effect.js'; import { LiveConfig } from '../../config/schema/live.js'; import { CardWideConfig, configDefaults } from '../../config/schema/types.js'; @@ -170,18 +169,8 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { const slides: TemplateResult[] = []; const cameraToSlide: Record = {}; - for (const [cameraID, cameraConfig] of this.cameraManager - .getStore() - .getCameraConfigEntries(cameraIDs)) { - const liveCameraID = this._getSubstreamCameraID(cameraID, view); - const liveCameraConfig = - cameraID === liveCameraID - ? cameraConfig - : this.cameraManager?.getStore().getCameraConfig(liveCameraID); - - const slide = liveCameraConfig - ? this._renderLive(liveCameraID, liveCameraConfig) - : null; + for (const cameraID of cameraIDs ?? []) { + const slide = this._renderLive(this._getSubstreamCameraID(cameraID, view)); if (slide) { cameraToSlide[cameraID] = slides.length; slides.push(slide); @@ -207,11 +196,9 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { } } - protected _renderLive( - cameraID: string, - cameraConfig: CameraConfig, - ): TemplateResult | void { - if (!this.liveConfig || !this.hass || !this.cameraManager) { + protected _renderLive(cameraID: string): TemplateResult | void { + const camera = this.cameraManager?.getStore().getCamera(cameraID); + if (!this.liveConfig || !this.hass || !this.cameraManager || !camera) { return; } @@ -224,7 +211,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { .microphoneState=${view?.camera === cameraID ? this.microphoneState : undefined} - .cameraConfig=${cameraConfig} + .camera=${camera} .cameraEndpoints=${guard( [this.cameraManager, cameraID], () => this.cameraManager?.getCameraEndpoints(cameraID) ?? undefined, diff --git a/src/components/live/provider.ts b/src/components/live/provider.ts index 5d5b6734..79a1056f 100644 --- a/src/components/live/provider.ts +++ b/src/components/live/provider.ts @@ -10,12 +10,13 @@ import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { guard } from 'lit/directives/guard.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; +import { Camera } from '../../camera-manager/camera.js'; import { CameraEndpoints } from '../../camera-manager/types.js'; import { MicrophoneState } from '../../card-controller/types.js'; import { LazyLoadController } from '../../components-lib/lazy-load-controller.js'; import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch-live-error.js'; import { PartialZoomSettings } from '../../components-lib/zoom/types.js'; -import { CameraConfig, LiveProvider } from '../../config/schema/cameras.js'; +import { LiveProvider } from '../../config/schema/cameras.js'; import { LiveConfig } from '../../config/schema/live.js'; import { CardWideConfig, configDefaults } from '../../config/schema/types.js'; import { STREAM_TROUBLESHOOTING_URL } from '../../const.js'; @@ -39,7 +40,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP public hass?: HomeAssistant; @property({ attribute: false }) - public cameraConfig?: CameraConfig; + public camera?: Camera; @property({ attribute: false }) public cameraEndpoints?: CameraEndpoints; @@ -105,20 +106,18 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP * @returns A live provider (that is not 'auto'). */ protected _getResolvedProvider(): Omit { - if (this.cameraConfig?.live_provider === 'auto') { - if ( - this.cameraConfig?.webrtc_card?.entity || - this.cameraConfig?.webrtc_card?.url - ) { + const config = this.camera?.getConfig(); + if (config?.live_provider === 'auto') { + if (config?.webrtc_card?.entity || config?.webrtc_card?.url) { return 'webrtc-card'; - } else if (this.cameraConfig?.camera_entity) { + } else if (config?.camera_entity) { return 'ha'; - } else if (this.cameraConfig?.frigate.camera_name) { + } else if (config?.frigate.camera_name) { return 'jsmpeg'; } return configDefaults.cameras.live_provider; } - return this.cameraConfig?.live_provider || 'image'; + return config?.live_provider || 'image'; } /** @@ -129,7 +128,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP protected _shouldShowImageDuringLoading(): boolean { return ( !this._isVideoMediaLoaded && - !!this.cameraConfig?.camera_entity && + !!this.camera?.getConfig()?.camera_entity && !!this.hass && !!this.liveConfig?.show_image_during_load && !this._showStreamTroubleshooting && @@ -172,7 +171,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP } } - if (changedProps.has('cameraConfig')) { + if (changedProps.has('camera')) { const provider = this._getResolvedProvider(); if (provider === 'jsmpeg') { this._importPromises.push(import('./providers/jsmpeg.js')); @@ -198,8 +197,9 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP } protected _renderContainer(template: TemplateResult): TemplateResult { + const config = this.camera?.getConfig(); const intermediateTemplate = html` ) => { if (ev.detail.placeholder) { ev.stopPropagation(); @@ -213,11 +213,11 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP return html` ${this.liveConfig?.zoomable ? html` - this.cameraConfig?.dimensions?.layout + .defaultSettings=${guard([config?.dimensions?.layout], () => + config?.dimensions?.layout ? { - pan: this.cameraConfig.dimensions.layout.pan, - zoom: this.cameraConfig.dimensions.layout.zoom, + pan: config.dimensions.layout.pan, + zoom: config.dimensions.layout.zoom, } : undefined, )} @@ -233,11 +233,13 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP } protected render(): TemplateResult | void { + const cameraConfig = this.camera?.getConfig(); if ( !this._lazyLoadController?.isLoaded() || !this.hass || !this.liveConfig || - !this.cameraConfig + !this.camera || + !cameraConfig ) { return; } @@ -251,27 +253,26 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP if ( provider === 'ha' || provider === 'image' || - (this.cameraConfig?.camera_entity && - this.cameraConfig.always_error_if_entity_unavailable) + (cameraConfig?.camera_entity && cameraConfig.always_error_if_entity_unavailable) ) { - if (!this.cameraConfig?.camera_entity) { + if (!cameraConfig?.camera_entity) { dispatchLiveErrorEvent(this); return renderMessage({ message: localize('error.no_live_camera'), type: 'error', icon: 'mdi:camera', - context: this.cameraConfig, + context: cameraConfig, }); } - const stateObj = this.hass.states[this.cameraConfig.camera_entity]; + const stateObj = this.hass.states[cameraConfig.camera_entity]; if (!stateObj) { dispatchLiveErrorEvent(this); return renderMessage({ message: localize('error.live_camera_not_found'), type: 'error', icon: 'mdi:camera', - context: this.cameraConfig, + context: cameraConfig, }); } @@ -301,7 +302,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP ? html` this._providerErrorHandler()} > @@ -330,7 +331,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP ${ref(this._refProvider)} class=${classMap(classes)} .hass=${this.hass} - .cameraConfig=${this.cameraConfig} + .camera=${this.camera} .cameraEndpoints=${this.cameraEndpoints} .microphoneState=${this.microphoneState} .microphoneConfig=${this.liveConfig.microphone} @@ -343,7 +344,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP ${ref(this._refProvider)} class=${classMap(classes)} .hass=${this.hass} - .cameraConfig=${this.cameraConfig} + .cameraConfig=${cameraConfig} .cameraEndpoints=${this.cameraEndpoints} .cardWideConfig=${this.cardWideConfig} ?controls=${this.liveConfig.controls.builtin} @@ -355,7 +356,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP ${ref(this._refProvider)} class=${classMap(classes)} .hass=${this.hass} - .cameraConfig=${this.cameraConfig} + .cameraConfig=${cameraConfig} .cameraEndpoints=${this.cameraEndpoints} .cardWideConfig=${this.cardWideConfig} @advanced-camera-card:live:error=${() => this._providerErrorHandler()} diff --git a/src/components/live/providers/go2rtc/index.ts b/src/components/live/providers/go2rtc/index.ts index ea59502f..cddf6de2 100644 --- a/src/components/live/providers/go2rtc/index.ts +++ b/src/components/live/providers/go2rtc/index.ts @@ -7,17 +7,23 @@ import { unsafeCSS, } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; +import { Camera } from '../../../../camera-manager/camera.js'; import { CameraEndpoints } from '../../../../camera-manager/types.js'; import { MicrophoneState } from '../../../../card-controller/types.js'; import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js'; import { VideoMediaPlayerController } from '../../../../components-lib/media-player/video.js'; -import { CameraConfig } from '../../../../config/schema/cameras.js'; import { MicrophoneConfig } from '../../../../config/schema/live.js'; +import { homeAssistantSignPath } from '../../../../ha/sign-path.js'; import { HomeAssistant } from '../../../../ha/types.js'; +import { + addDynamicProxyURL, + getWebProxiedURL, + shouldUseWebProxy, +} from '../../../../ha/web-proxy.js'; import { localize } from '../../../../localize/localize.js'; import liveGo2RTCStyle from '../../../../scss/live-go2rtc.scss'; import { MediaPlayer, MediaPlayerController, Message } from '../../../../types.js'; -import { convertEndpointAddressToSignedWebsocket } from '../../../../utils/endpoint.js'; +import { errorToConsole } from '../../../../utils/basic.js'; import { renderMessage } from '../../../message.js'; import { VideoRTC } from './video-rtc.js'; @@ -36,7 +42,7 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer public hass?: HomeAssistant; @property({ attribute: false }) - public cameraConfig?: CameraConfig; + public camera?: Camera; @property({ attribute: false }) public cameraEndpoints?: CameraEndpoints; @@ -79,46 +85,102 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer this.requestUpdate(); } - protected async _createPlayer(): Promise { - if (!this.hass) { - return; + protected _handleError(message: Message, e?: Error): void { + if (e) { + errorToConsole(e as Error); + } + + this._message = { + type: 'error', + ...message, + }; + dispatchLiveErrorEvent(this); + return; + } + + protected async _getPlayerSource(): Promise { + const cameraConfig = this.camera?.getConfig(); + if (!this.hass || !cameraConfig) { + return null; } const endpoint = this.cameraEndpoints?.go2rtc; if (!endpoint) { - this._message = { - type: 'error', + this._handleError({ message: localize('error.live_camera_no_endpoint'), - context: this.cameraConfig, - }; - dispatchLiveErrorEvent(this); - return; + context: cameraConfig, + }); + return null; } - const address = await convertEndpointAddressToSignedWebsocket( - this.hass, - endpoint, - GO2RTC_URL_SIGN_EXPIRY_SECONDS, - ); - if (!address) { - this._message = { - type: 'error', - message: localize('error.failed_sign'), - context: this.cameraConfig, - }; - dispatchLiveErrorEvent(this); + const proxyConfig = this.camera?.getProxyConfig(); + let src: string | null = endpoint.endpoint; + let sign: boolean = endpoint.sign ?? false; + + if (proxyConfig && shouldUseWebProxy(this.hass, proxyConfig, 'live')) { + if (proxyConfig.dynamic) { + try { + await addDynamicProxyURL(this.hass, endpoint.endpoint, { + proxyConfig, + ttl: GO2RTC_URL_SIGN_EXPIRY_SECONDS, + + // The link may need to be opened multiple times. + openLimit: 0, + }); + } catch (e) { + this._handleError( + { + message: localize('error.failed_proxy'), + context: cameraConfig, + }, + e as Error, + ); + return null; + } + } + + src = getWebProxiedURL(endpoint.endpoint, { websocket: true }); + sign = true; + } + + if (src && sign) { + try { + src = await homeAssistantSignPath( + this.hass, + src, + GO2RTC_URL_SIGN_EXPIRY_SECONDS, + ); + } catch (e) { + this._handleError( + { + message: localize('error.failed_sign'), + context: cameraConfig, + }, + e as Error, + ); + return null; + } + } + + return src; + } + + protected async _createPlayer(): Promise { + const src = await this._getPlayerSource(); + if (!src) { return; } this._player = new VideoRTC(); this._player.mediaPlayerController = this._mediaPlayerController; this._player.microphoneStream = this.microphoneState?.stream ?? null; - this._player.src = address; + this._player.src = src; this._player.visibilityCheck = false; this._player.setControls(this.controls); - if (this.cameraConfig?.go2rtc?.modes && this.cameraConfig.go2rtc.modes.length) { - this._player.mode = this.cameraConfig.go2rtc.modes.join(','); + const cameraConfig = this.camera?.getConfig(); + if (cameraConfig?.go2rtc?.modes && cameraConfig.go2rtc.modes.length) { + this._player.mode = cameraConfig.go2rtc.modes.join(','); } this.requestUpdate(); diff --git a/src/components/live/providers/jsmpeg.ts b/src/components/live/providers/jsmpeg.ts index b19099bc..c7a197b6 100644 --- a/src/components/live/providers/jsmpeg.ts +++ b/src/components/live/providers/jsmpeg.ts @@ -14,11 +14,12 @@ import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispa import { JSMPEGMediaPlayerController } from '../../../components-lib/media-player/jsmpeg.js'; import { CameraConfig } from '../../../config/schema/cameras.js'; import { CardWideConfig } from '../../../config/schema/types.js'; +import { homeAssistantSignPath } from '../../../ha/sign-path.js'; import { HomeAssistant } from '../../../ha/types.js'; import { localize } from '../../../localize/localize.js'; import liveJSMPEGStyle from '../../../scss/live-jsmpeg.scss'; import { MediaPlayer, MediaPlayerController, Message } from '../../../types.js'; -import { convertEndpointAddressToSignedWebsocket } from '../../../utils/endpoint.js'; +import { convertHTTPAdressToWebsocket, errorToConsole } from '../../../utils/basic.js'; import { dispatchMediaLoadedEvent, dispatchMediaPauseEvent, @@ -183,11 +184,18 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla return; } - const address = await convertEndpointAddressToSignedWebsocket( - this.hass, - endpoint, - JSMPEG_URL_SIGN_EXPIRY_SECONDS, - ); + let response: string | null | undefined; + try { + response = await homeAssistantSignPath( + this.hass, + endpoint.endpoint, + JSMPEG_URL_SIGN_EXPIRY_SECONDS, + ); + } catch (e) { + errorToConsole(e as Error); + } + const address = response ? convertHTTPAdressToWebsocket(response) : null; + if (!address) { this._message = { type: 'error', diff --git a/src/components/viewer/provider.ts b/src/components/viewer/provider.ts index ae730d70..b4a41e38 100644 --- a/src/components/viewer/provider.ts +++ b/src/components/viewer/provider.ts @@ -158,13 +158,17 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi // 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 { + await addDynamicProxyURL(this.hass, urlWithoutQSorHash, { + proxyConfig, + + // The link may need to be opened multiple times. + openLimit: 0, + }); + } catch (e) { + errorToConsole(e as Error); + } } try { diff --git a/src/config/schema/cameras.ts b/src/config/schema/cameras.ts index c25c2517..f9876e42 100644 --- a/src/config/schema/cameras.ts +++ b/src/config/schema/cameras.ts @@ -121,10 +121,11 @@ export const cameraConfigDefault = { entities: [], }, proxy: { - media: 'auto' as const, dynamic: true, - ssl_verification: 'auto' as const, + live: 'auto' as const, + media: 'auto' as const, ssl_ciphers: 'auto' as const, + ssl_verification: 'auto' as const, }, always_error_if_entity_unavailable: false, }; @@ -133,6 +134,7 @@ const SSL_CIPHERS = ['default', 'insecure', 'intermediate', 'modern'] as const; export type SSLCiphers = (typeof SSL_CIPHERS)[number]; const proxyConfigSchema = z.object({ + live: z.boolean().or(z.literal('auto')).default(cameraConfigDefault.proxy.live), media: z.boolean().or(z.literal('auto')).default(cameraConfigDefault.proxy.media), dynamic: z.boolean().default(cameraConfigDefault.proxy.dynamic), ssl_verification: z @@ -144,7 +146,6 @@ const proxyConfigSchema = z.object({ .or(z.literal('auto')) .default(cameraConfigDefault.proxy.ssl_ciphers), }); -export type ProxyConfig = z.infer; const rotationSchema = z .literal(0) diff --git a/src/const.ts b/src/const.ts index 086716f3..7b495e45 100644 --- a/src/const.ts +++ b/src/const.ts @@ -92,6 +92,7 @@ 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_LIVE = `${CONF_CAMERAS}.#.proxy.live` 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; diff --git a/src/editor.ts b/src/editor.ts index 0a22d156..cab20a89 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -77,6 +77,7 @@ import { CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_FILE_PATTERN, CONF_CAMERAS_ARRAY_MOTIONEYE_URL, CONF_CAMERAS_ARRAY_PROXY_DYNAMIC, + CONF_CAMERAS_ARRAY_PROXY_LIVE, CONF_CAMERAS_ARRAY_PROXY_MEDIA, CONF_CAMERAS_ARRAY_PROXY_SSL_CIPHERS, CONF_CAMERAS_ARRAY_PROXY_SSL_VERIFICATION, @@ -871,19 +872,19 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard }, ]; - protected _proxyMedia: EditorSelectOption[] = [ + protected _proxyModes: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'auto', - label: localize('config.cameras.proxy.media.auto'), + label: localize('config.cameras.proxy.modes.auto'), }, { value: true, - label: localize('config.cameras.proxy.media.true'), + label: localize('config.cameras.proxy.modes.true'), }, { value: false, - label: localize('config.cameras.proxy.media.false'), + label: localize('config.cameras.proxy.modes.false'), }, ]; @@ -2563,10 +2564,17 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard 'mdi:arrow-decision', html` ${this._renderOptionSelector( - getArrayConfigPath(CONF_CAMERAS_ARRAY_PROXY_MEDIA, cameraIndex), - this._proxyMedia, + getArrayConfigPath(CONF_CAMERAS_ARRAY_PROXY_LIVE, cameraIndex), + this._proxyModes, { - label: localize('config.cameras.proxy.media.editor_label'), + label: localize('config.cameras.proxy.live'), + }, + )} + ${this._renderOptionSelector( + getArrayConfigPath(CONF_CAMERAS_ARRAY_PROXY_MEDIA, cameraIndex), + this._proxyModes, + { + label: localize('config.cameras.proxy.media'), }, )} ${this._renderSwitch( diff --git a/src/ha/web-proxy.ts b/src/ha/web-proxy.ts index a11ed9c1..ade9b4cf 100644 --- a/src/ha/web-proxy.ts +++ b/src/ha/web-proxy.ts @@ -7,30 +7,37 @@ 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)}`; +interface ProxiedURLOptions { + version?: number; + websocket?: boolean; +} + +/** + * Get a proxied URL for the given URL. + * @param url The URL to proxy. + * @param options Options for the proxied URL. + * @returns The proxied URL. + */ +export const getWebProxiedURL = (url: string, options?: ProxiedURLOptions): string => { + return ( + `/api/${HASS_WEB_PROXY_DOMAIN}/v${options?.version ?? 0}/` + + `${options?.websocket ? 'ws' : ''}?url=${encodeURIComponent(url)}` + ); }; export const shouldUseWebProxy = ( hass: HomeAssistant, proxyConfig: CameraProxyConfig, - context: 'media' = 'media', + context: 'media' | 'live' = '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: HomeAssistant, url_pattern: string, options?: { + proxyConfig?: CameraProxyConfig; urlID?: string; sslVerification?: boolean; sslCiphers?: string; @@ -43,8 +50,9 @@ export async function addDynamicProxyURL( url_pattern: url_pattern, ...(options && { url_id: options.urlID, - ssl_verification: options.sslVerification, - ssl_ciphers: options.sslCiphers, + ssl_verification: + options.sslVerification ?? options?.proxyConfig?.ssl_verification, + ssl_ciphers: options.sslCiphers ?? options?.proxyConfig?.ssl_ciphers, open_limit: options.openLimit, ttl: options.ttl, allow_unauthenticated: options.allowUnauthenticated, diff --git a/src/localize/languages/ca.json b/src/localize/languages/ca.json index 3c179b57..46105956 100644 --- a/src/localize/languages/ca.json +++ b/src/localize/languages/ca.json @@ -83,9 +83,9 @@ "rotation": "", "rotations": { "0": "", - "90": "", "180": "", - "270": "" + "270": "", + "90": "" } }, "engines": { @@ -145,9 +145,10 @@ "proxy": { "dynamic": "", "editor_label": "", - "media": { + "live": "", + "media": "", + "modes": { "auto": "", - "editor_label": "", "false": "", "true": "" }, @@ -679,6 +680,7 @@ "duplicate_camera_id": "Duplica l'identificador de la càmera Frigate per a la següent càmera, utilitza el paràmetre 'id' per identificar les càmeres de manera única", "duplicate_folder_id": "", "empty_response": "S'ha rebut una resposta buida de Home Assistant per a la sol·licitud", + "failed_proxy": "", "failed_response": "No s'ha pogut rebre la resposta de Home Assistant per a la sol·licitud", "failed_retain": "No s'ha pogut retenir l'esdeveniment", "failed_sign": "No s'ha pogut signar l'URL de Home Assistant", diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 5aaffb9c..2a51cbd5 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -83,9 +83,9 @@ "rotation": "Rotation", "rotations": { "0": "No rotation", - "90": "90 degrees clockwise", "180": "180 degrees clockwise", - "270": "270 degrees clockwise" + "270": "270 degrees clockwise", + "90": "90 degrees clockwise" } }, "engines": { @@ -145,11 +145,12 @@ "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" + "live": "Live proxying", + "media": "Media proxying", + "modes": { + "auto": "Proxying automatically configured", + "false": "Proxying disabled", + "true": "Proxying enabled" }, "ssl_ciphers": { "auto": "SSL ciphers automatically configured", @@ -330,8 +331,8 @@ "folders": { "ha": { "editor_label": "Home Assistant Media Folder Options", - "url": "URL of media folder", - "path_info": "Additional path matching functionality is available in the text editor" + "path_info": "Additional path matching functionality is available in the text editor", + "url": "URL of media folder" }, "icon": "Icon for this folder", "id": "Unique ID for this folder in this card", @@ -665,8 +666,8 @@ } }, "error": { - "awaiting_live": "Waiting for live stream to load ...", "awaiting_folder": "Waiting for folder to load", + "awaiting_live": "Waiting for live stream to load ...", "awaiting_media": "Waiting for media to load", "camera_initialization": "Camera initialization failed", "camera_initialization_reolink": "Could not initialize Reolink camera", @@ -679,6 +680,7 @@ "duplicate_camera_id": "Duplicate camera id for the following camera, use the 'id' parameter to uniquely identify cameras", "duplicate_folder_id": "Duplicate folder id for the following folder, use the 'id' parameter to uniquely identify folders", "empty_response": "Received empty response from Home Assistant for request", + "failed_proxy": "Could not proxy via Home Assistant", "failed_response": "Failed to receive response from Home Assistant for request", "failed_retain": "Could not retain event", "failed_sign": "Could not sign Home Assistant URL", diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json index 52747dd0..ffa6b9c2 100644 --- a/src/localize/languages/fr.json +++ b/src/localize/languages/fr.json @@ -83,9 +83,9 @@ "rotation": "", "rotations": { "0": "", - "90": "", "180": "", - "270": "" + "270": "", + "90": "" } }, "engines": { @@ -145,11 +145,12 @@ "proxy": { "dynamic": "Proxy dynamique", "editor_label": "Proxy de la caméra", - "media": { - "auto": "Proxy média automatique", - "editor_label": "Proxy média", - "false": "Proxy média désactivé", - "true": "Proxy média activé" + "live": "", + "media": "", + "modes": { + "auto": "Proxy automatique", + "false": "Proxy désactivé", + "true": "Proxy activé" }, "ssl_ciphers": { "auto": "Chiffrement SSL automatique", @@ -679,6 +680,7 @@ "duplicate_camera_id": "ID de caméra Frigate en double pour la caméra suivante, utilisez le paramètre « ID » pour identifier de manière unique les caméras", "duplicate_folder_id": "", "empty_response": "Réponse vide reçue de Home Assistant pour la demande", + "failed_proxy": "", "failed_response": "Échec de la réponse de Home Assistant à la demande", "failed_retain": "Impossible de conserver l'événement", "failed_sign": "Impossible de signer l'URL de Home Assistant", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index d23d8c6b..34721f32 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -83,9 +83,9 @@ "rotation": "", "rotations": { "0": "", - "90": "", "180": "", - "270": "" + "270": "", + "90": "" } }, "engines": { @@ -145,9 +145,10 @@ "proxy": { "dynamic": "", "editor_label": "", - "media": { + "live": "", + "media": "", + "modes": { "auto": "", - "editor_label": "", "false": "", "true": "" }, @@ -679,6 +680,7 @@ "duplicate_camera_id": "Duplicato ID dellla telecamera Frigate, utilizzare il parametro 'ID' per identificare in modo univoco le telecamere", "duplicate_folder_id": "", "empty_response": "Ricevuto risposta vuota da Home Assistant per la richiesta", + "failed_proxy": "", "failed_response": "Impossibile ricevere risposta da Home Assistant per la richiesta", "failed_retain": "Impossibile conservare l'evento", "failed_sign": "Impossibile firmare l'URL ad Home Assistant", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 6ac04be4..fe97c84c 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -83,9 +83,9 @@ "rotation": "", "rotations": { "0": "", - "90": "", "180": "", - "270": "" + "270": "", + "90": "" } }, "engines": { @@ -145,9 +145,10 @@ "proxy": { "dynamic": "", "editor_label": "", - "media": { + "live": "", + "media": "", + "modes": { "auto": "", - "editor_label": "", "false": "", "true": "" }, @@ -679,6 +680,7 @@ "duplicate_camera_id": "Duplique o ID da câmera Frigate para a câmera a seguir, use o parâmetro 'id' para identificar exclusivamente as câmeras", "duplicate_folder_id": "", "empty_response": "Sem resposta do Home Assistant para a solicitação", + "failed_proxy": "", "failed_response": "Falha ao receber resposta do Home Assistant para solicitação", "failed_retain": "Não foi possível reter o evento", "failed_sign": "Não foi possível assinar a URL do Home Assistant", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index 6c7085c1..b0d53ac3 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -83,9 +83,9 @@ "rotation": "", "rotations": { "0": "", - "90": "", "180": "", - "270": "" + "270": "", + "90": "" } }, "engines": { @@ -145,9 +145,10 @@ "proxy": { "dynamic": "", "editor_label": "", - "media": { + "live": "", + "media": "", + "modes": { "auto": "", - "editor_label": "", "false": "", "true": "" }, @@ -679,6 +680,7 @@ "duplicate_camera_id": "Duplique o ID da câmera Frigate para a câmera a seguir, use o parâmetro 'id' para identificar exclusivamente as câmeras", "duplicate_folder_id": "", "empty_response": "Sem resposta do Home Assistant para a solicitação", + "failed_proxy": "", "failed_response": "Falha ao receber resposta do Home Assistant para solicitação", "failed_retain": "Não foi possível reter o evento", "failed_sign": "Não foi possível assinar a URL do Home Assistant", diff --git a/src/utils/basic.ts b/src/utils/basic.ts index 2b6a11d7..6fed3439 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -323,3 +323,7 @@ export const generateFloatApproximatelyEqualsCustomizer = ( : undefined; }; }; + +export const convertHTTPAdressToWebsocket = (url: string): string => { + return url.replace(/^http/i, 'ws'); +}; diff --git a/src/utils/endpoint.ts b/src/utils/endpoint.ts deleted file mode 100644 index c95a1899..00000000 --- a/src/utils/endpoint.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { homeAssistantSignPath } from '../ha/sign-path'; -import { HomeAssistant } from '../ha/types'; -import { Endpoint } from '../types'; -import { errorToConsole } from './basic'; - -export const convertEndpointAddressToSignedWebsocket = async ( - hass: HomeAssistant, - endpoint: Endpoint, - expires?: number, -): Promise => { - if (!endpoint.sign) { - return endpoint.endpoint; - } - - let response: string | null | undefined; - try { - response = await homeAssistantSignPath(hass, endpoint.endpoint, expires); - } catch (e) { - errorToConsole(e as Error); - } - - return response ? response.replace(/^http/i, 'ws') : null; -}; diff --git a/tests/camera-manager/camera.test.ts b/tests/camera-manager/camera.test.ts index 84473c99..97df86d1 100644 --- a/tests/camera-manager/camera.test.ts +++ b/tests/camera-manager/camera.test.ts @@ -4,7 +4,6 @@ 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/schema/cameras.js'; import { callStateWatcherCallback, createCameraConfig, @@ -179,6 +178,7 @@ describe('Camera', () => { {}, { dynamic: true, + live: false, media: false, ssl_verification: true, ssl_ciphers: 'default' as const, @@ -186,9 +186,10 @@ describe('Camera', () => { ], [ 'when media set to true', - { media: true }, + { proxy: { media: true } }, { dynamic: true, + live: false, media: true, ssl_verification: true, ssl_ciphers: 'default' as const, @@ -196,9 +197,10 @@ describe('Camera', () => { ], [ 'when media set to false', - { media: false as const }, + { proxy: { media: false as const } }, { dynamic: true, + live: false, media: false, ssl_verification: true, ssl_ciphers: 'default' as const, @@ -206,9 +208,10 @@ describe('Camera', () => { ], [ 'when media set to auto', - { media: 'auto' as const }, + { proxy: { media: 'auto' as const } }, { dynamic: true, + live: false, media: false, ssl_verification: true, ssl_ciphers: 'default' as const, @@ -216,9 +219,10 @@ describe('Camera', () => { ], [ 'when ssl_verification is set to auto', - { ssl_verification: 'auto' as const }, + { proxy: { ssl_verification: 'auto' as const } }, { dynamic: true, + live: false, media: false, ssl_verification: true, ssl_ciphers: 'default' as const, @@ -226,9 +230,10 @@ describe('Camera', () => { ], [ 'when ssl_verification is set to true', - { ssl_verification: true }, + { proxy: { ssl_verification: true } }, { dynamic: true, + live: false, media: false, ssl_verification: true, ssl_ciphers: 'default' as const, @@ -236,9 +241,10 @@ describe('Camera', () => { ], [ 'when ssl_verification is set to false', - { ssl_verification: false }, + { proxy: { ssl_verification: false } }, { dynamic: true, + live: false, media: false, ssl_verification: false, ssl_ciphers: 'default' as const, @@ -246,9 +252,10 @@ describe('Camera', () => { ], [ 'when ssl_ciphers is set to auto', - { ssl_ciphers: 'auto' as const }, + { proxy: { ssl_ciphers: 'auto' as const } }, { dynamic: true, + live: false, media: false, ssl_verification: true, ssl_ciphers: 'default' as const, @@ -256,9 +263,10 @@ describe('Camera', () => { ], [ 'when ssl_ciphers is set to modern', - { ssl_ciphers: 'modern' as const }, + { proxy: { ssl_ciphers: 'modern' as const } }, { dynamic: true, + live: false, media: false, ssl_verification: true, ssl_ciphers: 'modern' as const, @@ -266,9 +274,47 @@ describe('Camera', () => { ], [ 'when dynamic is set to false', - { dynamic: false }, + { proxy: { dynamic: false } }, { dynamic: false, + live: false, + media: false, + ssl_verification: true, + ssl_ciphers: 'default' as const, + }, + ], + [ + 'when go2rtc has no url', + { live_provider: 'go2rtc' }, + { + dynamic: true, + live: false, + media: false, + ssl_verification: true, + ssl_ciphers: 'default' as const, + }, + ], + [ + 'when go2rtc has a url', + { live_provider: 'go2rtc', go2rtc: { url: 'http://localhost:1984' } }, + { + dynamic: true, + live: true, + media: false, + ssl_verification: true, + ssl_ciphers: 'default' as const, + }, + ], + [ + 'when go2rtc has a url but live is set to false', + { + live_provider: 'go2rtc', + go2rtc: { url: 'http://localhost:1984' }, + proxy: { live: false }, + }, + { + dynamic: true, + live: false, media: false, ssl_verification: true, ssl_ciphers: 'default' as const, @@ -276,15 +322,9 @@ describe('Camera', () => { ], ])( '%s', - ( - _name: string, - proxyConfig: Partial, - expectedResult: CameraProxyConfig, - ) => { + (_name: string, cameraConfig: unknown, expectedResult: CameraProxyConfig) => { const camera = new Camera( - createCameraConfig({ - proxy: proxyConfig, - }), + createCameraConfig(cameraConfig), new GenericCameraManagerEngine(mock()), ); expect(camera.getProxyConfig()).toEqual(expectedResult); diff --git a/tests/camera-manager/reolink/camera.test.ts b/tests/camera-manager/reolink/camera.test.ts index 300ef23c..812d4879 100644 --- a/tests/camera-manager/reolink/camera.test.ts +++ b/tests/camera-manager/reolink/camera.test.ts @@ -5,7 +5,6 @@ import { ReolinkCamera } from '../../../src/camera-manager/reolink/camera'; import { CameraProxyConfig } from '../../../src/camera-manager/types'; import { ActionsExecutor } from '../../../src/card-controller/actions/types'; import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; -import { ProxyConfig } from '../../../src/config/schema/cameras'; import { EntityRegistryManagerLive } from '../../../src/ha/registry/entity'; import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock'; import { @@ -341,6 +340,7 @@ describe('ReolinkCamera', () => { {}, { dynamic: true, + live: false, media: true, ssl_verification: false, ssl_ciphers: 'intermediate' as const, @@ -348,9 +348,10 @@ describe('ReolinkCamera', () => { ], [ 'when media set to on', - { media: true }, + { proxy: { media: true } }, { dynamic: true, + live: false, media: true, ssl_verification: false, ssl_ciphers: 'intermediate' as const, @@ -358,9 +359,10 @@ describe('ReolinkCamera', () => { ], [ 'when media set to off', - { media: false }, + { proxy: { media: false } }, { dynamic: true, + live: false, media: false, ssl_verification: false, ssl_ciphers: 'intermediate' as const, @@ -368,9 +370,10 @@ describe('ReolinkCamera', () => { ], [ 'when media set to auto', - { media: 'auto' as const }, + { proxy: { media: 'auto' as const } }, { dynamic: true, + live: false, media: true, ssl_verification: false, ssl_ciphers: 'intermediate' as const, @@ -378,9 +381,10 @@ describe('ReolinkCamera', () => { ], [ 'when ssl_verification is set to auto', - { ssl_verification: 'auto' as const }, + { proxy: { ssl_verification: 'auto' as const } }, { dynamic: true, + live: false, media: true, ssl_verification: false, ssl_ciphers: 'intermediate' as const, @@ -388,9 +392,10 @@ describe('ReolinkCamera', () => { ], [ 'when ssl_verification is set to true', - { ssl_verification: true }, + { proxy: { ssl_verification: true } }, { dynamic: true, + live: false, media: true, ssl_verification: true, ssl_ciphers: 'intermediate' as const, @@ -398,9 +403,10 @@ describe('ReolinkCamera', () => { ], [ 'when ssl_verification is set to false', - { ssl_verification: false }, + { proxy: { ssl_verification: false } }, { dynamic: true, + live: false, media: true, ssl_verification: false, ssl_ciphers: 'intermediate' as const, @@ -408,9 +414,10 @@ describe('ReolinkCamera', () => { ], [ 'when ssl_ciphers is set to auto', - { ssl_ciphers: 'auto' as const }, + { proxy: { ssl_ciphers: 'auto' as const } }, { dynamic: true, + live: false, media: true, ssl_verification: false, ssl_ciphers: 'intermediate' as const, @@ -418,9 +425,10 @@ describe('ReolinkCamera', () => { ], [ 'when ssl_ciphers is set to modern', - { ssl_ciphers: 'modern' as const }, + { proxy: { ssl_ciphers: 'modern' as const } }, { dynamic: true, + live: false, media: true, ssl_verification: false, ssl_ciphers: 'modern' as const, @@ -428,9 +436,47 @@ describe('ReolinkCamera', () => { ], [ 'when dynamic is set to false', - { dynamic: false }, + { proxy: { dynamic: false } }, { dynamic: false, + live: false, + media: true, + ssl_verification: false, + ssl_ciphers: 'intermediate' as const, + }, + ], + [ + 'when go2rtc has no url', + { live_provider: 'go2rtc' }, + { + dynamic: true, + live: false, + media: true, + ssl_verification: false, + ssl_ciphers: 'intermediate' as const, + }, + ], + [ + 'when go2rtc has a url', + { live_provider: 'go2rtc', go2rtc: { url: 'http://localhost:1984' } }, + { + dynamic: true, + live: true, + media: true, + ssl_verification: false, + ssl_ciphers: 'intermediate' as const, + }, + ], + [ + 'when go2rtc has a url but live is set to false', + { + live_provider: 'go2rtc', + go2rtc: { url: 'http://localhost:1984' }, + proxy: { live: false }, + }, + { + dynamic: true, + live: false, media: true, ssl_verification: false, ssl_ciphers: 'intermediate' as const, @@ -438,15 +484,9 @@ describe('ReolinkCamera', () => { ], ])( '%s', - ( - _name: string, - proxyConfig: Partial, - expectedResult: CameraProxyConfig, - ) => { + (_name: string, cameraConfig: unknown, expectedResult: CameraProxyConfig) => { const camera = new ReolinkCamera( - createCameraConfig({ - proxy: proxyConfig, - }), + createCameraConfig(cameraConfig), mock(), ); expect(camera.getProxyConfig()).toEqual(expectedResult); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index fe1ca69e..e5b7de7d 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -40,6 +40,7 @@ describe('config defaults', () => { }, proxy: { dynamic: true, + live: 'auto', media: 'auto', ssl_verification: 'auto', ssl_ciphers: 'auto', diff --git a/tests/ha/web-proxy.test.ts b/tests/ha/web-proxy.test.ts index d36c8a67..46c4cb64 100644 --- a/tests/ha/web-proxy.test.ts +++ b/tests/ha/web-proxy.test.ts @@ -8,15 +8,21 @@ import { import { createHASS } from '../test-utils.js'; describe('getWebProxiedURL', () => { - it('should return proxied URL with v != 0', () => { - expect(getWebProxiedURL('http://example.com', 2)).toBe( + it('should return proxied URL with default version', () => { + expect(getWebProxiedURL('http://example.com')).toBe( + '/api/hass_web_proxy/v0/?url=http%3A%2F%2Fexample.com', + ); + }); + + it('should return proxied URL with non-default version', () => { + expect(getWebProxiedURL('http://example.com', { version: 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', + it('should return proxied URL with websocket', () => { + expect(getWebProxiedURL('http://example.com', { websocket: true })).toBe( + '/api/hass_web_proxy/v0/ws?url=http%3A%2F%2Fexample.com', ); }); }); @@ -26,6 +32,7 @@ describe('shouldUseWebProxy', () => { config: Partial = {}, ): CameraProxyConfig => ({ media: true, + live: true, ssl_verification: true, ssl_ciphers: 'default', dynamic: true, @@ -83,4 +90,28 @@ describe('addDynamicProxyURL', () => { }, ); }); + + it('should add dynamic proxy URL using config defaults', async () => { + const hass = createHASS(); + const proxyConfig: CameraProxyConfig = { + media: true, + live: true, + ssl_verification: false, + ssl_ciphers: 'insecure', + dynamic: true, + }; + + await addDynamicProxyURL(hass, 'http://example.com', { + proxyConfig: proxyConfig, + }); + + expect(hass.callService).toHaveBeenCalledWith( + 'hass_web_proxy', + 'create_proxied_url', + expect.objectContaining({ + ssl_verification: false, + ssl_ciphers: 'insecure', + }), + ); + }); }); diff --git a/tests/utils/basic.test.ts b/tests/utils/basic.test.ts index 0ba659fb..0220eabf 100644 --- a/tests/utils/basic.test.ts +++ b/tests/utils/basic.test.ts @@ -7,6 +7,7 @@ import { arrayMove, aspectRatioToStyle, contentsChanged, + convertHTTPAdressToWebsocket, dayToDate, desparsifyArrays, errorToConsole, @@ -514,3 +515,23 @@ describe('generateFloatApproximatelyEqualsCustomizer', () => { }); }); }); + +describe('convertHTTPAdressToWebsocket', () => { + it('should convert http to ws', () => { + expect(convertHTTPAdressToWebsocket('http://example.com')).toBe('ws://example.com'); + }); + it('should convert https to ws', () => { + expect(convertHTTPAdressToWebsocket('https://example.com')).toBe( + 'wss://example.com', + ); + }); + it('should not change ws url', () => { + expect(convertHTTPAdressToWebsocket('ws://example.com')).toBe('ws://example.com'); + }); + it('should not change wss url', () => { + expect(convertHTTPAdressToWebsocket('wss://example.com')).toBe('wss://example.com'); + }); + it('should handle mixed case', () => { + expect(convertHTTPAdressToWebsocket('HtTp://example.com')).toBe('ws://example.com'); + }); +}); diff --git a/tests/utils/endpoint.test.ts b/tests/utils/endpoint.test.ts deleted file mode 100644 index f5b12e65..00000000 --- a/tests/utils/endpoint.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { homeAssistantSignPath } from '../../src/ha/sign-path.js'; -import { convertEndpointAddressToSignedWebsocket } from '../../src/utils/endpoint'; -import { createHASS } from '../test-utils'; - -vi.mock('../../src/ha/sign-path.js'); - -describe('convertEndpointAddressToSignedWebsocket', () => { - it('without signing', async () => { - expect( - await convertEndpointAddressToSignedWebsocket(createHASS(), { - endpoint: 'http://example.com', - sign: false, - }), - ).toBe('http://example.com'); - }); - - describe('with signing', () => { - afterEach(() => { - vi.clearAllMocks(); - }); - - it('successful', async () => { - vi.mocked(homeAssistantSignPath).mockResolvedValue('http://signed.com'); - - expect( - await convertEndpointAddressToSignedWebsocket(createHASS(), { - endpoint: 'http://example.com', - sign: true, - }), - ).toBe('ws://signed.com'); - }); - - it('with null response', async () => { - vi.mocked(homeAssistantSignPath).mockResolvedValue(null); - - expect( - await convertEndpointAddressToSignedWebsocket(createHASS(), { - endpoint: 'http://example.com', - sign: true, - }), - ).toBeNull(); - }); - - it('with exception on signing', async () => { - const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined); - - vi.mocked(homeAssistantSignPath).mockRejectedValue(new Error()); - - expect( - await convertEndpointAddressToSignedWebsocket(createHASS(), { - endpoint: 'http://example.com', - sign: true, - }), - ).toBeNull(); - - expect(consoleSpy).toBeCalled(); - }); - }); -});