feat: Enable go2rtc live stream proxying (#2159)

- Closes #1984
This commit is contained in:
Dermot Duffy
2025-08-28 21:19:19 -07:00
committed by GitHub
parent c3d95a5b32
commit bc5022ef1a
29 changed files with 479 additions and 282 deletions
+9 -5
View File
@@ -265,10 +265,10 @@ cameras:
Configures whether and how the content is proxied via Configures whether and how the content is proxied via
[hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration) [hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration)
(this must be installed separately). This allows fetching media **through** the (this must be installed separately). This allows fetching media or live streams
Home Assistant process itself, allowing the card to access resources it **through** the Home Assistant process itself, allowing the card to access
otherwise would not be able to directly access. There are [security and resources it otherwise would not be able to directly access. There are [security
performance and performance
implications](https://github.com/dermotduffy/hass-web-proxy-integration?tab=readme-ov-file#considerations) implications](https://github.com/dermotduffy/hass-web-proxy-integration?tab=readme-ov-file#considerations)
to consider before installing to consider before installing
[hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration) [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') ![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 | | Engine | Purpose of proxying |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -300,6 +302,7 @@ Proxying parameters:
| Option | Default | Description | | 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. | | `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). | | `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_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 preset: window
- camera_entity: camera.needs_proxy - camera_entity: camera.needs_proxy
proxy: proxy:
live: auto
media: auto media: auto
dynamic: true dynamic: true
ssl_verification: auto ssl_verification: auto
+11 -9
View File
@@ -4,15 +4,15 @@
The `live_provider` parameter determines what provides the live stream for a camera. Each provider offers different capabilities: 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 | | Live Provider | Latency | Frame Rate | Loading Time | Installation | Supports [Proxying](./README.md?id=proxy) | Description |
| ---------------------------------- | ------- | ---------- | ------------ | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------------- | ------- | ---------- | ------------ | ------------------------------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `go2rtc` | Best | High | Better | Builtin | Uses [go2rtc](https://github.com/AlexxIT/go2rtc) to stream live feeds. This is supported by Frigate >= `0.12`. | | `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 | Use the built-in Home Assistant camera stream. | | `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 | 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` (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 | 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. | | `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 | Use refreshing snapshots of the built-in Home Assistant camera streams. | | `image` | Poor | Poor | Best | Builtin | :heavy_multiplication_x: | Use refreshing snapshots of the built-in Home Assistant camera streams. |
| `jsmpeg` | Better | Low | Poor | Builtin | Use a the JSMPEG stream. | | `jsmpeg` | Better | Low | Poor | Builtin | :heavy_multiplication_x: | 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). | | `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` ## `go2rtc`
@@ -32,6 +32,8 @@ cameras:
| `stream` | Determined by camera engine (e.g. `frigate` camera name). | A valid `go2rtc` stream name. | | `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` | | `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` ## `image`
All configuration is under: All configuration is under:
+21
View File
@@ -488,6 +488,27 @@ folders:
regexp: 'Person.*' 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 ## Human interaction
This example will automatically use a HD live substream when This example will automatically use a HD live substream when
+16
View File
@@ -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 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. 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 ### iOS App not updating after card version change
Try resetting the app frontend cache: Try resetting the app frontend cache:
+8 -1
View File
@@ -77,8 +77,15 @@ export class Camera {
public getProxyConfig(): CameraProxyConfig { public getProxyConfig(): CameraProxyConfig {
return { 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, 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_verification: this._config.proxy.ssl_verification !== false,
ssl_ciphers: ssl_ciphers:
this._config.proxy.ssl_ciphers === 'auto' this._config.proxy.ssl_ciphers === 'auto'
+1
View File
@@ -123,6 +123,7 @@ export interface CameraEndpoints {
export interface CameraProxyConfig { export interface CameraProxyConfig {
dynamic: boolean; dynamic: boolean;
live: boolean;
media: boolean; media: boolean;
ssl_verification: boolean; ssl_verification: boolean;
ssl_ciphers: SSLCiphers; ssl_ciphers: SSLCiphers;
+6 -19
View File
@@ -17,7 +17,6 @@ import { MediaActionsController } from '../../components-lib/media-actions-contr
import { MediaHeightController } from '../../components-lib/media-height-controller.js'; import { MediaHeightController } from '../../components-lib/media-height-controller.js';
import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js'; import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js';
import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.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 { TransitionEffect } from '../../config/schema/common/transition-effect.js';
import { LiveConfig } from '../../config/schema/live.js'; import { LiveConfig } from '../../config/schema/live.js';
import { CardWideConfig, configDefaults } from '../../config/schema/types.js'; import { CardWideConfig, configDefaults } from '../../config/schema/types.js';
@@ -170,18 +169,8 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
const slides: TemplateResult[] = []; const slides: TemplateResult[] = [];
const cameraToSlide: Record<string, number> = {}; const cameraToSlide: Record<string, number> = {};
for (const [cameraID, cameraConfig] of this.cameraManager for (const cameraID of cameraIDs ?? []) {
.getStore() const slide = this._renderLive(this._getSubstreamCameraID(cameraID, view));
.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;
if (slide) { if (slide) {
cameraToSlide[cameraID] = slides.length; cameraToSlide[cameraID] = slides.length;
slides.push(slide); slides.push(slide);
@@ -207,11 +196,9 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
} }
} }
protected _renderLive( protected _renderLive(cameraID: string): TemplateResult | void {
cameraID: string, const camera = this.cameraManager?.getStore().getCamera(cameraID);
cameraConfig: CameraConfig, if (!this.liveConfig || !this.hass || !this.cameraManager || !camera) {
): TemplateResult | void {
if (!this.liveConfig || !this.hass || !this.cameraManager) {
return; return;
} }
@@ -224,7 +211,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
.microphoneState=${view?.camera === cameraID .microphoneState=${view?.camera === cameraID
? this.microphoneState ? this.microphoneState
: undefined} : undefined}
.cameraConfig=${cameraConfig} .camera=${camera}
.cameraEndpoints=${guard( .cameraEndpoints=${guard(
[this.cameraManager, cameraID], [this.cameraManager, cameraID],
() => this.cameraManager?.getCameraEndpoints(cameraID) ?? undefined, () => this.cameraManager?.getCameraEndpoints(cameraID) ?? undefined,
+30 -29
View File
@@ -10,12 +10,13 @@ import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { guard } from 'lit/directives/guard.js'; import { guard } from 'lit/directives/guard.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { Camera } from '../../camera-manager/camera.js';
import { CameraEndpoints } from '../../camera-manager/types.js'; import { CameraEndpoints } from '../../camera-manager/types.js';
import { MicrophoneState } from '../../card-controller/types.js'; import { MicrophoneState } from '../../card-controller/types.js';
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js'; import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch-live-error.js'; import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch-live-error.js';
import { PartialZoomSettings } from '../../components-lib/zoom/types.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 { LiveConfig } from '../../config/schema/live.js';
import { CardWideConfig, configDefaults } from '../../config/schema/types.js'; import { CardWideConfig, configDefaults } from '../../config/schema/types.js';
import { STREAM_TROUBLESHOOTING_URL } from '../../const.js'; import { STREAM_TROUBLESHOOTING_URL } from '../../const.js';
@@ -39,7 +40,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
public hass?: HomeAssistant; public hass?: HomeAssistant;
@property({ attribute: false }) @property({ attribute: false })
public cameraConfig?: CameraConfig; public camera?: Camera;
@property({ attribute: false }) @property({ attribute: false })
public cameraEndpoints?: CameraEndpoints; public cameraEndpoints?: CameraEndpoints;
@@ -105,20 +106,18 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
* @returns A live provider (that is not 'auto'). * @returns A live provider (that is not 'auto').
*/ */
protected _getResolvedProvider(): Omit<LiveProvider, 'auto'> { protected _getResolvedProvider(): Omit<LiveProvider, 'auto'> {
if (this.cameraConfig?.live_provider === 'auto') { const config = this.camera?.getConfig();
if ( if (config?.live_provider === 'auto') {
this.cameraConfig?.webrtc_card?.entity || if (config?.webrtc_card?.entity || config?.webrtc_card?.url) {
this.cameraConfig?.webrtc_card?.url
) {
return 'webrtc-card'; return 'webrtc-card';
} else if (this.cameraConfig?.camera_entity) { } else if (config?.camera_entity) {
return 'ha'; return 'ha';
} else if (this.cameraConfig?.frigate.camera_name) { } else if (config?.frigate.camera_name) {
return 'jsmpeg'; return 'jsmpeg';
} }
return configDefaults.cameras.live_provider; 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 { protected _shouldShowImageDuringLoading(): boolean {
return ( return (
!this._isVideoMediaLoaded && !this._isVideoMediaLoaded &&
!!this.cameraConfig?.camera_entity && !!this.camera?.getConfig()?.camera_entity &&
!!this.hass && !!this.hass &&
!!this.liveConfig?.show_image_during_load && !!this.liveConfig?.show_image_during_load &&
!this._showStreamTroubleshooting && !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(); const provider = this._getResolvedProvider();
if (provider === 'jsmpeg') { if (provider === 'jsmpeg') {
this._importPromises.push(import('./providers/jsmpeg.js')); this._importPromises.push(import('./providers/jsmpeg.js'));
@@ -198,8 +197,9 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
} }
protected _renderContainer(template: TemplateResult): TemplateResult { protected _renderContainer(template: TemplateResult): TemplateResult {
const config = this.camera?.getConfig();
const intermediateTemplate = html` <advanced-camera-card-media-dimensions-container const intermediateTemplate = html` <advanced-camera-card-media-dimensions-container
.dimensionsConfig=${this.cameraConfig?.dimensions} .dimensionsConfig=${config?.dimensions}
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => { @advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
if (ev.detail.placeholder) { if (ev.detail.placeholder) {
ev.stopPropagation(); ev.stopPropagation();
@@ -213,11 +213,11 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
return html` ${this.liveConfig?.zoomable return html` ${this.liveConfig?.zoomable
? html` <advanced-camera-card-zoomer ? html` <advanced-camera-card-zoomer
.defaultSettings=${guard([this.cameraConfig?.dimensions?.layout], () => .defaultSettings=${guard([config?.dimensions?.layout], () =>
this.cameraConfig?.dimensions?.layout config?.dimensions?.layout
? { ? {
pan: this.cameraConfig.dimensions.layout.pan, pan: config.dimensions.layout.pan,
zoom: this.cameraConfig.dimensions.layout.zoom, zoom: config.dimensions.layout.zoom,
} }
: undefined, : undefined,
)} )}
@@ -233,11 +233,13 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
} }
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
const cameraConfig = this.camera?.getConfig();
if ( if (
!this._lazyLoadController?.isLoaded() || !this._lazyLoadController?.isLoaded() ||
!this.hass || !this.hass ||
!this.liveConfig || !this.liveConfig ||
!this.cameraConfig !this.camera ||
!cameraConfig
) { ) {
return; return;
} }
@@ -251,27 +253,26 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
if ( if (
provider === 'ha' || provider === 'ha' ||
provider === 'image' || provider === 'image' ||
(this.cameraConfig?.camera_entity && (cameraConfig?.camera_entity && cameraConfig.always_error_if_entity_unavailable)
this.cameraConfig.always_error_if_entity_unavailable)
) { ) {
if (!this.cameraConfig?.camera_entity) { if (!cameraConfig?.camera_entity) {
dispatchLiveErrorEvent(this); dispatchLiveErrorEvent(this);
return renderMessage({ return renderMessage({
message: localize('error.no_live_camera'), message: localize('error.no_live_camera'),
type: 'error', type: 'error',
icon: 'mdi:camera', 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) { if (!stateObj) {
dispatchLiveErrorEvent(this); dispatchLiveErrorEvent(this);
return renderMessage({ return renderMessage({
message: localize('error.live_camera_not_found'), message: localize('error.live_camera_not_found'),
type: 'error', type: 'error',
icon: 'mdi:camera', icon: 'mdi:camera',
context: this.cameraConfig, context: cameraConfig,
}); });
} }
@@ -301,7 +302,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
? html` <advanced-camera-card-live-image ? html` <advanced-camera-card-live-image
${ref(this._refProvider)} ${ref(this._refProvider)}
.hass=${this.hass} .hass=${this.hass}
.cameraConfig=${this.cameraConfig} .cameraConfig=${cameraConfig}
class=${classMap({ class=${classMap({
...classes, ...classes,
// The image provider is providing the temporary loading image, // The image provider is providing the temporary loading image,
@@ -320,7 +321,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
${ref(this._refProvider)} ${ref(this._refProvider)}
class=${classMap(classes)} class=${classMap(classes)}
.hass=${this.hass} .hass=${this.hass}
.cameraConfig=${this.cameraConfig} .cameraConfig=${cameraConfig}
?controls=${this.liveConfig.controls.builtin} ?controls=${this.liveConfig.controls.builtin}
@advanced-camera-card:live:error=${() => this._providerErrorHandler()} @advanced-camera-card:live:error=${() => this._providerErrorHandler()}
> >
@@ -330,7 +331,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
${ref(this._refProvider)} ${ref(this._refProvider)}
class=${classMap(classes)} class=${classMap(classes)}
.hass=${this.hass} .hass=${this.hass}
.cameraConfig=${this.cameraConfig} .camera=${this.camera}
.cameraEndpoints=${this.cameraEndpoints} .cameraEndpoints=${this.cameraEndpoints}
.microphoneState=${this.microphoneState} .microphoneState=${this.microphoneState}
.microphoneConfig=${this.liveConfig.microphone} .microphoneConfig=${this.liveConfig.microphone}
@@ -343,7 +344,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
${ref(this._refProvider)} ${ref(this._refProvider)}
class=${classMap(classes)} class=${classMap(classes)}
.hass=${this.hass} .hass=${this.hass}
.cameraConfig=${this.cameraConfig} .cameraConfig=${cameraConfig}
.cameraEndpoints=${this.cameraEndpoints} .cameraEndpoints=${this.cameraEndpoints}
.cardWideConfig=${this.cardWideConfig} .cardWideConfig=${this.cardWideConfig}
?controls=${this.liveConfig.controls.builtin} ?controls=${this.liveConfig.controls.builtin}
@@ -355,7 +356,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
${ref(this._refProvider)} ${ref(this._refProvider)}
class=${classMap(classes)} class=${classMap(classes)}
.hass=${this.hass} .hass=${this.hass}
.cameraConfig=${this.cameraConfig} .cameraConfig=${cameraConfig}
.cameraEndpoints=${this.cameraEndpoints} .cameraEndpoints=${this.cameraEndpoints}
.cardWideConfig=${this.cardWideConfig} .cardWideConfig=${this.cardWideConfig}
@advanced-camera-card:live:error=${() => this._providerErrorHandler()} @advanced-camera-card:live:error=${() => this._providerErrorHandler()}
+84 -22
View File
@@ -7,17 +7,23 @@ import {
unsafeCSS, unsafeCSS,
} from 'lit'; } from 'lit';
import { customElement, property, state } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
import { Camera } from '../../../../camera-manager/camera.js';
import { CameraEndpoints } from '../../../../camera-manager/types.js'; import { CameraEndpoints } from '../../../../camera-manager/types.js';
import { MicrophoneState } from '../../../../card-controller/types.js'; import { MicrophoneState } from '../../../../card-controller/types.js';
import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js'; import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js';
import { VideoMediaPlayerController } from '../../../../components-lib/media-player/video.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 { MicrophoneConfig } from '../../../../config/schema/live.js';
import { homeAssistantSignPath } from '../../../../ha/sign-path.js';
import { HomeAssistant } from '../../../../ha/types.js'; import { HomeAssistant } from '../../../../ha/types.js';
import {
addDynamicProxyURL,
getWebProxiedURL,
shouldUseWebProxy,
} from '../../../../ha/web-proxy.js';
import { localize } from '../../../../localize/localize.js'; import { localize } from '../../../../localize/localize.js';
import liveGo2RTCStyle from '../../../../scss/live-go2rtc.scss'; import liveGo2RTCStyle from '../../../../scss/live-go2rtc.scss';
import { MediaPlayer, MediaPlayerController, Message } from '../../../../types.js'; 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 { renderMessage } from '../../../message.js';
import { VideoRTC } from './video-rtc.js'; import { VideoRTC } from './video-rtc.js';
@@ -36,7 +42,7 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
public hass?: HomeAssistant; public hass?: HomeAssistant;
@property({ attribute: false }) @property({ attribute: false })
public cameraConfig?: CameraConfig; public camera?: Camera;
@property({ attribute: false }) @property({ attribute: false })
public cameraEndpoints?: CameraEndpoints; public cameraEndpoints?: CameraEndpoints;
@@ -79,46 +85,102 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
this.requestUpdate(); this.requestUpdate();
} }
protected async _createPlayer(): Promise<void> { protected _handleError(message: Message, e?: Error): void {
if (!this.hass) { if (e) {
errorToConsole(e as Error);
}
this._message = {
type: 'error',
...message,
};
dispatchLiveErrorEvent(this);
return; return;
} }
protected async _getPlayerSource(): Promise<string | null> {
const cameraConfig = this.camera?.getConfig();
if (!this.hass || !cameraConfig) {
return null;
}
const endpoint = this.cameraEndpoints?.go2rtc; const endpoint = this.cameraEndpoints?.go2rtc;
if (!endpoint) { if (!endpoint) {
this._message = { this._handleError({
type: 'error',
message: localize('error.live_camera_no_endpoint'), message: localize('error.live_camera_no_endpoint'),
context: this.cameraConfig, context: cameraConfig,
}; });
dispatchLiveErrorEvent(this); return null;
return;
} }
const address = await convertEndpointAddressToSignedWebsocket( 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, this.hass,
endpoint, src,
GO2RTC_URL_SIGN_EXPIRY_SECONDS, GO2RTC_URL_SIGN_EXPIRY_SECONDS,
); );
if (!address) { } catch (e) {
this._message = { this._handleError(
type: 'error', {
message: localize('error.failed_sign'), message: localize('error.failed_sign'),
context: this.cameraConfig, context: cameraConfig,
}; },
dispatchLiveErrorEvent(this); e as Error,
);
return null;
}
}
return src;
}
protected async _createPlayer(): Promise<void> {
const src = await this._getPlayerSource();
if (!src) {
return; return;
} }
this._player = new VideoRTC(); this._player = new VideoRTC();
this._player.mediaPlayerController = this._mediaPlayerController; this._player.mediaPlayerController = this._mediaPlayerController;
this._player.microphoneStream = this.microphoneState?.stream ?? null; this._player.microphoneStream = this.microphoneState?.stream ?? null;
this._player.src = address; this._player.src = src;
this._player.visibilityCheck = false; this._player.visibilityCheck = false;
this._player.setControls(this.controls); this._player.setControls(this.controls);
if (this.cameraConfig?.go2rtc?.modes && this.cameraConfig.go2rtc.modes.length) { const cameraConfig = this.camera?.getConfig();
this._player.mode = this.cameraConfig.go2rtc.modes.join(','); if (cameraConfig?.go2rtc?.modes && cameraConfig.go2rtc.modes.length) {
this._player.mode = cameraConfig.go2rtc.modes.join(',');
} }
this.requestUpdate(); this.requestUpdate();
+11 -3
View File
@@ -14,11 +14,12 @@ import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispa
import { JSMPEGMediaPlayerController } from '../../../components-lib/media-player/jsmpeg.js'; import { JSMPEGMediaPlayerController } from '../../../components-lib/media-player/jsmpeg.js';
import { CameraConfig } from '../../../config/schema/cameras.js'; import { CameraConfig } from '../../../config/schema/cameras.js';
import { CardWideConfig } from '../../../config/schema/types.js'; import { CardWideConfig } from '../../../config/schema/types.js';
import { homeAssistantSignPath } from '../../../ha/sign-path.js';
import { HomeAssistant } from '../../../ha/types.js'; import { HomeAssistant } from '../../../ha/types.js';
import { localize } from '../../../localize/localize.js'; import { localize } from '../../../localize/localize.js';
import liveJSMPEGStyle from '../../../scss/live-jsmpeg.scss'; import liveJSMPEGStyle from '../../../scss/live-jsmpeg.scss';
import { MediaPlayer, MediaPlayerController, Message } from '../../../types.js'; import { MediaPlayer, MediaPlayerController, Message } from '../../../types.js';
import { convertEndpointAddressToSignedWebsocket } from '../../../utils/endpoint.js'; import { convertHTTPAdressToWebsocket, errorToConsole } from '../../../utils/basic.js';
import { import {
dispatchMediaLoadedEvent, dispatchMediaLoadedEvent,
dispatchMediaPauseEvent, dispatchMediaPauseEvent,
@@ -183,11 +184,18 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
return; return;
} }
const address = await convertEndpointAddressToSignedWebsocket( let response: string | null | undefined;
try {
response = await homeAssistantSignPath(
this.hass, this.hass,
endpoint, endpoint.endpoint,
JSMPEG_URL_SIGN_EXPIRY_SECONDS, JSMPEG_URL_SIGN_EXPIRY_SECONDS,
); );
} catch (e) {
errorToConsole(e as Error);
}
const address = response ? convertHTTPAdressToWebsocket(response) : null;
if (!address) { if (!address) {
this._message = { this._message = {
type: 'error', type: 'error',
+6 -2
View File
@@ -158,13 +158,17 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
// Don't use URL() parsing, since that will strip the port number if // 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. // it's the default, just need to strip any hash part of the URL.
const urlWithoutQSorHash = unsignedURL.split(/#/)[0]; const urlWithoutQSorHash = unsignedURL.split(/#/)[0];
try {
await addDynamicProxyURL(this.hass, urlWithoutQSorHash, { await addDynamicProxyURL(this.hass, urlWithoutQSorHash, {
sslVerification: proxyConfig.ssl_verification, proxyConfig,
sslCiphers: proxyConfig.ssl_ciphers,
// The link may need to be opened multiple times. // The link may need to be opened multiple times.
openLimit: 0, openLimit: 0,
}); });
} catch (e) {
errorToConsole(e as Error);
}
} }
try { try {
+4 -3
View File
@@ -121,10 +121,11 @@ export const cameraConfigDefault = {
entities: [], entities: [],
}, },
proxy: { proxy: {
media: 'auto' as const,
dynamic: true, dynamic: true,
ssl_verification: 'auto' as const, live: 'auto' as const,
media: 'auto' as const,
ssl_ciphers: 'auto' as const, ssl_ciphers: 'auto' as const,
ssl_verification: 'auto' as const,
}, },
always_error_if_entity_unavailable: false, 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]; export type SSLCiphers = (typeof SSL_CIPHERS)[number];
const proxyConfigSchema = z.object({ 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), media: z.boolean().or(z.literal('auto')).default(cameraConfigDefault.proxy.media),
dynamic: z.boolean().default(cameraConfigDefault.proxy.dynamic), dynamic: z.boolean().default(cameraConfigDefault.proxy.dynamic),
ssl_verification: z ssl_verification: z
@@ -144,7 +146,6 @@ const proxyConfigSchema = z.object({
.or(z.literal('auto')) .or(z.literal('auto'))
.default(cameraConfigDefault.proxy.ssl_ciphers), .default(cameraConfigDefault.proxy.ssl_ciphers),
}); });
export type ProxyConfig = z.infer<typeof proxyConfigSchema>;
const rotationSchema = z const rotationSchema = z
.literal(0) .literal(0)
+1
View File
@@ -92,6 +92,7 @@ export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_ZOOM_FACTOR =
`${CONF_CAMERAS}.#.dimensions.layout.zoom` as const; `${CONF_CAMERAS}.#.dimensions.layout.zoom` as const;
export const CONF_CAMERAS_ARRAY_PROXY_DYNAMIC = export const CONF_CAMERAS_ARRAY_PROXY_DYNAMIC =
`${CONF_CAMERAS}.#.proxy.dynamic` as const; `${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_MEDIA = `${CONF_CAMERAS}.#.proxy.media` as const;
export const CONF_CAMERAS_ARRAY_PROXY_SSL_CIPHERS = export const CONF_CAMERAS_ARRAY_PROXY_SSL_CIPHERS =
`${CONF_CAMERAS}.#.proxy.ssl_ciphers` as const; `${CONF_CAMERAS}.#.proxy.ssl_ciphers` as const;
+15 -7
View File
@@ -77,6 +77,7 @@ import {
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_FILE_PATTERN, CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_FILE_PATTERN,
CONF_CAMERAS_ARRAY_MOTIONEYE_URL, CONF_CAMERAS_ARRAY_MOTIONEYE_URL,
CONF_CAMERAS_ARRAY_PROXY_DYNAMIC, CONF_CAMERAS_ARRAY_PROXY_DYNAMIC,
CONF_CAMERAS_ARRAY_PROXY_LIVE,
CONF_CAMERAS_ARRAY_PROXY_MEDIA, CONF_CAMERAS_ARRAY_PROXY_MEDIA,
CONF_CAMERAS_ARRAY_PROXY_SSL_CIPHERS, CONF_CAMERAS_ARRAY_PROXY_SSL_CIPHERS,
CONF_CAMERAS_ARRAY_PROXY_SSL_VERIFICATION, 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: '', label: '' },
{ {
value: 'auto', value: 'auto',
label: localize('config.cameras.proxy.media.auto'), label: localize('config.cameras.proxy.modes.auto'),
}, },
{ {
value: true, value: true,
label: localize('config.cameras.proxy.media.true'), label: localize('config.cameras.proxy.modes.true'),
}, },
{ {
value: false, 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', 'mdi:arrow-decision',
html` html`
${this._renderOptionSelector( ${this._renderOptionSelector(
getArrayConfigPath(CONF_CAMERAS_ARRAY_PROXY_MEDIA, cameraIndex), getArrayConfigPath(CONF_CAMERAS_ARRAY_PROXY_LIVE, cameraIndex),
this._proxyMedia, 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( ${this._renderSwitch(
+21 -13
View File
@@ -7,30 +7,37 @@ const hasWebProxyAvailable = (hass: HomeAssistant): boolean => {
return hass.config.components.includes(HASS_WEB_PROXY_DOMAIN); return hass.config.components.includes(HASS_WEB_PROXY_DOMAIN);
}; };
export const getWebProxiedURL = (url: string, v?: number): string => { interface ProxiedURLOptions {
return `/api/${HASS_WEB_PROXY_DOMAIN}/v${v ?? 0}/?url=${encodeURIComponent(url)}`; 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 = ( export const shouldUseWebProxy = (
hass: HomeAssistant, hass: HomeAssistant,
proxyConfig: CameraProxyConfig, proxyConfig: CameraProxyConfig,
context: 'media' = 'media', context: 'media' | 'live' = 'media',
): boolean => { ): boolean => {
return hasWebProxyAvailable(hass) && !!proxyConfig[context]; 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( export async function addDynamicProxyURL(
hass: HomeAssistant, hass: HomeAssistant,
url_pattern: string, url_pattern: string,
options?: { options?: {
proxyConfig?: CameraProxyConfig;
urlID?: string; urlID?: string;
sslVerification?: boolean; sslVerification?: boolean;
sslCiphers?: string; sslCiphers?: string;
@@ -43,8 +50,9 @@ export async function addDynamicProxyURL(
url_pattern: url_pattern, url_pattern: url_pattern,
...(options && { ...(options && {
url_id: options.urlID, url_id: options.urlID,
ssl_verification: options.sslVerification, ssl_verification:
ssl_ciphers: options.sslCiphers, options.sslVerification ?? options?.proxyConfig?.ssl_verification,
ssl_ciphers: options.sslCiphers ?? options?.proxyConfig?.ssl_ciphers,
open_limit: options.openLimit, open_limit: options.openLimit,
ttl: options.ttl, ttl: options.ttl,
allow_unauthenticated: options.allowUnauthenticated, allow_unauthenticated: options.allowUnauthenticated,
+6 -4
View File
@@ -83,9 +83,9 @@
"rotation": "", "rotation": "",
"rotations": { "rotations": {
"0": "", "0": "",
"90": "",
"180": "", "180": "",
"270": "" "270": "",
"90": ""
} }
}, },
"engines": { "engines": {
@@ -145,9 +145,10 @@
"proxy": { "proxy": {
"dynamic": "", "dynamic": "",
"editor_label": "", "editor_label": "",
"media": { "live": "",
"media": "",
"modes": {
"auto": "", "auto": "",
"editor_label": "",
"false": "", "false": "",
"true": "" "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_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": "", "duplicate_folder_id": "",
"empty_response": "S'ha rebut una resposta buida de Home Assistant per a la sol·licitud", "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_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_retain": "No s'ha pogut retenir l'esdeveniment",
"failed_sign": "No s'ha pogut signar l'URL de Home Assistant", "failed_sign": "No s'ha pogut signar l'URL de Home Assistant",
+12 -10
View File
@@ -83,9 +83,9 @@
"rotation": "Rotation", "rotation": "Rotation",
"rotations": { "rotations": {
"0": "No rotation", "0": "No rotation",
"90": "90 degrees clockwise",
"180": "180 degrees clockwise", "180": "180 degrees clockwise",
"270": "270 degrees clockwise" "270": "270 degrees clockwise",
"90": "90 degrees clockwise"
} }
}, },
"engines": { "engines": {
@@ -145,11 +145,12 @@
"proxy": { "proxy": {
"dynamic": "Dynamic proxying", "dynamic": "Dynamic proxying",
"editor_label": "Camera proxying", "editor_label": "Camera proxying",
"media": { "live": "Live proxying",
"auto": "Media proxying automatically configured", "media": "Media proxying",
"editor_label": "Media proxying", "modes": {
"false": "Media proxying disabled", "auto": "Proxying automatically configured",
"true": "Media proxying enabled" "false": "Proxying disabled",
"true": "Proxying enabled"
}, },
"ssl_ciphers": { "ssl_ciphers": {
"auto": "SSL ciphers automatically configured", "auto": "SSL ciphers automatically configured",
@@ -330,8 +331,8 @@
"folders": { "folders": {
"ha": { "ha": {
"editor_label": "Home Assistant Media Folder Options", "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", "icon": "Icon for this folder",
"id": "Unique ID for this folder in this card", "id": "Unique ID for this folder in this card",
@@ -665,8 +666,8 @@
} }
}, },
"error": { "error": {
"awaiting_live": "Waiting for live stream to load ...",
"awaiting_folder": "Waiting for folder to load", "awaiting_folder": "Waiting for folder to load",
"awaiting_live": "Waiting for live stream to load ...",
"awaiting_media": "Waiting for media to load", "awaiting_media": "Waiting for media to load",
"camera_initialization": "Camera initialization failed", "camera_initialization": "Camera initialization failed",
"camera_initialization_reolink": "Could not initialize Reolink camera", "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_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", "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", "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_response": "Failed to receive response from Home Assistant for request",
"failed_retain": "Could not retain event", "failed_retain": "Could not retain event",
"failed_sign": "Could not sign Home Assistant URL", "failed_sign": "Could not sign Home Assistant URL",
+9 -7
View File
@@ -83,9 +83,9 @@
"rotation": "", "rotation": "",
"rotations": { "rotations": {
"0": "", "0": "",
"90": "",
"180": "", "180": "",
"270": "" "270": "",
"90": ""
} }
}, },
"engines": { "engines": {
@@ -145,11 +145,12 @@
"proxy": { "proxy": {
"dynamic": "Proxy dynamique", "dynamic": "Proxy dynamique",
"editor_label": "Proxy de la caméra", "editor_label": "Proxy de la caméra",
"media": { "live": "",
"auto": "Proxy média automatique", "media": "",
"editor_label": "Proxy média", "modes": {
"false": "Proxy média désactivé", "auto": "Proxy automatique",
"true": "Proxy média activé" "false": "Proxy désactivé",
"true": "Proxy activé"
}, },
"ssl_ciphers": { "ssl_ciphers": {
"auto": "Chiffrement SSL automatique", "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_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": "", "duplicate_folder_id": "",
"empty_response": "Réponse vide reçue de Home Assistant pour la demande", "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_response": "Échec de la réponse de Home Assistant à la demande",
"failed_retain": "Impossible de conserver l'événement", "failed_retain": "Impossible de conserver l'événement",
"failed_sign": "Impossible de signer l'URL de Home Assistant", "failed_sign": "Impossible de signer l'URL de Home Assistant",
+6 -4
View File
@@ -83,9 +83,9 @@
"rotation": "", "rotation": "",
"rotations": { "rotations": {
"0": "", "0": "",
"90": "",
"180": "", "180": "",
"270": "" "270": "",
"90": ""
} }
}, },
"engines": { "engines": {
@@ -145,9 +145,10 @@
"proxy": { "proxy": {
"dynamic": "", "dynamic": "",
"editor_label": "", "editor_label": "",
"media": { "live": "",
"media": "",
"modes": {
"auto": "", "auto": "",
"editor_label": "",
"false": "", "false": "",
"true": "" "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_camera_id": "Duplicato ID dellla telecamera Frigate, utilizzare il parametro 'ID' per identificare in modo univoco le telecamere",
"duplicate_folder_id": "", "duplicate_folder_id": "",
"empty_response": "Ricevuto risposta vuota da Home Assistant per la richiesta", "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_response": "Impossibile ricevere risposta da Home Assistant per la richiesta",
"failed_retain": "Impossibile conservare l'evento", "failed_retain": "Impossibile conservare l'evento",
"failed_sign": "Impossibile firmare l'URL ad Home Assistant", "failed_sign": "Impossibile firmare l'URL ad Home Assistant",
+6 -4
View File
@@ -83,9 +83,9 @@
"rotation": "", "rotation": "",
"rotations": { "rotations": {
"0": "", "0": "",
"90": "",
"180": "", "180": "",
"270": "" "270": "",
"90": ""
} }
}, },
"engines": { "engines": {
@@ -145,9 +145,10 @@
"proxy": { "proxy": {
"dynamic": "", "dynamic": "",
"editor_label": "", "editor_label": "",
"media": { "live": "",
"media": "",
"modes": {
"auto": "", "auto": "",
"editor_label": "",
"false": "", "false": "",
"true": "" "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_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": "", "duplicate_folder_id": "",
"empty_response": "Sem resposta do Home Assistant para a solicitação", "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_response": "Falha ao receber resposta do Home Assistant para solicitação",
"failed_retain": "Não foi possível reter o evento", "failed_retain": "Não foi possível reter o evento",
"failed_sign": "Não foi possível assinar a URL do Home Assistant", "failed_sign": "Não foi possível assinar a URL do Home Assistant",
+6 -4
View File
@@ -83,9 +83,9 @@
"rotation": "", "rotation": "",
"rotations": { "rotations": {
"0": "", "0": "",
"90": "",
"180": "", "180": "",
"270": "" "270": "",
"90": ""
} }
}, },
"engines": { "engines": {
@@ -145,9 +145,10 @@
"proxy": { "proxy": {
"dynamic": "", "dynamic": "",
"editor_label": "", "editor_label": "",
"media": { "live": "",
"media": "",
"modes": {
"auto": "", "auto": "",
"editor_label": "",
"false": "", "false": "",
"true": "" "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_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": "", "duplicate_folder_id": "",
"empty_response": "Sem resposta do Home Assistant para a solicitação", "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_response": "Falha ao receber resposta do Home Assistant para solicitação",
"failed_retain": "Não foi possível reter o evento", "failed_retain": "Não foi possível reter o evento",
"failed_sign": "Não foi possível assinar a URL do Home Assistant", "failed_sign": "Não foi possível assinar a URL do Home Assistant",
+4
View File
@@ -323,3 +323,7 @@ export const generateFloatApproximatelyEqualsCustomizer = (
: undefined; : undefined;
}; };
}; };
export const convertHTTPAdressToWebsocket = (url: string): string => {
return url.replace(/^http/i, 'ws');
};
-23
View File
@@ -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<string | null> => {
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;
};
+58 -18
View File
@@ -4,7 +4,6 @@ import { Camera } from '../../src/camera-manager/camera.js';
import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic.js'; import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic.js';
import { CameraProxyConfig } from '../../src/camera-manager/types.js'; import { CameraProxyConfig } from '../../src/camera-manager/types.js';
import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js'; import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
import { ProxyConfig } from '../../src/config/schema/cameras.js';
import { import {
callStateWatcherCallback, callStateWatcherCallback,
createCameraConfig, createCameraConfig,
@@ -179,6 +178,7 @@ describe('Camera', () => {
{}, {},
{ {
dynamic: true, dynamic: true,
live: false,
media: false, media: false,
ssl_verification: true, ssl_verification: true,
ssl_ciphers: 'default' as const, ssl_ciphers: 'default' as const,
@@ -186,9 +186,10 @@ describe('Camera', () => {
], ],
[ [
'when media set to true', 'when media set to true',
{ media: true }, { proxy: { media: true } },
{ {
dynamic: true, dynamic: true,
live: false,
media: true, media: true,
ssl_verification: true, ssl_verification: true,
ssl_ciphers: 'default' as const, ssl_ciphers: 'default' as const,
@@ -196,9 +197,10 @@ describe('Camera', () => {
], ],
[ [
'when media set to false', 'when media set to false',
{ media: false as const }, { proxy: { media: false as const } },
{ {
dynamic: true, dynamic: true,
live: false,
media: false, media: false,
ssl_verification: true, ssl_verification: true,
ssl_ciphers: 'default' as const, ssl_ciphers: 'default' as const,
@@ -206,9 +208,10 @@ describe('Camera', () => {
], ],
[ [
'when media set to auto', 'when media set to auto',
{ media: 'auto' as const }, { proxy: { media: 'auto' as const } },
{ {
dynamic: true, dynamic: true,
live: false,
media: false, media: false,
ssl_verification: true, ssl_verification: true,
ssl_ciphers: 'default' as const, ssl_ciphers: 'default' as const,
@@ -216,9 +219,10 @@ describe('Camera', () => {
], ],
[ [
'when ssl_verification is set to auto', 'when ssl_verification is set to auto',
{ ssl_verification: 'auto' as const }, { proxy: { ssl_verification: 'auto' as const } },
{ {
dynamic: true, dynamic: true,
live: false,
media: false, media: false,
ssl_verification: true, ssl_verification: true,
ssl_ciphers: 'default' as const, ssl_ciphers: 'default' as const,
@@ -226,9 +230,10 @@ describe('Camera', () => {
], ],
[ [
'when ssl_verification is set to true', 'when ssl_verification is set to true',
{ ssl_verification: true }, { proxy: { ssl_verification: true } },
{ {
dynamic: true, dynamic: true,
live: false,
media: false, media: false,
ssl_verification: true, ssl_verification: true,
ssl_ciphers: 'default' as const, ssl_ciphers: 'default' as const,
@@ -236,9 +241,10 @@ describe('Camera', () => {
], ],
[ [
'when ssl_verification is set to false', 'when ssl_verification is set to false',
{ ssl_verification: false }, { proxy: { ssl_verification: false } },
{ {
dynamic: true, dynamic: true,
live: false,
media: false, media: false,
ssl_verification: false, ssl_verification: false,
ssl_ciphers: 'default' as const, ssl_ciphers: 'default' as const,
@@ -246,9 +252,10 @@ describe('Camera', () => {
], ],
[ [
'when ssl_ciphers is set to auto', 'when ssl_ciphers is set to auto',
{ ssl_ciphers: 'auto' as const }, { proxy: { ssl_ciphers: 'auto' as const } },
{ {
dynamic: true, dynamic: true,
live: false,
media: false, media: false,
ssl_verification: true, ssl_verification: true,
ssl_ciphers: 'default' as const, ssl_ciphers: 'default' as const,
@@ -256,9 +263,10 @@ describe('Camera', () => {
], ],
[ [
'when ssl_ciphers is set to modern', 'when ssl_ciphers is set to modern',
{ ssl_ciphers: 'modern' as const }, { proxy: { ssl_ciphers: 'modern' as const } },
{ {
dynamic: true, dynamic: true,
live: false,
media: false, media: false,
ssl_verification: true, ssl_verification: true,
ssl_ciphers: 'modern' as const, ssl_ciphers: 'modern' as const,
@@ -266,9 +274,47 @@ describe('Camera', () => {
], ],
[ [
'when dynamic is set to false', 'when dynamic is set to false',
{ dynamic: false }, { proxy: { dynamic: false } },
{ {
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, media: false,
ssl_verification: true, ssl_verification: true,
ssl_ciphers: 'default' as const, ssl_ciphers: 'default' as const,
@@ -276,15 +322,9 @@ describe('Camera', () => {
], ],
])( ])(
'%s', '%s',
( (_name: string, cameraConfig: unknown, expectedResult: CameraProxyConfig) => {
_name: string,
proxyConfig: Partial<ProxyConfig>,
expectedResult: CameraProxyConfig,
) => {
const camera = new Camera( const camera = new Camera(
createCameraConfig({ createCameraConfig(cameraConfig),
proxy: proxyConfig,
}),
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()), new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
); );
expect(camera.getProxyConfig()).toEqual(expectedResult); expect(camera.getProxyConfig()).toEqual(expectedResult);
+58 -18
View File
@@ -5,7 +5,6 @@ import { ReolinkCamera } from '../../../src/camera-manager/reolink/camera';
import { CameraProxyConfig } from '../../../src/camera-manager/types'; import { CameraProxyConfig } from '../../../src/camera-manager/types';
import { ActionsExecutor } from '../../../src/card-controller/actions/types'; import { ActionsExecutor } from '../../../src/card-controller/actions/types';
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
import { ProxyConfig } from '../../../src/config/schema/cameras';
import { EntityRegistryManagerLive } from '../../../src/ha/registry/entity'; import { EntityRegistryManagerLive } from '../../../src/ha/registry/entity';
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock'; import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
import { import {
@@ -341,6 +340,7 @@ describe('ReolinkCamera', () => {
{}, {},
{ {
dynamic: true, dynamic: true,
live: false,
media: true, media: true,
ssl_verification: false, ssl_verification: false,
ssl_ciphers: 'intermediate' as const, ssl_ciphers: 'intermediate' as const,
@@ -348,9 +348,10 @@ describe('ReolinkCamera', () => {
], ],
[ [
'when media set to on', 'when media set to on',
{ media: true }, { proxy: { media: true } },
{ {
dynamic: true, dynamic: true,
live: false,
media: true, media: true,
ssl_verification: false, ssl_verification: false,
ssl_ciphers: 'intermediate' as const, ssl_ciphers: 'intermediate' as const,
@@ -358,9 +359,10 @@ describe('ReolinkCamera', () => {
], ],
[ [
'when media set to off', 'when media set to off',
{ media: false }, { proxy: { media: false } },
{ {
dynamic: true, dynamic: true,
live: false,
media: false, media: false,
ssl_verification: false, ssl_verification: false,
ssl_ciphers: 'intermediate' as const, ssl_ciphers: 'intermediate' as const,
@@ -368,9 +370,10 @@ describe('ReolinkCamera', () => {
], ],
[ [
'when media set to auto', 'when media set to auto',
{ media: 'auto' as const }, { proxy: { media: 'auto' as const } },
{ {
dynamic: true, dynamic: true,
live: false,
media: true, media: true,
ssl_verification: false, ssl_verification: false,
ssl_ciphers: 'intermediate' as const, ssl_ciphers: 'intermediate' as const,
@@ -378,9 +381,10 @@ describe('ReolinkCamera', () => {
], ],
[ [
'when ssl_verification is set to auto', 'when ssl_verification is set to auto',
{ ssl_verification: 'auto' as const }, { proxy: { ssl_verification: 'auto' as const } },
{ {
dynamic: true, dynamic: true,
live: false,
media: true, media: true,
ssl_verification: false, ssl_verification: false,
ssl_ciphers: 'intermediate' as const, ssl_ciphers: 'intermediate' as const,
@@ -388,9 +392,10 @@ describe('ReolinkCamera', () => {
], ],
[ [
'when ssl_verification is set to true', 'when ssl_verification is set to true',
{ ssl_verification: true }, { proxy: { ssl_verification: true } },
{ {
dynamic: true, dynamic: true,
live: false,
media: true, media: true,
ssl_verification: true, ssl_verification: true,
ssl_ciphers: 'intermediate' as const, ssl_ciphers: 'intermediate' as const,
@@ -398,9 +403,10 @@ describe('ReolinkCamera', () => {
], ],
[ [
'when ssl_verification is set to false', 'when ssl_verification is set to false',
{ ssl_verification: false }, { proxy: { ssl_verification: false } },
{ {
dynamic: true, dynamic: true,
live: false,
media: true, media: true,
ssl_verification: false, ssl_verification: false,
ssl_ciphers: 'intermediate' as const, ssl_ciphers: 'intermediate' as const,
@@ -408,9 +414,10 @@ describe('ReolinkCamera', () => {
], ],
[ [
'when ssl_ciphers is set to auto', 'when ssl_ciphers is set to auto',
{ ssl_ciphers: 'auto' as const }, { proxy: { ssl_ciphers: 'auto' as const } },
{ {
dynamic: true, dynamic: true,
live: false,
media: true, media: true,
ssl_verification: false, ssl_verification: false,
ssl_ciphers: 'intermediate' as const, ssl_ciphers: 'intermediate' as const,
@@ -418,9 +425,10 @@ describe('ReolinkCamera', () => {
], ],
[ [
'when ssl_ciphers is set to modern', 'when ssl_ciphers is set to modern',
{ ssl_ciphers: 'modern' as const }, { proxy: { ssl_ciphers: 'modern' as const } },
{ {
dynamic: true, dynamic: true,
live: false,
media: true, media: true,
ssl_verification: false, ssl_verification: false,
ssl_ciphers: 'modern' as const, ssl_ciphers: 'modern' as const,
@@ -428,9 +436,47 @@ describe('ReolinkCamera', () => {
], ],
[ [
'when dynamic is set to false', 'when dynamic is set to false',
{ dynamic: false }, { proxy: { dynamic: false } },
{ {
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, media: true,
ssl_verification: false, ssl_verification: false,
ssl_ciphers: 'intermediate' as const, ssl_ciphers: 'intermediate' as const,
@@ -438,15 +484,9 @@ describe('ReolinkCamera', () => {
], ],
])( ])(
'%s', '%s',
( (_name: string, cameraConfig: unknown, expectedResult: CameraProxyConfig) => {
_name: string,
proxyConfig: Partial<ProxyConfig>,
expectedResult: CameraProxyConfig,
) => {
const camera = new ReolinkCamera( const camera = new ReolinkCamera(
createCameraConfig({ createCameraConfig(cameraConfig),
proxy: proxyConfig,
}),
mock<CameraManagerEngine>(), mock<CameraManagerEngine>(),
); );
expect(camera.getProxyConfig()).toEqual(expectedResult); expect(camera.getProxyConfig()).toEqual(expectedResult);
+1
View File
@@ -40,6 +40,7 @@ describe('config defaults', () => {
}, },
proxy: { proxy: {
dynamic: true, dynamic: true,
live: 'auto',
media: 'auto', media: 'auto',
ssl_verification: 'auto', ssl_verification: 'auto',
ssl_ciphers: 'auto', ssl_ciphers: 'auto',
+36 -5
View File
@@ -8,15 +8,21 @@ import {
import { createHASS } from '../test-utils.js'; import { createHASS } from '../test-utils.js';
describe('getWebProxiedURL', () => { describe('getWebProxiedURL', () => {
it('should return proxied URL with v != 0', () => { it('should return proxied URL with default version', () => {
expect(getWebProxiedURL('http://example.com', 2)).toBe( 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', '/api/hass_web_proxy/v2/?url=http%3A%2F%2Fexample.com',
); );
}); });
it('should return proxied URL with default v', () => { it('should return proxied URL with websocket', () => {
expect(getWebProxiedURL('http://example.com')).toBe( expect(getWebProxiedURL('http://example.com', { websocket: true })).toBe(
'/api/hass_web_proxy/v0/?url=http%3A%2F%2Fexample.com', '/api/hass_web_proxy/v0/ws?url=http%3A%2F%2Fexample.com',
); );
}); });
}); });
@@ -26,6 +32,7 @@ describe('shouldUseWebProxy', () => {
config: Partial<CameraProxyConfig> = {}, config: Partial<CameraProxyConfig> = {},
): CameraProxyConfig => ({ ): CameraProxyConfig => ({
media: true, media: true,
live: true,
ssl_verification: true, ssl_verification: true,
ssl_ciphers: 'default', ssl_ciphers: 'default',
dynamic: true, 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',
}),
);
});
}); });
+21
View File
@@ -7,6 +7,7 @@ import {
arrayMove, arrayMove,
aspectRatioToStyle, aspectRatioToStyle,
contentsChanged, contentsChanged,
convertHTTPAdressToWebsocket,
dayToDate, dayToDate,
desparsifyArrays, desparsifyArrays,
errorToConsole, 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');
});
});
-60
View File
@@ -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();
});
});
});