From 1e821f09c692ff1b95bac5883406b828e383adc8 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 20 Dec 2025 13:06:09 -0800 Subject: [PATCH] fix: Improve 2-way audio detection (#2293) - For Frigate cameras, you must be running at least [integration v5.12.0](https://github.com/blakeblackshear/frigate-hass-integration/releases/tag/v5.12.0). - Closes: #2191 Includes a significant refactor of cameras, and the introduction of a `2-way-audio` capability that is dynamically fetched from `go2rtc`. One gotcha is if you previously had a substream with 2-way audio, you may need to modify ```yaml - camera_entity: camera.foo capabilities: disable_except: - substream ``` ... to ... ```yaml - camera_entity: camera.foo capabilities: disable_except: - substream - 2-way-audio ``` --- docs/configuration/cameras/README.md | 2 + src/camera-manager/camera.ts | 126 ++++++- src/camera-manager/engine.ts | 7 - src/camera-manager/entity-camera.ts | 2 - src/camera-manager/frigate/camera.ts | 191 +++++++--- src/camera-manager/frigate/engine-frigate.ts | 88 +---- src/camera-manager/generic/engine-generic.ts | 57 +-- src/camera-manager/manager.ts | 7 +- src/camera-manager/motioneye/camera.ts | 23 +- .../motioneye/engine-motioneye.ts | 45 +-- src/camera-manager/reolink/camera.ts | 91 +++-- src/camera-manager/reolink/engine-reolink.ts | 21 -- src/camera-manager/tplink/camera.ts | 62 +--- src/camera-manager/utils/go2rtc-endpoint.ts | 25 -- src/camera-manager/utils/go2rtc/audio.ts | 69 ++++ src/camera-manager/utils/go2rtc/endpoint.ts | 51 +++ src/camera-manager/utils/go2rtc/types.ts | 16 + src/components-lib/menu-button-controller.ts | 14 +- src/components/live/provider.ts | 27 +- src/components/live/providers/go2rtc/index.ts | 82 ++--- .../live/providers/go2rtc/video-rtc.js | 54 ++- src/components/viewer/provider.ts | 58 ++- src/ha/fetch.ts | 89 +++++ src/ha/resolved-media.ts | 2 +- src/ha/sign-path.ts | 3 +- src/ha/web-proxy.ts | 43 +++ src/ha/ws-request.ts | 1 - src/localize/languages/en.json | 1 + src/patches/ha-web-rtc-player.ts | 32 +- src/types.ts | 10 +- src/utils/audio.ts | 107 ++++++ src/utils/live-provider.ts | 36 ++ tests/camera-manager/camera.test.ts | 145 ++++++-- tests/camera-manager/frigate/camera.test.ts | 345 +++++++++++++++++- .../frigate/engine-frigate.test.ts | 265 +------------- .../generic/engine-generic.test.ts | 48 +-- tests/camera-manager/manager.test.ts | 11 +- .../motioneye/engine-motioneye.test.ts | 25 ++ .../reolink/engine-reolink.test.ts | 59 +-- tests/camera-manager/store.test.ts | 42 +-- .../tplink/engine-tplink.test.ts | 7 +- .../utils/go2rtc-endpoint.test.ts | 52 ++- .../menu-button-controller.test.ts | 109 +++--- tests/ha/fetch.test.ts | 192 ++++++++++ tests/ha/web-proxy.test.ts | 159 ++++++++ tests/test-utils.ts | 24 +- tests/utils/audio.test.ts | 273 +++++++++++++- 47 files changed, 2240 insertions(+), 958 deletions(-) delete mode 100644 src/camera-manager/utils/go2rtc-endpoint.ts create mode 100644 src/camera-manager/utils/go2rtc/audio.ts create mode 100644 src/camera-manager/utils/go2rtc/endpoint.ts create mode 100644 src/camera-manager/utils/go2rtc/types.ts create mode 100644 src/ha/fetch.ts create mode 100644 src/utils/live-provider.ts create mode 100644 tests/camera-manager/motioneye/engine-motioneye.test.ts create mode 100644 tests/ha/fetch.test.ts diff --git a/docs/configuration/cameras/README.md b/docs/configuration/cameras/README.md index ec76703d..8943a3f0 100644 --- a/docs/configuration/cameras/README.md +++ b/docs/configuration/cameras/README.md @@ -66,6 +66,7 @@ cameras: | `snapshots` | Snapshots can be fetched from the camera. | | `substream` | The camera can be used as a substream on another camera. | | `trigger` | The camera can be triggered. | +| `2-way-audio` | The camera can be used for 2-way audio. | > [!NOTE] > If using a camera only as a `substream`, don't forget to keep both the @@ -482,6 +483,7 @@ cameras: - snapshots - substream - trigger + - 2-way-audio disable: # Capabilities to selectively disable. - camera_entity: camera.rotated diff --git a/src/camera-manager/camera.ts b/src/camera-manager/camera.ts index 6dd5ccf7..7646bdfe 100644 --- a/src/camera-manager/camera.ts +++ b/src/camera-manager/camera.ts @@ -3,17 +3,41 @@ import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz'; import { CameraConfig } from '../config/schema/cameras'; import { isTriggeredState } from '../ha/is-triggered-state'; -import { HassStateDifference } from '../ha/types'; +import { HassStateDifference, HomeAssistant } from '../ha/types'; import { localize } from '../localize/localize'; +import { CapabilitiesRaw, CapabilityKey, Endpoint } from '../types'; +import { liveProviderSupports2WayAudio } from '../utils/live-provider'; import { Capabilities } from './capabilities'; import { CameraManagerEngine } from './engine'; import { CameraNoIDError } from './error'; -import { CameraEventCallback, CameraProxyConfig } from './types'; +import { + CameraEndpoints, + CameraEndpointsContext, + CameraEventCallback, + CameraProxyConfig, +} from './types'; +import { + getGo2RTCMetadataEndpoint, + getGo2RTCStreamEndpoint, +} from './utils/go2rtc/endpoint'; import { getConfiguredPTZAction } from './utils/ptz'; -export interface CameraInitializationOptions { - stateWatcher: StateWatcherSubscriptionInterface; +interface CapabilityOptions { + // Pre-built Capabilities object. + capabilities?: Capabilities; + + // Raw capabilities for construction. + raw?: CapabilitiesRaw; + disable?: CapabilityKey[]; + disableExcept?: CapabilityKey[]; } + +export interface CameraInitializationOptions { + hass: HomeAssistant; + stateWatcher: StateWatcherSubscriptionInterface; + capabilityOptions?: CapabilityOptions; +} + type DestroyCallback = () => void | Promise; export class Camera { @@ -27,22 +51,73 @@ export class Camera { config: CameraConfig, engine: CameraManagerEngine, options?: { - capabilities?: Capabilities; eventCallback?: CameraEventCallback; + capabilities?: Capabilities; }, ) { this._config = config; this._engine = engine; - this._capabilities = options?.capabilities; this._eventCallback = options?.eventCallback; + this._capabilities = options?.capabilities; } async initialize(options: CameraInitializationOptions): Promise { + await this._initialize(options); + this._capabilities = + options.capabilityOptions?.capabilities ?? + this._capabilities ?? + (await this._buildCapabilities(options)); this._subscribeBasedOnCapabilities(options.stateWatcher); this._onDestroy(() => options.stateWatcher.unsubscribe(this._stateChangeHandler)); return this; } + /** + * Subclass initialization hook. Override for async initialization work. + */ + protected async _initialize( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _options: CameraInitializationOptions, + ): Promise {} + + protected async _buildCapabilities( + options: CameraInitializationOptions, + ): Promise { + const rawCapabilities = await this._getRawCapabilities(options); + const config = this.getConfig(); + const has2WayAudio = await liveProviderSupports2WayAudio( + options.hass, + config, + this._getGo2RTCMetadataEndpoint(), + this.getProxyConfig(), + ); + + return new Capabilities( + { ...rawCapabilities, '2-way-audio': has2WayAudio }, + { + disable: config.capabilities?.disable, + disableExcept: config.capabilities?.disable_except, + }, + ); + } + + /** + * Get raw capabilities for this camera. Subclasses should override + * and call super._getRawCapabilities() to extend defaults. + */ + protected async _getRawCapabilities( + options: CameraInitializationOptions, + ): Promise { + return { + live: true, + menu: true, + substream: true, + trigger: true, + 'remote-control-entity': true, + ...options.capabilityOptions?.raw, + }; + } + public async destroy(): Promise { this._destroyCallbacks.forEach((callback) => callback()); } @@ -70,6 +145,45 @@ export class Camera { return this._capabilities ?? null; } + /** + * Get camera endpoints. Subclasses should override to add engine-specific endpoints. + * @param _context Optional context for dynamic endpoints (e.g., UI URLs based on current view). + */ + public getEndpoints(context?: CameraEndpointsContext): CameraEndpoints | null { + const ui = this._getUIEndpoint(context); + const go2rtc = this._getGo2RTCStreamEndpoint(); + const webrtcCard = this._getWebRTCCardEndpoint(); + + return ui || go2rtc || webrtcCard + ? { + ...(ui && { ui }), + ...(go2rtc && { go2rtc }), + ...(webrtcCard && { webrtcCard }), + } + : null; + } + + /** + * Get the go2rtc metadata endpoint for capability detection. + * Subclasses should override if they have custom go2rtc URL or stream resolution. + */ + protected _getGo2RTCMetadataEndpoint(): Endpoint | null { + return getGo2RTCMetadataEndpoint(this._config); + } + + protected _getGo2RTCStreamEndpoint(): Endpoint | null { + return getGo2RTCStreamEndpoint(this._config); + } + + protected _getWebRTCCardEndpoint(): Endpoint | null { + return this._config.camera_entity ? { endpoint: this._config.camera_entity } : null; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected _getUIEndpoint(_context?: CameraEndpointsContext): Endpoint | null { + return null; + } + public getProxyConfig(): CameraProxyConfig { return { live: diff --git a/src/camera-manager/engine.ts b/src/camera-manager/engine.ts index b7aac685..8814aeb3 100644 --- a/src/camera-manager/engine.ts +++ b/src/camera-manager/engine.ts @@ -6,8 +6,6 @@ import { ViewItemCapabilities } from '../view/types'; import { Camera } from './camera'; import { CameraManagerReadOnlyConfigStore } from './store'; import { - CameraEndpoints, - CameraEndpointsContext, CameraManagerCameraMetadata, CameraQuery, Engine, @@ -122,9 +120,4 @@ export interface CameraManagerEngine { ): CameraManagerCameraMetadata; getMediaCapabilities(media: ViewMedia): ViewItemCapabilities | null; - - getCameraEndpoints( - cameraConfig: CameraConfig, - context?: CameraEndpointsContext, - ): CameraEndpoints | null; } diff --git a/src/camera-manager/entity-camera.ts b/src/camera-manager/entity-camera.ts index 0bb1cf07..c279cc9d 100644 --- a/src/camera-manager/entity-camera.ts +++ b/src/camera-manager/entity-camera.ts @@ -1,5 +1,4 @@ import { Entity, EntityRegistryManager } from '../ha/registry/entity/types'; -import { HomeAssistant } from '../ha/types'; import { localize } from '../localize/localize'; import { Camera, CameraInitializationOptions } from './camera'; import { CameraInitializationError } from './error'; @@ -7,7 +6,6 @@ import { getCameraEntityFromConfig } from './utils/camera-entity-from-config'; export interface EntityCameraInitializationOptions extends CameraInitializationOptions { entityRegistryManager: EntityRegistryManager; - hass: HomeAssistant; } export class EntityCamera extends Camera { diff --git a/src/camera-manager/frigate/camera.ts b/src/camera-manager/frigate/camera.ts index 3c065200..ce1eabda 100644 --- a/src/camera-manager/frigate/camera.ts +++ b/src/camera-manager/frigate/camera.ts @@ -1,19 +1,26 @@ +import { format } from 'date-fns'; import { uniq } from 'lodash-es'; import { ActionsExecutor } from '../../card-controller/actions/types'; -import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher'; import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz'; import { CameraConfig } from '../../config/schema/cameras'; import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types'; import { HomeAssistant } from '../../ha/types'; import { localize } from '../../localize/localize'; -import { PTZCapabilities, PTZMovementType } from '../../types'; +import { + CapabilitiesRaw, + Endpoint, + PTZCapabilities, + PTZMovementType, +} from '../../types'; import { errorToConsole } from '../../utils/basic'; import { Camera, CameraInitializationOptions } from '../camera'; -import { Capabilities } from '../capabilities'; -import { CameraManagerEngine } from '../engine'; import { CameraInitializationError } from '../error'; -import { CameraEventCallback } from '../types'; +import { CameraEndpoints, CameraEndpointsContext } from '../types'; import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config'; +import { + getGo2RTCMetadataEndpoint, + getGo2RTCStreamEndpoint, +} from '../utils/go2rtc/endpoint'; import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; import { FrigateEventWatcherRequest, @@ -27,8 +34,6 @@ const CAMERA_BIRDSEYE = 'birdseye' as const; interface FrigateCameraInitializationOptions extends CameraInitializationOptions { entityRegistryManager: EntityRegistryManager; frigateEventWatcher: FrigateEventWatcherSubscriptionInterface; - hass: HomeAssistant; - stateWatcher: StateWatcherSubscriptionInterface; } export const isBirdseye = (cameraConfig: CameraConfig): boolean => { @@ -36,26 +41,15 @@ export const isBirdseye = (cameraConfig: CameraConfig): boolean => { }; export class FrigateCamera extends Camera { - constructor( - config: CameraConfig, - engine: CameraManagerEngine, - options?: { - capabilities?: Capabilities; - eventCallback?: CameraEventCallback; - }, - ) { - super(config, engine, options); - } - public async initialize(options: FrigateCameraInitializationOptions): Promise { await this._initializeConfig(options.hass, options.entityRegistryManager); - await this._initializeCapabilities(options.hass, options.stateWatcher); + await super.initialize(options); if (this._capabilities?.has('trigger')) { await this._subscribeToEvents(options.hass, options.frigateEventWatcher); } - return await super.initialize(options); + return this; } public async executePTZAction( @@ -170,45 +164,27 @@ export class FrigateCamera extends Camera { } } - protected async _initializeCapabilities( - hass: HomeAssistant, - stateWatcher: StateWatcherSubscriptionInterface, - ): Promise { + protected async _getRawCapabilities( + options: FrigateCameraInitializationOptions, + ): Promise { + const base = await super._getRawCapabilities(options); const config = this.getConfig(); - const configPTZCapabilities = getPTZCapabilitiesFromCameraConfig(this.getConfig()); - const frigatePTZCapabilities = await this._getPTZCapabilities(hass, config); - - const combinedPTZCapabilities: PTZCapabilities | null = - configPTZCapabilities || frigatePTZCapabilities - ? { - ...frigatePTZCapabilities, - ...configPTZCapabilities, - } - : null; + const frigatePTZ = await this._getPTZCapabilities(options.hass, config); + const configPTZ = getPTZCapabilitiesFromCameraConfig(config); + const combinedPTZ: PTZCapabilities | null = + configPTZ || frigatePTZ ? { ...frigatePTZ, ...configPTZ } : null; const birdseye = isBirdseye(config); - this._capabilities = new Capabilities( - { - 'favorite-events': !birdseye, - 'favorite-recordings': false, - 'remote-control-entity': true, - seek: !birdseye, - clips: !birdseye, - snapshots: !birdseye, - recordings: !birdseye, - live: true, - menu: true, - substream: true, - trigger: true, - ...(combinedPTZCapabilities && { ptz: combinedPTZCapabilities }), - }, - { - disable: config.capabilities?.disable, - disableExcept: config.capabilities?.disable_except, - }, - ); - this._subscribeBasedOnCapabilities(stateWatcher); + return { + ...base, + 'favorite-events': !birdseye, + seek: !birdseye, + clips: !birdseye, + snapshots: !birdseye, + recordings: !birdseye, + ...(combinedPTZ && { ptz: combinedPTZ }), + }; } protected _getFrigateCameraNameFromEntity(entity: Entity): string | null { @@ -225,6 +201,100 @@ export class FrigateCamera extends Camera { return null; } + public override getEndpoints( + context?: CameraEndpointsContext, + ): CameraEndpoints | null { + const base = super.getEndpoints(context); + const jsmpeg = this._getJSMPEGEndpoint(); + + if (!base && !jsmpeg) { + return null; + } + + return { + ...base, + ...(jsmpeg && { jsmpeg }), + }; + } + + protected override _getGo2RTCMetadataEndpoint(): Endpoint | null { + const stream = this._config.go2rtc?.stream ?? this._config.frigate.camera_name; + const url = + this._config.go2rtc?.url ?? + `/api/frigate/${this._config.frigate.client_id}/go2rtc`; + return getGo2RTCMetadataEndpoint(this._config, { url, stream }); + } + + protected override _getGo2RTCStreamEndpoint(): Endpoint | null { + const stream = this._config.go2rtc?.stream ?? this._config.frigate.camera_name; + const url = + this._config.go2rtc?.url ?? + // go2rtc is exposed by the Frigate integration under the 'mse' path. + `/api/frigate/${this._config.frigate.client_id}/mse`; + + return getGo2RTCStreamEndpoint(this._config, { + url, + stream, + }); + } + + protected _getJSMPEGEndpoint(): Endpoint | null { + if (!this._config.frigate.camera_name) { + return null; + } + return { + endpoint: + `/api/frigate/${this._config.frigate.client_id}` + + `/jsmpeg/${this._config.frigate.camera_name}`, + sign: true, + }; + } + + protected override _getUIEndpoint(context?: CameraEndpointsContext): Endpoint | null { + if (!this._config.frigate.url) { + return null; + } + if (!this._config.frigate.camera_name) { + return { endpoint: this._config.frigate.url }; + } + + const cameraURL = `${this._config.frigate.url}/#${this._config.frigate.camera_name}`; + + if (context?.view === 'live') { + return { endpoint: cameraURL }; + } + + const eventsURL = `${this._config.frigate.url}/events?camera=${this._config.frigate.camera_name}`; + const recordingsURL = `${this._config.frigate.url}/recording/${this._config.frigate.camera_name}`; + + // If media is available, use it for a more precise URL. + switch (context?.media?.getMediaType()) { + case 'clip': + case 'snapshot': + return { endpoint: eventsURL }; + case 'recording': + const startTime = context.media.getStartTime(); + return { + endpoint: + recordingsURL + (startTime ? '/' + format(startTime, 'yyyy-MM-dd/HH') : ''), + }; + } + + // Fall back to using the view. + switch (context?.view) { + case 'clip': + case 'clips': + case 'snapshots': + case 'snapshot': + return { endpoint: eventsURL }; + case 'recording': + case 'recordings': + return { endpoint: recordingsURL }; + } + + return { endpoint: cameraURL }; + } + protected async _getPTZCapabilities( hass: HomeAssistant, cameraConfig: CameraConfig, @@ -372,6 +442,14 @@ export class FrigateCamera extends Camera { const clipChange = !ev.before.has_clip && ev.after.has_clip; const config = this.getConfig(); + const cameraID = this._config.id; + + if (!cameraID) { + // This can happen if an event arrives during the time a camera is + // initializing. + return; + } + if ( (config.frigate.zones?.length && !config.frigate.zones.some((zone) => ev.after.current_zones.includes(zone))) || @@ -392,8 +470,9 @@ export class FrigateCamera extends Camera { } this._eventCallback?.({ + cameraID, + fidelity: 'high', - cameraID: this.getID(), type: ev.type, // In cases where there are both clip and snapshot media, ensure to only // trigger on the media type that is allowed by the configuration. diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index 164128fa..23b11d94 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -1,4 +1,4 @@ -import { add, endOfHour, format, fromUnixTime, startOfHour } from 'date-fns'; +import { add, endOfHour, fromUnixTime, startOfHour } from 'date-fns'; import { isEqual, orderBy, throttle, uniqWith } from 'lodash-es'; import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher'; import { CameraConfig } from '../../config/schema/cameras'; @@ -25,8 +25,6 @@ import { GenericCameraManagerEngine } from '../generic/engine-generic'; import { DateRange } from '../range'; import { CameraManagerReadOnlyConfigStore } from '../store'; import { - CameraEndpoints, - CameraEndpointsContext, CameraEventCallback, CameraManagerCameraMetadata, CameraManagerRequestCache, @@ -53,7 +51,6 @@ import { RecordingSegmentsQuery, RecordingSegmentsQueryResultsMap, } from '../types'; -import { getDefaultGo2RTCEndpoint } from '../utils/go2rtc-endpoint'; import { FrigateCamera, isBirdseye } from './camera'; import { FrigateEventWatcher } from './event-watcher'; import { FrigateViewMediaFactory } from './media'; @@ -920,87 +917,4 @@ export class FrigateCameraManagerEngine engineIcon: 'frigate', }; } - - public getCameraEndpoints( - cameraConfig: CameraConfig, - context?: CameraEndpointsContext, - ): CameraEndpoints | null { - const getUIEndpoint = (): Endpoint | null => { - if (!cameraConfig.frigate.url) { - return null; - } - if (!cameraConfig.frigate.camera_name) { - return { endpoint: cameraConfig.frigate.url }; - } - - const cameraURL = - `${cameraConfig.frigate.url}/#` + cameraConfig.frigate.camera_name; - - if (context?.view === 'live') { - return { endpoint: cameraURL }; - } - - const eventsURL = - `${cameraConfig.frigate.url}/events?camera=` + cameraConfig.frigate.camera_name; - const recordingsURL = - `${cameraConfig.frigate.url}/recording/` + cameraConfig.frigate.camera_name; - - // If media is available, use it since it may result in a more precisely - // correct URL. - switch (context?.media?.getMediaType()) { - case 'clip': - case 'snapshot': - return { endpoint: eventsURL }; - case 'recording': - const startTime = context.media.getStartTime(); - if (startTime) { - return { endpoint: recordingsURL + format(startTime, 'yyyy-MM-dd/HH') }; - } - } - - // Otherwise, fall back to just using the view if we have that. - switch (context?.view) { - case 'clip': - case 'clips': - case 'snapshots': - case 'snapshot': - return { endpoint: eventsURL }; - case 'recording': - case 'recordings': - return { endpoint: recordingsURL }; - } - - return { - endpoint: cameraURL, - }; - }; - - const getJSMPEG = (): Endpoint | null => { - return { - endpoint: - `/api/frigate/${cameraConfig.frigate.client_id}` + - `/jsmpeg/${cameraConfig.frigate.camera_name}`, - sign: true, - }; - }; - - const ui = getUIEndpoint(); - const go2rtc = getDefaultGo2RTCEndpoint(cameraConfig, { - url: - cameraConfig.go2rtc?.url ?? - // go2rtc is exposed by the Frigate integration under the (slightly - // misleading) 'mse' path, even though that path can serve all go2rtc - // modes. - `/api/frigate/${cameraConfig.frigate.client_id}/mse`, - stream: cameraConfig.go2rtc?.stream ?? cameraConfig.frigate.camera_name, - }); - const jsmpeg = getJSMPEG(); - - return { - ...super.getCameraEndpoints(cameraConfig, context), - ...(ui && { ui: ui }), - ...(go2rtc && { go2rtc: go2rtc }), - ...(jsmpeg && { jsmpeg: jsmpeg }), - }; - } } diff --git a/src/camera-manager/generic/engine-generic.ts b/src/camera-manager/generic/engine-generic.ts index 6f6d66c0..18a799d1 100644 --- a/src/camera-manager/generic/engine-generic.ts +++ b/src/camera-manager/generic/engine-generic.ts @@ -8,12 +8,9 @@ import { Endpoint } from '../../types'; import { ViewMedia } from '../../view/item'; import { ViewItemCapabilities } from '../../view/types'; import { Camera } from '../camera'; -import { Capabilities } from '../capabilities'; import { CameraManagerEngine } from '../engine'; import { CameraManagerReadOnlyConfigStore } from '../store'; import { - CameraEndpoints, - CameraEndpointsContext, CameraEventCallback, CameraManagerCameraMetadata, CameraQuery, @@ -33,7 +30,6 @@ import { RecordingSegmentsQueryResultsMap, } from '../types'; import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config'; -import { getDefaultGo2RTCEndpoint } from '../utils/go2rtc-endpoint'; import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; export class GenericCameraManagerEngine implements CameraManagerEngine { @@ -53,32 +49,22 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { } public async createCamera( - _hass: HomeAssistant, + hass: HomeAssistant, cameraConfig: CameraConfig, ): Promise { return await new Camera(cameraConfig, this, { - capabilities: new Capabilities( - { - 'favorite-events': false, - 'favorite-recordings': false, - 'remote-control-entity': true, - clips: false, - live: true, - menu: true, - recordings: false, - seek: false, - snapshots: false, - substream: true, - trigger: true, + eventCallback: this._eventCallback, + }).initialize({ + hass, + stateWatcher: this._stateWatcher, + capabilityOptions: { + raw: { ptz: getPTZCapabilitiesFromCameraConfig(cameraConfig) ?? undefined, }, - { - disable: cameraConfig.capabilities?.disable, - disableExcept: cameraConfig.capabilities?.disable_except, - }, - ), - eventCallback: this._eventCallback, - }).initialize({ stateWatcher: this._stateWatcher }); + disable: cameraConfig.capabilities?.disable, + disableExcept: cameraConfig.capabilities?.disable_except, + }, + }); } public generateDefaultEventQuery( @@ -213,25 +199,4 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { public getMediaCapabilities(_media: ViewMedia): ViewItemCapabilities | null { return null; } - - public getCameraEndpoints( - cameraConfig: CameraConfig, - _context?: CameraEndpointsContext, - ): CameraEndpoints | null { - const getWebRTCCard = (): Endpoint | null => { - // The user may override this in their webrtc_card configuration. - const endpoint = cameraConfig.camera_entity ? cameraConfig.camera_entity : null; - return endpoint ? { endpoint: endpoint } : null; - }; - - const go2rtc = getDefaultGo2RTCEndpoint(cameraConfig); - const webrtcCard = getWebRTCCard(); - - return go2rtc || webrtcCard - ? { - ...(go2rtc && { go2rtc: go2rtc }), - ...(webrtcCard && { webrtcCard: webrtcCard }), - } - : null; - } } diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index 8a7a7439..309e1cdc 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -752,12 +752,7 @@ export class CameraManager { cameraID: string, context?: CameraEndpointsContext, ): CameraEndpoints | null { - const cameraConfig = this._store.getCameraConfig(cameraID); - const engine = this._store.getEngineForCameraID(cameraID); - if (!cameraConfig || !engine) { - return null; - } - return engine.getCameraEndpoints(cameraConfig, context); + return this._store.getCamera(cameraID)?.getEndpoints(context) ?? null; } public getCameraMetadata(cameraID: string): CameraManagerCameraMetadata | null { diff --git a/src/camera-manager/motioneye/camera.ts b/src/camera-manager/motioneye/camera.ts index 298c47eb..a1daf5f1 100644 --- a/src/camera-manager/motioneye/camera.ts +++ b/src/camera-manager/motioneye/camera.ts @@ -1,5 +1,8 @@ +import { CapabilitiesRaw, Endpoint } from '../../types'; +import { CameraInitializationOptions } from '../camera'; import { EntityCamera } from '../entity-camera'; -import { CameraProxyConfig } from '../types'; +import { CameraEndpointsContext, CameraProxyConfig } from '../types'; +import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; export class MotionEyeCamera extends EntityCamera { public getProxyConfig(): CameraProxyConfig { @@ -10,4 +13,22 @@ export class MotionEyeCamera extends EntityCamera { media: this._config.proxy.media === 'auto' ? true : this._config.proxy.media, }; } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected override _getUIEndpoint(_context?: CameraEndpointsContext): Endpoint | null { + return this._config.motioneye?.url ? { endpoint: this._config.motioneye.url } : null; + } + + protected async _getRawCapabilities( + options: CameraInitializationOptions, + ): Promise { + const ptz = getPTZCapabilitiesFromCameraConfig(this.getConfig()); + + return { + ...(await super._getRawCapabilities(options)), + clips: true, + snapshots: true, + ...(ptz && { ptz }), + }; + } } diff --git a/src/camera-manager/motioneye/engine-motioneye.ts b/src/camera-manager/motioneye/engine-motioneye.ts index afa9454f..e56386a9 100644 --- a/src/camera-manager/motioneye/engine-motioneye.ts +++ b/src/camera-manager/motioneye/engine-motioneye.ts @@ -14,18 +14,14 @@ import { import { BrowseMediaStep, BrowseMediaTarget } from '../../ha/browse-media/walker'; import { isMediaWithinDates } from '../../ha/browse-media/within-dates'; import { HomeAssistant } from '../../ha/types'; -import { Endpoint } from '../../types'; import { allPromises, formatDate, isValidDate } from '../../utils/basic'; import { ViewMedia } from '../../view/item'; -import { EntityCamera } from '../entity-camera'; import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media'; import { Camera } from '../camera'; -import { Capabilities } from '../capabilities'; import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine'; +import { EntityCamera } from '../entity-camera'; import { CameraManagerReadOnlyConfigStore } from '../store'; import { - CameraEndpoints, - CameraEndpointsContext, CameraManagerCameraMetadata, Engine, EngineOptions, @@ -39,7 +35,6 @@ import { QueryResultsType, QueryReturnType, } from '../types'; -import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; import { MotionEyeCamera } from './camera'; import { MotionEyeEventQueryResults } from './types'; @@ -76,26 +71,6 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine cameraConfig: CameraConfig, ): Promise { const camera = new MotionEyeCamera(cameraConfig, this, { - capabilities: new Capabilities( - { - 'favorite-events': false, - 'favorite-recordings': false, - 'remote-control-entity': true, - clips: true, - live: true, - menu: true, - recordings: false, - seek: false, - snapshots: true, - substream: true, - trigger: true, - ptz: getPTZCapabilitiesFromCameraConfig(cameraConfig) ?? undefined, - }, - { - disable: cameraConfig.capabilities?.disable, - disableExcept: cameraConfig.capabilities?.disable_except, - }, - ), eventCallback: this._eventCallback, }); return await camera.initialize({ @@ -423,22 +398,4 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine engineIcon: 'motioneye', }; } - - public getCameraEndpoints( - cameraConfig: CameraConfig, - context?: CameraEndpointsContext, - ): CameraEndpoints | null { - const getUIEndpoint = (): Endpoint | null => { - return cameraConfig.motioneye?.url - ? { - endpoint: cameraConfig.motioneye.url, - } - : null; - }; - const ui = getUIEndpoint(); - return { - ...super.getCameraEndpoints(cameraConfig, context), - ...(ui && { ui: ui }), - }; - } } diff --git a/src/camera-manager/reolink/camera.ts b/src/camera-manager/reolink/camera.ts index dd848b10..de7ff246 100644 --- a/src/camera-manager/reolink/camera.ts +++ b/src/camera-manager/reolink/camera.ts @@ -1,17 +1,20 @@ import { ActionsExecutor } from '../../card-controller/actions/types'; -import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher'; import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz'; import { DeviceRegistryManager } from '../../ha/registry/device/index'; import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types'; import { HomeAssistant } from '../../ha/types'; import { localize } from '../../localize/localize'; -import { PTZCapabilities, PTZMovementType } from '../../types'; +import { + CapabilitiesRaw, + Endpoint, + PTZCapabilities, + PTZMovementType, +} from '../../types'; import { createSelectOptionAction } from '../../utils/action.js'; import { Camera, CameraInitializationOptions } from '../camera'; -import { Capabilities } from '../capabilities'; import { EntityCamera } from '../entity-camera'; import { CameraInitializationError } from '../error'; -import { CameraProxyConfig } from '../types'; +import { CameraEndpointsContext, CameraProxyConfig } from '../types'; import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; // Reolink channels are zero indexed. @@ -20,7 +23,6 @@ const REOLINK_DEFAULT_CHANNEL = 0; interface ReolinkCameraInitializationOptions extends CameraInitializationOptions { entityRegistryManager: EntityRegistryManager; deviceRegistryManager: DeviceRegistryManager; - hass: HomeAssistant; } class ReolinkInitializationError extends CameraInitializationError {} @@ -50,15 +52,14 @@ export class ReolinkCamera extends EntityCamera { // Entities used for PTZ control. protected _ptzEntities: PTZEntities | null = null; + /** + * Reolink cameras require additional options not present in the base class + * initialization options, so this ~empty method is used to expand the type + * expectations. Without this, callers cannot specify objects (e.g. the device + * registry) without TypeScript errors. + */ public async initialize(options: ReolinkCameraInitializationOptions): Promise { - await super.initialize(options); - await this._initializeChannel(options.hass, options.deviceRegistryManager); - await this._initializeCapabilities( - options.hass, - options.entityRegistryManager, - options.stateWatcher, - ); - return this; + return super.initialize(options); } protected async _getChannelFromConfigurationURL( @@ -135,47 +136,37 @@ export class ReolinkCamera extends EntityCamera { this._reolinkCameraUID = reolinkCameraUID; } - protected async _initializeCapabilities( - hass: HomeAssistant, - entityRegistry: EntityRegistryManager, - stateWatcher: StateWatcherSubscriptionInterface, + protected async _initialize( + options: ReolinkCameraInitializationOptions, ): Promise { - const config = this.getConfig(); - const configPTZCapabilities = getPTZCapabilitiesFromCameraConfig(this.getConfig()); - this._ptzEntities = await this._getPTZEntities(hass, entityRegistry); - const reolinkPTZCapabilities = this._ptzEntities - ? this._entitiesToCapabilities(hass, this._ptzEntities) + await this._initializeChannel(options.hass, options.deviceRegistryManager); + this._ptzEntities = await this._getPTZEntities( + options.hass, + options.entityRegistryManager, + ); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected override _getUIEndpoint(_context?: CameraEndpointsContext): Endpoint | null { + return this._config.reolink?.url ? { endpoint: this._config.reolink.url } : null; + } + + protected async _getRawCapabilities( + options: ReolinkCameraInitializationOptions, + ): Promise { + const configPTZ = getPTZCapabilitiesFromCameraConfig(this.getConfig()); + const reolinkPTZ = this._ptzEntities + ? this._entitiesToCapabilities(options.hass, this._ptzEntities) : null; - const combinedPTZCapabilities: PTZCapabilities | null = - configPTZCapabilities || reolinkPTZCapabilities - ? { - ...reolinkPTZCapabilities, - ...configPTZCapabilities, - } - : null; + const combinedPTZ: PTZCapabilities | null = + configPTZ || reolinkPTZ ? { ...reolinkPTZ, ...configPTZ } : null; - this._capabilities = new Capabilities( - { - 'favorite-events': false, - 'favorite-recordings': false, - 'remote-control-entity': true, - clips: true, - live: true, - menu: true, - recordings: false, - seek: false, - snapshots: false, - substream: true, - trigger: true, - ...(combinedPTZCapabilities && { ptz: combinedPTZCapabilities }), - }, - { - disable: config.capabilities?.disable, - disableExcept: config.capabilities?.disable_except, - }, - ); - this._subscribeBasedOnCapabilities(stateWatcher); + return { + ...(await super._getRawCapabilities(options)), + clips: true, + ...(combinedPTZ && { ptz: combinedPTZ }), + }; } protected _entitiesToCapabilities( diff --git a/src/camera-manager/reolink/engine-reolink.ts b/src/camera-manager/reolink/engine-reolink.ts index 2263193b..9a8e498f 100644 --- a/src/camera-manager/reolink/engine-reolink.ts +++ b/src/camera-manager/reolink/engine-reolink.ts @@ -18,7 +18,6 @@ import { DeviceRegistryManager } from '../../ha/registry/device'; import { EntityRegistryManager } from '../../ha/registry/entity/types'; import { ResolvedMediaCache } from '../../ha/resolved-media'; import { HomeAssistant } from '../../ha/types'; -import { Endpoint } from '../../types'; import { allPromises, formatDate, isValidDate } from '../../utils/basic'; import { ViewMedia } from '../../view/item'; import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media'; @@ -26,8 +25,6 @@ import { Camera } from '../camera'; import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine'; import { CameraManagerReadOnlyConfigStore } from '../store'; import { - CameraEndpoints, - CameraEndpointsContext, CameraEventCallback, CameraManagerCameraMetadata, CameraManagerRequestCache, @@ -420,22 +417,4 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine { engineIcon: 'reolink', }; } - - public getCameraEndpoints( - cameraConfig: CameraConfig, - context?: CameraEndpointsContext, - ): CameraEndpoints | null { - const getUIEndpoint = (): Endpoint | null => { - return cameraConfig.reolink?.url - ? { - endpoint: cameraConfig.reolink.url, - } - : null; - }; - const ui = getUIEndpoint(); - return { - ...super.getCameraEndpoints(cameraConfig, context), - ...(ui && { ui: ui }), - }; - } } diff --git a/src/camera-manager/tplink/camera.ts b/src/camera-manager/tplink/camera.ts index dcb642a0..70f81f61 100644 --- a/src/camera-manager/tplink/camera.ts +++ b/src/camera-manager/tplink/camera.ts @@ -1,11 +1,8 @@ import { ActionsExecutor } from '../../card-controller/actions/types'; -import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher'; import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz'; import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types'; import { HomeAssistant } from '../../ha/types'; -import { PTZCapabilities, PTZMovementType } from '../../types'; -import { Camera } from '../camera'; -import { Capabilities } from '../capabilities'; +import { CapabilitiesRaw, PTZCapabilities, PTZMovementType } from '../../types'; import { EntityCamera, EntityCameraInitializationOptions } from '../entity-camera'; import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; @@ -22,57 +19,30 @@ type PTZEntity = keyof PTZEntities; export class TPLinkCamera extends EntityCamera { protected _ptzEntities: PTZEntities | null = null; - public async initialize(options: TPLinkCameraInitializationOptions): Promise { - await super.initialize(options); - await this._initializeCapabilities( + protected async _initialize( + options: TPLinkCameraInitializationOptions, + ): Promise { + this._ptzEntities = await this._getPTZEntities( options.hass, options.entityRegistryManager, - options.stateWatcher, ); - return this; } - protected async _initializeCapabilities( - hass: HomeAssistant, - entityRegistry: EntityRegistryManager, - stateWatcher: StateWatcherSubscriptionInterface, - ): Promise { - const config = this.getConfig(); - const configPTZCapabilities = getPTZCapabilitiesFromCameraConfig(config); - this._ptzEntities = await this._getPTZEntities(hass, entityRegistry); - const tplinkPTZCapabilities = this._ptzEntities + protected async _getRawCapabilities( + options: TPLinkCameraInitializationOptions, + ): Promise { + const configPTZ = getPTZCapabilitiesFromCameraConfig(this.getConfig()); + const tplinkPTZ = this._ptzEntities ? this._entitiesToCapabilities(this._ptzEntities) : null; - const combinedPTZCapabilities: PTZCapabilities | null = - configPTZCapabilities || tplinkPTZCapabilities - ? { - ...tplinkPTZCapabilities, - ...configPTZCapabilities, - } - : null; + const combinedPTZ: PTZCapabilities | null = + configPTZ || tplinkPTZ ? { ...tplinkPTZ, ...configPTZ } : null; - this._capabilities = new Capabilities( - { - 'favorite-events': false, - 'favorite-recordings': false, - 'remote-control-entity': true, - clips: false, - live: true, - menu: true, - recordings: false, - seek: false, - snapshots: false, - substream: true, - trigger: true, - ...(combinedPTZCapabilities && { ptz: combinedPTZCapabilities }), - }, - { - disable: config.capabilities?.disable, - disableExcept: config.capabilities?.disable_except, - }, - ); - this._subscribeBasedOnCapabilities(stateWatcher); + return { + ...(await super._getRawCapabilities(options)), + ...(combinedPTZ && { ptz: combinedPTZ }), + }; } protected async _getPTZEntities( diff --git a/src/camera-manager/utils/go2rtc-endpoint.ts b/src/camera-manager/utils/go2rtc-endpoint.ts deleted file mode 100644 index 6e8c4301..00000000 --- a/src/camera-manager/utils/go2rtc-endpoint.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { CameraConfig } from '../../config/schema/cameras'; -import { Endpoint } from '../../types'; - -export const getDefaultGo2RTCEndpoint = ( - cameraConfig: CameraConfig, - options?: { - url?: string; - stream?: string; - }, -): Endpoint | null => { - const url = options?.url ?? cameraConfig.go2rtc?.url; - const stream = options?.stream ?? cameraConfig.go2rtc?.stream; - - if (!url || !stream) { - return null; - } - const endpoint = `${url}/api/ws?src=${stream}`; - - return { - endpoint: endpoint, - - // Only sign the endpoint if it's local to HA. - sign: endpoint.startsWith('/'), - }; -}; diff --git a/src/camera-manager/utils/go2rtc/audio.ts b/src/camera-manager/utils/go2rtc/audio.ts new file mode 100644 index 00000000..f2e93149 --- /dev/null +++ b/src/camera-manager/utils/go2rtc/audio.ts @@ -0,0 +1,69 @@ +import { homeAssistantSignAndFetch } from '../../../ha/fetch'; +import { HomeAssistant } from '../../../ha/types'; +import { createProxiedEndpointIfNecessary } from '../../../ha/web-proxy'; +import { Endpoint } from '../../../types'; +import { errorToConsole } from '../../../utils/basic'; +import { CameraProxyConfig } from '../../types'; +import { Go2RTCStreamInfo, go2RTCStreamInfoSchema } from './types'; + +const GO2RTC_METADATA_TIMEOUT_SECONDS = 2; + +const getGo2RTCStreamMetadata = async ( + hass: HomeAssistant, + endpoint: Endpoint, +): Promise => { + try { + return await homeAssistantSignAndFetch(hass, endpoint, go2RTCStreamInfoSchema, { + timeoutSeconds: GO2RTC_METADATA_TIMEOUT_SECONDS, + }); + } catch (e) { + errorToConsole(e as Error); + return null; + } +}; + +const streamSupports2WayAudio = (streamInfo: Go2RTCStreamInfo | null): boolean => { + if (!streamInfo?.producers) { + return false; + } + return streamInfo.producers.some( + (producer) => + producer.medias?.some( + (media) => + media.includes('audio') && + (media.includes('sendonly') || media.includes('sendrecv')), + ) ?? false, + ); +}; + +/** + * Fetch go2rtc metadata and determine if the stream supports 2-way audio. + * Handles proxy transformation if proxy config requires it. + * Returns false if the endpoint is not available or fetch fails. + * + * Note: Caller is responsible for checking if live_provider is 'go2rtc' before calling. + * + * @param hass Home Assistant instance. + * @param go2rtcMetadataEndpoint The go2rtc metadata endpoint. + * @param proxyConfig The camera's proxy configuration for live streams. + * @returns True if supports 2-way audio, false otherwise. + */ +export const supports2WayAudio = async ( + hass: HomeAssistant, + go2rtcMetadataEndpoint?: Endpoint | null, + proxyConfig?: CameraProxyConfig, +): Promise => { + if (!go2rtcMetadataEndpoint) { + return false; + } + + const endpoint = await createProxiedEndpointIfNecessary( + hass, + go2rtcMetadataEndpoint, + proxyConfig, + { context: 'live', openLimit: 1 }, + ); + + const streamInfo = await getGo2RTCStreamMetadata(hass, endpoint); + return streamSupports2WayAudio(streamInfo); +}; diff --git a/src/camera-manager/utils/go2rtc/endpoint.ts b/src/camera-manager/utils/go2rtc/endpoint.ts new file mode 100644 index 00000000..59369269 --- /dev/null +++ b/src/camera-manager/utils/go2rtc/endpoint.ts @@ -0,0 +1,51 @@ +import { CameraConfig } from '../../../config/schema/cameras'; +import { Endpoint } from '../../../types'; + +type EndpointOptions = { + url?: string; + stream?: string; +}; + +const buildGo2RTCEndpoint = ( + cameraConfig: CameraConfig, + pathBuilder: (url: string, stream: string) => string, + options?: EndpointOptions, +): Endpoint | null => { + const url = options?.url ?? cameraConfig.go2rtc?.url; + const stream = options?.stream ?? cameraConfig.go2rtc?.stream; + + if (!url || !stream) { + return null; + } + + const endpoint = pathBuilder(url, stream); + return { + endpoint, + // Only sign the endpoint if it's local to HA. + sign: endpoint.startsWith('/'), + }; +}; + +export const getGo2RTCStreamEndpoint = ( + cameraConfig: CameraConfig, + options?: EndpointOptions, +): Endpoint | null => { + return buildGo2RTCEndpoint( + cameraConfig, + (url, stream) => `${url}/api/ws?src=${stream}`, + options, + ); +}; + +export const getGo2RTCMetadataEndpoint = ( + cameraConfig: CameraConfig, + options?: EndpointOptions, +): Endpoint | null => { + return buildGo2RTCEndpoint( + cameraConfig, + // Use probe parameters to trigger active stream detection. + // Without these, go2rtc only returns static config without producer medias. + (url, stream) => `${url}/api/streams?src=${stream}&video=all&audio=allµphone`, + options, + ); +}; diff --git a/src/camera-manager/utils/go2rtc/types.ts b/src/camera-manager/utils/go2rtc/types.ts new file mode 100644 index 00000000..2bfaf2af --- /dev/null +++ b/src/camera-manager/utils/go2rtc/types.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +const go2RTCProducerSchema = z.object({ + medias: z.array(z.string()).optional(), +}); + +/** + * Zod schema for Go2RTC stream information. Schema only covers the minimum + * required by the card. + * Response from `/api/streams?src=${stream}&video=all&audio=allµphone` + */ +export const go2RTCStreamInfoSchema = z.object({ + producers: z.array(go2RTCProducerSchema).optional(), +}); + +export type Go2RTCStreamInfo = z.infer; diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index f3224bd9..fca77739 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -83,8 +83,9 @@ export class MenuButtonController { this._getCameraUIButton(config, options?.showCameraUIButton), this._getMicrophoneButton( config, + cameraManager, + options?.view, options?.microphoneManager, - options?.currentMediaLoadedInfo, ), this._getExpandButton(config, options?.inExpandedMode), this._getFullscreenButton(config, options?.fullscreenManager), @@ -381,10 +382,17 @@ export class MenuButtonController { protected _getMicrophoneButton( config: AdvancedCameraCardConfig, + cameraManager: CameraManager, + view?: View | null, microphoneManager?: MicrophoneManager | null, - currentMediaLoadedInfo?: MediaLoadedInfo | null, ): MenuItem | null { - if (microphoneManager && currentMediaLoadedInfo?.capabilities?.supports2WayAudio) { + if (!view) { + return null; + } + + const capabilities = cameraManager.getCameraCapabilities(getStreamCameraID(view)); + + if (microphoneManager && capabilities?.has('2-way-audio')) { const unavailable = microphoneManager.isForbidden() || !microphoneManager.isSupported(); const muted = microphoneManager.isMuted(); diff --git a/src/components/live/provider.ts b/src/components/live/provider.ts index 79a1056f..aace3d81 100644 --- a/src/components/live/provider.ts +++ b/src/components/live/provider.ts @@ -16,9 +16,8 @@ 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 { LiveProvider } from '../../config/schema/cameras.js'; import { LiveConfig } from '../../config/schema/live.js'; -import { CardWideConfig, configDefaults } from '../../config/schema/types.js'; +import { CardWideConfig } from '../../config/schema/types.js'; import { STREAM_TROUBLESHOOTING_URL } from '../../const.js'; import { HomeAssistant } from '../../ha/types.js'; import { localize } from '../../localize/localize.js'; @@ -29,6 +28,7 @@ import { MediaPlayerController, MediaPlayerElement, } from '../../types.js'; +import { getResolvedLiveProvider } from '../../utils/live-provider.js'; import { dispatchMediaUnloadedEvent } from '../../utils/media-info.js'; import '../icon.js'; import { renderMessage } from '../message.js'; @@ -101,25 +101,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP return (await this._refProvider.value?.getMediaPlayerController()) ?? null; } - /** - * Get the fully resolved live provider. - * @returns A live provider (that is not 'auto'). - */ - protected _getResolvedProvider(): Omit { - const config = this.camera?.getConfig(); - if (config?.live_provider === 'auto') { - if (config?.webrtc_card?.entity || config?.webrtc_card?.url) { - return 'webrtc-card'; - } else if (config?.camera_entity) { - return 'ha'; - } else if (config?.frigate.camera_name) { - return 'jsmpeg'; - } - return configDefaults.cameras.live_provider; - } - return config?.live_provider || 'image'; - } - /** * Determine if a camera image should be shown in lieu of the real stream * whilst loading. @@ -172,7 +153,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP } if (changedProps.has('camera')) { - const provider = this._getResolvedProvider(); + const provider = getResolvedLiveProvider(this.camera?.getConfig()); if (provider === 'jsmpeg') { this._importPromises.push(import('./providers/jsmpeg.js')); } else if (provider === 'ha') { @@ -248,7 +229,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP this.title = this.label; this.ariaLabel = this.label; - const provider = this._getResolvedProvider(); + const provider = getResolvedLiveProvider(this.camera?.getConfig()); if ( provider === 'ha' || diff --git a/src/components/live/providers/go2rtc/index.ts b/src/components/live/providers/go2rtc/index.ts index cddf6de2..c88a3878 100644 --- a/src/components/live/providers/go2rtc/index.ts +++ b/src/components/live/providers/go2rtc/index.ts @@ -15,11 +15,7 @@ import { VideoMediaPlayerController } from '../../../../components-lib/media-pla 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 { createProxiedEndpointIfNecessary } 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'; @@ -100,12 +96,13 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer protected async _getPlayerSource(): Promise { const cameraConfig = this.camera?.getConfig(); + const proxyConfig = this.camera?.getProxyConfig(); if (!this.hass || !cameraConfig) { return null; } - const endpoint = this.cameraEndpoints?.go2rtc; - if (!endpoint) { + const streamEndpoint = this.cameraEndpoints?.go2rtc; + if (!streamEndpoint) { this._handleError({ message: localize('error.live_camera_no_endpoint'), context: cameraConfig, @@ -113,56 +110,49 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer return null; } - const proxyConfig = this.camera?.getProxyConfig(); - let src: string | null = endpoint.endpoint; - let sign: boolean = endpoint.sign ?? false; + let result: string | null = null; - if (proxyConfig && shouldUseWebProxy(this.hass, proxyConfig, 'live')) { - if (proxyConfig.dynamic) { - try { - await addDynamicProxyURL(this.hass, endpoint.endpoint, { - proxyConfig, - ttl: GO2RTC_URL_SIGN_EXPIRY_SECONDS, + try { + const endpoint = await createProxiedEndpointIfNecessary( + this.hass, + streamEndpoint, + proxyConfig, + { + context: 'live', + ttl: GO2RTC_URL_SIGN_EXPIRY_SECONDS, + websocket: true, - // 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; - } - } + // The link may need to be opened multiple times. + openLimit: 0, + }, + ); - src = getWebProxiedURL(endpoint.endpoint, { websocket: true }); - sign = true; - } - - if (src && sign) { - try { - src = await homeAssistantSignPath( + if (endpoint.sign) { + result = await homeAssistantSignPath( this.hass, - src, + endpoint.endpoint, GO2RTC_URL_SIGN_EXPIRY_SECONDS, ); - } catch (e) { - this._handleError( - { + if (!result) { + this._handleError({ message: localize('error.failed_sign'), context: cameraConfig, - }, - e as Error, - ); - return null; + }); + } + } else { + result = endpoint.endpoint; } + } catch (e) { + this._handleError( + { + message: localize('error.failed_proxy'), + context: cameraConfig, + }, + e as Error, + ); } - return src; + return result; } protected async _createPlayer(): Promise { diff --git a/src/components/live/providers/go2rtc/video-rtc.js b/src/components/live/providers/go2rtc/video-rtc.js index 269766d4..da2299c2 100644 --- a/src/components/live/providers/go2rtc/video-rtc.js +++ b/src/components/live/providers/go2rtc/video-rtc.js @@ -1,4 +1,8 @@ -import { mayHaveAudio } from '../../../../utils/audio'; +import { + addAudioTracksMuteStateListener, + has2WayAudio, + hasAudio, +} from '../../../../utils/audio'; import { hideMediaControlsTemporarily, MEDIA_LOAD_CONTROLS_HIDE_SECONDS, @@ -172,6 +176,29 @@ export class VideoRTC extends HTMLElement { * @type {boolean}} */ this.controls = true; + + /** + * [internal] Cleanup function for audio track mute state listener. + * @type {Function | null} + */ + this._audioTracksMuteStateCleanup = null; + } + + /** + * Dispatch a media loaded event with current capabilities. + */ + _dispatchMediaLoadedEvent() { + dispatchMediaLoadedEvent(this, this.video, { + ...(this.mediaPlayerController && { + mediaPlayerController: this.mediaPlayerController, + }), + capabilities: { + has2WayAudio: has2WayAudio(this.pc), + hasAudio: hasAudio(this.video, this.pc, this.mseCodecs), + supportsPause: true, + }, + technology: getTechnologyForVideoRTC(this), + }); } /** @@ -352,21 +379,13 @@ export class VideoRTC extends HTMLElement { if (this.controls) { hideMediaControlsTemporarily(this.video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS); } - dispatchMediaLoadedEvent(this, this.video, { - ...(this.mediaPlayerController && { - mediaPlayerController: this.mediaPlayerController, - }), - capabilities: { - // 2-way audio is only supported on WebRTC connections. The state of - // `this.microphoneStream` is not taken into account here since - // that can be created after the fact -- this is purely saying that - // were a microphone stream available it could be used usefully. - supports2WayAudio: !!this.pc, - supportsPause: true, - hasAudio: mayHaveAudio(this.video), - }, - technology: getTechnologyForVideoRTC(this), - }); + this._dispatchMediaLoadedEvent(); + + // Listen for audio track mute/unmute changes and re-dispatch + this._audioTracksMuteStateCleanup?.(); + this._audioTracksMuteStateCleanup = addAudioTracksMuteStateListener(this.pc, () => + this._dispatchMediaLoadedEvent(), + ); }; this.video.onvolumechange = () => dispatchMediaVolumeChangeEvent(this); this.video.onplay = () => dispatchMediaPlayEvent(this); @@ -415,6 +434,9 @@ export class VideoRTC extends HTMLElement { this.video.src = ''; this.video.srcObject = null; + + this._audioTracksMuteStateCleanup?.(); + this._audioTracksMuteStateCleanup = null; } /** diff --git a/src/components/viewer/provider.ts b/src/components/viewer/provider.ts index b4a41e38..3f8b7b99 100644 --- a/src/components/viewer/provider.ts +++ b/src/components/viewer/provider.ts @@ -22,11 +22,7 @@ import { isHARelativeURL } from '../../ha/is-ha-relative-url.js'; import { ResolvedMediaCache, resolveMedia } from '../../ha/resolved-media.js'; import { homeAssistantSignPath } from '../../ha/sign-path.js'; import { HomeAssistant, ResolvedMedia } from '../../ha/types.js'; -import { - addDynamicProxyURL, - getWebProxiedURL, - shouldUseWebProxy, -} from '../../ha/web-proxy.js'; +import { createProxiedEndpointIfNecessary } from '../../ha/web-proxy.js'; import '../../patches/ha-hls-player.js'; import viewerProviderStyle from '../../scss/viewer-provider.scss'; import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../../types.js'; @@ -153,34 +149,32 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi const camera = cameraID ? this.cameraManager?.getStore().getCamera(cameraID) : null; const proxyConfig = camera?.getProxyConfig(); - if (proxyConfig && shouldUseWebProxy(this.hass, proxyConfig, 'media')) { - if (proxyConfig.dynamic) { - // Don't use URL() parsing, since that will strip the port number if - // it's the default, just need to strip any hash part of the URL. - const urlWithoutQSorHash = unsignedURL.split(/#/)[0]; - - 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 { - this._url = await homeAssistantSignPath( - this.hass, - getWebProxiedURL(unsignedURL), - ); - } catch (e) { - errorToConsole(e as Error); - } - } else { + if (!proxyConfig) { this._url = unsignedURL; + return; + } + + try { + // Create endpoint from unsigned URL - it doesn't need signing initially + const unsignedEndpoint = { endpoint: unsignedURL, sign: false }; + const proxiedEndpoint = await createProxiedEndpointIfNecessary( + this.hass, + unsignedEndpoint, + proxyConfig, + { + context: 'media', + // The link may need to be opened multiple times. + openLimit: 0, + }, + ); + + if (proxiedEndpoint.sign) { + this._url = await homeAssistantSignPath(this.hass, proxiedEndpoint.endpoint); + } else { + this._url = proxiedEndpoint.endpoint; + } + } catch (e) { + errorToConsole(e as Error); } } diff --git a/src/ha/fetch.ts b/src/ha/fetch.ts new file mode 100644 index 00000000..3af7cfb8 --- /dev/null +++ b/src/ha/fetch.ts @@ -0,0 +1,89 @@ +import { ZodSchema } from 'zod'; +import { localize } from '../localize/localize'; +import { AdvancedCameraCardError, Endpoint } from '../types'; +import { homeAssistantSignPath } from './sign-path'; +import { HomeAssistant } from './types'; + +/** + * Fetch a JSON response from a signed or unsigned endpoint and validate it + * against a Zod schema. + * May throw. + * + * @param hass Home Assistant instance. + * @param endpoint The endpoint to fetch from (string or Endpoint object). + * @param schema The Zod schema to validate the response against. + * @returns The parsed data or throws if fetch/validation fails. + */ +export const homeAssistantSignAndFetch = async ( + hass: HomeAssistant, + endpoint: Endpoint, + schema: ZodSchema, + options?: { + timeoutSeconds?: number; + }, +): Promise => { + let url: string | null = endpoint.endpoint; + const sign = endpoint.sign; + + // Sign the path if needed + if (sign) { + try { + url = await homeAssistantSignPath(hass, url); + } catch (error) { + throw new AdvancedCameraCardError(localize('error.failed_sign'), { + endpoint, + error, + }); + } + + if (!url) { + throw new AdvancedCameraCardError(localize('error.failed_sign'), { + endpoint, + }); + } + } + + let response: Response; + try { + response = await fetch(url, { + ...(options?.timeoutSeconds && { + signal: AbortSignal.timeout(options.timeoutSeconds * 1000), + }), + }); + } catch (error) { + throw new AdvancedCameraCardError(`${localize('error.failed_fetch')}: ${url}`, { + endpoint, + error, + }); + } + + if (!response.ok) { + throw new AdvancedCameraCardError(localize('error.failed_response'), { + endpoint, + response, + }); + } + + let data: unknown; + try { + data = await response.json(); + } catch (error) { + throw new AdvancedCameraCardError(localize('error.invalid_response'), { + endpoint, + response, + error, + }); + } + + const parsed = schema.safeParse(data); + + if (!parsed.success) { + throw new AdvancedCameraCardError(localize('error.invalid_response'), { + endpoint, + data, + error: parsed.error, + }); + } + + return parsed.data; +}; diff --git a/src/ha/resolved-media.ts b/src/ha/resolved-media.ts index c465adfd..6b0e93ef 100644 --- a/src/ha/resolved-media.ts +++ b/src/ha/resolved-media.ts @@ -1,7 +1,7 @@ import { LRUCache } from '../cache/lru'; import { errorToConsole } from '../utils/basic'; -import { HomeAssistant, ResolvedMedia, resolvedMediaSchema } from './types'; import { homeAssistantWSRequest } from './ws-request'; +import { HomeAssistant, ResolvedMedia, resolvedMediaSchema } from './types'; // It's important the cache size be at least as large as the largest likely // media query or media items will from a given query will be evicted for other diff --git a/src/ha/sign-path.ts b/src/ha/sign-path.ts index 0f6b28f2..4e70b566 100644 --- a/src/ha/sign-path.ts +++ b/src/ha/sign-path.ts @@ -1,6 +1,6 @@ import { SignedPath, signedPathSchema } from '../types'; -import { HomeAssistant } from './types'; import { homeAssistantWSRequest } from './ws-request'; +import { HomeAssistant } from './types'; /** * Request that HA sign a path. May throw. @@ -10,7 +10,6 @@ import { homeAssistantWSRequest } from './ws-request'; * HA will sign for 30 seconds). * @returns The signed URL, or null if the response was malformed. */ - export async function homeAssistantSignPath( hass: HomeAssistant, path: string, diff --git a/src/ha/web-proxy.ts b/src/ha/web-proxy.ts index ade9b4cf..50865ee2 100644 --- a/src/ha/web-proxy.ts +++ b/src/ha/web-proxy.ts @@ -1,4 +1,5 @@ import { CameraProxyConfig } from '../camera-manager/types'; +import { Endpoint } from '../types'; import { HomeAssistant } from './types'; export const HASS_WEB_PROXY_DOMAIN = 'hass_web_proxy'; @@ -59,3 +60,45 @@ export async function addDynamicProxyURL( }), }); } + +interface CreateProxiedEndpointOptions { + context?: 'live' | 'media'; + ttl?: number; + websocket?: boolean; + openLimit?: number; +} + +/** + * Create a proxied endpoint if the proxy configuration requires it. + * Handles dynamic proxy registration and returns a proxied Endpoint. + * @param hass Home Assistant instance. + * @param endpoint The endpoint to potentially proxy. + * @param proxyConfig The camera proxy configuration. If undefined, returns original endpoint. + * @param options Additional options for proxy registration. + * @returns Proxied Endpoint if proxying needed, original endpoint otherwise. + */ +export const createProxiedEndpointIfNecessary = async ( + hass: HomeAssistant, + endpoint: Endpoint, + proxyConfig?: CameraProxyConfig, + options?: CreateProxiedEndpointOptions, +): Promise => { + const context = options?.context ?? 'media'; + if (!proxyConfig || !shouldUseWebProxy(hass, proxyConfig, context)) { + return endpoint; + } + if (proxyConfig.dynamic) { + // Strip hash fragment for registration - it's client-side only and + // not relevant for proxy pattern matching. + const registrationUrl = endpoint.endpoint.split(/#/)[0]; + await addDynamicProxyURL(hass, registrationUrl, { + proxyConfig, + ttl: options?.ttl, + openLimit: options?.openLimit ?? 0, + }); + } + return { + endpoint: getWebProxiedURL(endpoint.endpoint, { websocket: options?.websocket }), + sign: true, + }; +}; diff --git a/src/ha/ws-request.ts b/src/ha/ws-request.ts index 74d65fc7..097be5c3 100644 --- a/src/ha/ws-request.ts +++ b/src/ha/ws-request.ts @@ -11,7 +11,6 @@ import { HomeAssistant } from './types'; * @param request The request to make. * @returns The parsed valid response or null on malformed. */ - export async function homeAssistantWSRequest( hass: HomeAssistant, schema: ZodSchema, diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 96a0b808..ae6e390c 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -682,6 +682,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_fetch": "Could not fetch URL", "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", diff --git a/src/patches/ha-web-rtc-player.ts b/src/patches/ha-web-rtc-player.ts index 29a77814..a5032fbd 100644 --- a/src/patches/ha-web-rtc-player.ts +++ b/src/patches/ha-web-rtc-player.ts @@ -17,7 +17,11 @@ import { VideoMediaPlayerController } from '../components-lib/media-player/video import { renderMessage } from '../components/message.js'; import liveHAComponentsStyle from '../scss/live-ha-components.scss'; import { MediaPlayer, MediaPlayerController } from '../types.js'; -import { mayHaveAudio } from '../utils/audio.js'; +import { + addAudioTracksMuteStateListener, + AudioTracksMuteStateCleanup, + hasAudio, +} from '../utils/audio.js'; import { hideMediaControlsTemporarily, MEDIA_LOAD_CONTROLS_HIDE_SECONDS, @@ -44,6 +48,8 @@ customElements.whenDefined('ha-web-rtc-player').then(() => { () => this.controls, ); + protected _audioTracksMuteStateCleanup: AudioTracksMuteStateCleanup = null; + public async getMediaPlayerController(): Promise { return this._mediaPlayerController; } @@ -132,10 +138,32 @@ customElements.whenDefined('ha-web-rtc-player').then(() => { mediaPlayerController: this._mediaPlayerController, capabilities: { supportsPause: true, - hasAudio: mayHaveAudio(this._videoEl), + hasAudio: hasAudio(this._videoEl, this._peerConnection), }, technology: ['webrtc'], }); + + // Listen for audio track mute/unmute changes and re-dispatch + this._audioTracksMuteStateCleanup?.(); + this._audioTracksMuteStateCleanup = addAudioTracksMuteStateListener( + this._peerConnection, + () => { + dispatchMediaLoadedEvent(this, this._videoEl, { + mediaPlayerController: this._mediaPlayerController, + capabilities: { + supportsPause: true, + hasAudio: hasAudio(this._videoEl, this._peerConnection), + }, + technology: ['webrtc'], + }); + }, + ); + } + + private _cleanUp(): void { + super._cleanUp(); + this._audioTracksMuteStateCleanup?.(); + this._audioTracksMuteStateCleanup = null; } static get styles(): CSSResultGroup { diff --git a/src/types.ts b/src/types.ts index 3ed0e36b..bd060151 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,9 +15,14 @@ export class AdvancedCameraCardError extends Error { } export interface MediaLoadedCapabilities { - supports2WayAudio?: boolean; supportsPause?: boolean; + hasAudio?: boolean; + + // Note: This is whether the current stream already _has_ 2-way audio, not + // whether the underlying camera _could_ establish 2 way audio. For the + // latter, consult the camera's capabilities ('2-way-audio'). + has2WayAudio?: boolean; } export type MediaTechnology = @@ -132,6 +137,8 @@ export interface CapabilitiesRaw { menu?: boolean; trigger?: boolean; + + '2-way-audio'?: boolean; } export type CapabilityKey = keyof CapabilitiesRaw; @@ -147,6 +154,7 @@ export const capabilityKeys: readonly [CapabilityKey, ...CapabilityKey[]] = [ 'seek', 'snapshots', 'substream', + '2-way-audio', 'trigger', ] as const; diff --git a/src/utils/audio.ts b/src/utils/audio.ts index e90831d1..e1db1237 100644 --- a/src/utils/audio.ts +++ b/src/utils/audio.ts @@ -22,3 +22,110 @@ export const mayHaveAudio = (video: HTMLVideoElement & AudioProperties): boolean } return true; }; + +/** + * Determine if audio is available for a go2rtc stream. + * @param pc The RTCPeerConnection (for WebRTC streams). + * @param mseCodecs The negotiated MSE codecs string (for MSE streams). + * @param video The video element (fallback for browser-based detection). + * @returns True if audio is available. + */ +export const hasAudio = ( + video: HTMLVideoElement & AudioProperties, + pc?: RTCPeerConnection | null, + mseCodecs?: string, +): boolean => { + // For WebRTC: Check if there's an audio receiver with an active track. + // We check that the track is not muted because muted means no media data + // is flowing (e.g., the source isn't producing audio). It is not related to + // the audio being muted by the user on the receiving end. + if (pc) { + const receivers = pc.getReceivers(); + + // Only trust receivers if they're populated (connection established) + if (receivers.length > 0) { + return receivers.some( + (receiver) => receiver.track?.kind === 'audio' && !receiver.track?.muted, + ); + } + } + // For MSE: Check negotiated codecs for audio codecs + if (mseCodecs) { + return ( + mseCodecs.includes('mp4a') || + mseCodecs.includes('opus') || + mseCodecs.includes('flac') + ); + } + // Fallback to browser-based detection (unreliable in Chrome) + return mayHaveAudio(video); +}; + +/** + * Check if a WebRTC peer connection has an outbound audio channel (i.e. 2-way + * audio / microphone support). + * @param pc The RTCPeerConnection to check. + * @returns True if the connection has an audio transceiver configured to send. + */ +export const has2WayAudio = (pc: RTCPeerConnection | null): boolean => { + return !!pc + ?.getTransceivers() + .some( + (tr) => + tr.sender.track?.kind === 'audio' && + (tr.direction === 'sendonly' || tr.direction === 'sendrecv'), + ); +}; + +export type AudioTracksMuteStateCleanup = (() => void) | null; + +/** + * Add listeners for mute/unmute events on all audio tracks in a WebRTC connection. + * The callback is fired when the aggregate mute state changes between: + * - All tracks unmuted (hasAudio = true) + * - All tracks muted (hasAudio = false) + * Mixed states (some muted, some unmuted) do not trigger the callback. + * @param pc The RTCPeerConnection to monitor. + * @param handler Callback fired with `true` when ALL tracks become unmuted, + * `false` when ALL tracks become muted. + * @returns A cleanup function to remove listeners, or null if no audio tracks. + */ +export const addAudioTracksMuteStateListener = ( + pc: RTCPeerConnection | null, + handler: (hasAudio: boolean) => void, +): AudioTracksMuteStateCleanup => { + if (!pc) { + return null; + } + + const audioTracks = pc + .getReceivers() + .map((r) => r.track) + .filter((t): t is MediaStreamTrack => t?.kind === 'audio'); + + if (audioTracks.length === 0) { + return null; + } + + const hasAnyUnmuted = () => audioTracks.some((t) => !t.muted); + let lastHasAudio = hasAnyUnmuted(); + + const _handler = () => { + const nowHasAudio = hasAnyUnmuted(); + if (nowHasAudio !== lastHasAudio) { + lastHasAudio = nowHasAudio; + handler(nowHasAudio); + } + }; + + audioTracks.forEach((track) => { + track.addEventListener('unmute', _handler); + track.addEventListener('mute', _handler); + }); + + return () => + audioTracks.forEach((track) => { + track.removeEventListener('unmute', _handler); + track.removeEventListener('mute', _handler); + }); +}; diff --git a/src/utils/live-provider.ts b/src/utils/live-provider.ts new file mode 100644 index 00000000..483c82eb --- /dev/null +++ b/src/utils/live-provider.ts @@ -0,0 +1,36 @@ +import { CameraProxyConfig } from '../camera-manager/types'; +import { supports2WayAudio as gortcSupports2WayAudio } from '../camera-manager/utils/go2rtc/audio'; +import { CameraConfig } from '../config/schema/cameras'; +import { LiveProvider } from '../config/schema/cameras.js'; +import { HomeAssistant } from '../ha/types'; +import { Endpoint } from '../types'; + +export const getResolvedLiveProvider = ( + config: CameraConfig | undefined, +): Exclude => { + if (config?.live_provider === 'auto') { + if (config.webrtc_card?.entity || config.webrtc_card?.url) { + return 'webrtc-card'; + } else if (config.camera_entity) { + return 'ha'; + } else if (config.frigate?.camera_name) { + return 'jsmpeg'; + } + // Default for auto is 'image' + return 'image'; + } + return config?.live_provider ?? 'image'; +}; + +export const liveProviderSupports2WayAudio = async ( + hass: HomeAssistant, + config: CameraConfig, + go2rtcMetadataEndpoint?: Endpoint | null, + proxyConfig?: CameraProxyConfig, +): Promise => { + if (getResolvedLiveProvider(config) !== 'go2rtc') { + return false; + } + + return gortcSupports2WayAudio(hass, go2rtcMetadataEndpoint, proxyConfig); +}; diff --git a/tests/camera-manager/camera.test.ts b/tests/camera-manager/camera.test.ts index 97df86d1..087eb594 100644 --- a/tests/camera-manager/camera.test.ts +++ b/tests/camera-manager/camera.test.ts @@ -4,13 +4,18 @@ 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 { liveProviderSupports2WayAudio } from '../../src/utils/live-provider.js'; import { callStateWatcherCallback, createCameraConfig, createCapabilities, + createHASS, + createInitializedCamera, createStateEntity, } from '../test-utils.js'; +vi.mock('../../src/utils/live-provider.js'); + describe('Camera', () => { it('should get config', async () => { const config = createCameraConfig(); @@ -24,12 +29,10 @@ describe('Camera', () => { describe('should get capabilities', async () => { it('when populated', async () => { const capabilities = createCapabilities(); - const camera = new Camera( + const camera = await createInitializedCamera( createCameraConfig(), new GenericCameraManagerEngine(mock()), - { - capabilities: capabilities, - }, + capabilities, ); expect(camera.getCapabilities()).toBe(capabilities); }); @@ -72,29 +75,85 @@ describe('Camera', () => { ); }); - it('should initialize and destroy', async () => { - const camera = new Camera( - createCameraConfig({ - triggers: { - entities: ['camera.foo'], - }, - }), - new GenericCameraManagerEngine(mock()), - { - capabilities: createCapabilities({ trigger: true }), - }, - ); + describe('initialize', () => { + it('should initialize and destroy', async () => { + const camera = new Camera( + createCameraConfig({ + triggers: { + entities: ['camera.foo'], + }, + }), + new GenericCameraManagerEngine(mock()), + ); - const stateWatcher = mock(); - await camera.initialize({ - stateWatcher: stateWatcher, + const stateWatcher = mock(); + await camera.initialize({ + hass: createHASS(), + stateWatcher: stateWatcher, + capabilityOptions: { capabilities: createCapabilities({ trigger: true }) }, + }); + + expect(stateWatcher.subscribe).toBeCalledWith(expect.any(Function), [ + 'camera.foo', + ]); + + await camera.destroy(); + + expect(stateWatcher.unsubscribe).toBeCalled(); }); - expect(stateWatcher.subscribe).toBeCalledWith(expect.any(Function), ['camera.foo']); + it('should set capabilities and use go2rtc metadata endpoint', async () => { + const camera = new Camera( + createCameraConfig({ + go2rtc: { + url: 'http://go2rtc', + stream: 'stream', + }, + }), + new GenericCameraManagerEngine(mock()), + ); - await camera.destroy(); + vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true); - expect(stateWatcher.unsubscribe).toBeCalled(); + await camera.initialize({ + hass: createHASS(), + stateWatcher: mock(), + }); + + expect(liveProviderSupports2WayAudio).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + { + endpoint: + 'http://go2rtc/api/streams?src=stream&video=all&audio=allµphone', + sign: false, + }, + expect.anything(), + ); + + expect(camera.getCapabilities()?.has('2-way-audio')).toBe(true); + }); + + it('should set capabilities when go2rtc metadata endpoint fails', async () => { + const camera = new Camera( + createCameraConfig({ + go2rtc: { + url: 'http://go2rtc', + stream: 'stream', + }, + }), + new GenericCameraManagerEngine(mock()), + ); + + vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(false); + + await camera.initialize({ + hass: createHASS(), + stateWatcher: mock(), + }); + + expect(camera.getCapabilities()?.has('2-way-audio')).toBe(false); + }); }); describe('should handle trigger state changes', () => { @@ -120,14 +179,15 @@ describe('Camera', () => { }), new GenericCameraManagerEngine(mock()), { - capabilities: createCapabilities({ trigger: true }), eventCallback: eventCallback, }, ); const stateWatcher = mock(); await camera.initialize({ + hass: createHASS(), stateWatcher: stateWatcher, + capabilityOptions: { capabilities: createCapabilities({ trigger: true }) }, }); expect(stateWatcher.subscribe).toBeCalled(); @@ -157,14 +217,15 @@ describe('Camera', () => { }), new GenericCameraManagerEngine(mock()), { - capabilities: createCapabilities({ trigger: false }), eventCallback: eventCallback, }, ); const stateWatcher = mock(); await camera.initialize({ + hass: createHASS(), stateWatcher: stateWatcher, + capabilityOptions: { capabilities: createCapabilities({ trigger: false }) }, }); expect(stateWatcher.subscribe).not.toBeCalled(); @@ -331,4 +392,40 @@ describe('Camera', () => { }, ); }); + + describe('getEndpoints', () => { + it('should return null when no endpoints are available', () => { + const camera = new Camera( + createCameraConfig({ + go2rtc: { stream: '' }, + camera_entity: '', + }), + new GenericCameraManagerEngine(mock()), + ); + expect(camera.getEndpoints()).toBeNull(); + }); + + it('should correctly merge endpoints', async () => { + const camera = new Camera( + createCameraConfig({ + go2rtc: { + url: 'http://go2rtc', + stream: 'stream', + }, + camera_entity: 'camera.foo', + }), + new GenericCameraManagerEngine(mock()), + ); + + expect(camera.getEndpoints()).toEqual({ + go2rtc: { + endpoint: 'http://go2rtc/api/ws?src=stream', + sign: false, + }, + webrtcCard: { + endpoint: 'camera.foo', + }, + }); + }); + }); }); diff --git a/tests/camera-manager/frigate/camera.test.ts b/tests/camera-manager/frigate/camera.test.ts index d5b4a8cd..96b23b1c 100644 --- a/tests/camera-manager/frigate/camera.test.ts +++ b/tests/camera-manager/frigate/camera.test.ts @@ -3,13 +3,21 @@ import { mock } from 'vitest-mock-extended'; import { CameraManagerEngine } from '../../../src/camera-manager/engine'; import { FrigateCamera } from '../../../src/camera-manager/frigate/camera'; import { FrigateEventWatcher } from '../../../src/camera-manager/frigate/event-watcher'; +import { + FrigateEventViewMedia, + FrigateRecordingViewMedia, +} from '../../../src/camera-manager/frigate/media'; import { getPTZInfo } from '../../../src/camera-manager/frigate/requests'; -import { FrigateEventChange } from '../../../src/camera-manager/frigate/types'; +import { + eventSchema, + FrigateEventChange, +} from '../../../src/camera-manager/frigate/types'; import { ActionsExecutor } from '../../../src/card-controller/actions/types'; import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; import { PTZAction } from '../../../src/config/schema/actions/custom/ptz'; import { CameraTriggerEventType } from '../../../src/config/schema/cameras'; import { Entity, EntityRegistryManager } from '../../../src/ha/registry/entity/types'; +import { ViewMediaType } from '../../../src/view/item'; import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock'; import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils'; @@ -297,6 +305,294 @@ describe('FrigateCamera', () => { }); }); + describe('getEndpoints', () => { + describe('getUIEndpoint', () => { + it('should return null when no frigate URL is set', () => { + const camera = new FrigateCamera( + createCameraConfig({ + frigate: { + url: '', + }, + }), + mock(), + ); + expect(camera.getEndpoints()).toBeNull(); + }); + + it('should return frigate URL when no camera name is set', () => { + const camera = new FrigateCamera( + createCameraConfig({ + frigate: { + url: 'http://frigate', + camera_name: '', + }, + }), + mock(), + ); + expect(camera.getEndpoints({ view: 'live' })?.ui).toEqual({ + endpoint: 'http://frigate', + }); + }); + + it('should return camera URL for live view', () => { + const camera = new FrigateCamera( + createCameraConfig({ + frigate: { + url: 'http://frigate', + camera_name: 'front_door', + }, + }), + mock(), + ); + expect(camera.getEndpoints({ view: 'live' })?.ui).toEqual({ + endpoint: 'http://frigate/#front_door', + }); + }); + + it('should return events URL for clip media', () => { + const camera = new FrigateCamera( + createCameraConfig({ + frigate: { + url: 'http://frigate', + camera_name: 'front_door', + }, + }), + mock(), + ); + const event = eventSchema.parse({ + camera: 'front_door', + id: 'event-id', + label: 'person', + start_time: 100, + end_time: 200, + has_clip: true, + has_snapshot: true, + retain_indefinitely: false, + false_positive: false, + sub_label: '', + top_score: 0.8, + zones: [], + }); + const media = new FrigateEventViewMedia( + ViewMediaType.Clip, + 'front_door', + event, + 'content-id', + 'thumbnail', + ); + + expect(camera.getEndpoints({ view: 'media', media })?.ui).toEqual({ + endpoint: 'http://frigate/events?camera=front_door', + }); + }); + + it('should return events URL for snapshot media', () => { + const camera = new FrigateCamera( + createCameraConfig({ + frigate: { + url: 'http://frigate', + camera_name: 'front_door', + }, + }), + mock(), + ); + const event = eventSchema.parse({ + camera: 'front_door', + id: 'event-id', + label: 'person', + start_time: 100, + end_time: 200, + has_clip: true, + has_snapshot: true, + retain_indefinitely: false, + false_positive: false, + sub_label: '', + top_score: 0.8, + zones: [], + }); + const media = new FrigateEventViewMedia( + ViewMediaType.Snapshot, + 'front_door', + event, + 'content-id', + 'thumbnail', + ); + + expect(camera.getEndpoints({ view: 'media', media })?.ui).toEqual({ + endpoint: 'http://frigate/events?camera=front_door', + }); + }); + + it('should return recordings URL with time for recording media with startTime', () => { + const camera = new FrigateCamera( + createCameraConfig({ + frigate: { + url: 'http://frigate', + camera_name: 'front_door', + }, + }), + mock(), + ); + const media = new FrigateRecordingViewMedia( + ViewMediaType.Recording, + 'front_door', + { + cameraID: 'front_door', + startTime: new Date('2023-01-01T10:00:00Z'), + endTime: new Date('2023-01-01T11:00:00Z'), + events: 0, + }, + 'recording-id', + 'content-id', + 'title', + ); + + expect(camera.getEndpoints({ view: 'media', media })?.ui).toEqual({ + endpoint: 'http://frigate/recording/front_door/2023-01-01/10', + }); + }); + + it('should return recordings URL without time for recording media without startTime', () => { + const camera = new FrigateCamera( + createCameraConfig({ + frigate: { + url: 'http://frigate', + camera_name: 'front_door', + }, + }), + mock(), + ); + // Create a media object where getStartTime returns null + const media = new FrigateRecordingViewMedia( + ViewMediaType.Recording, + 'front_door', + { + cameraID: 'front_door', + // Forced null for test + startTime: null as unknown as Date, + endTime: new Date('2023-01-01T11:00:00Z'), + events: 0, + }, + 'recording-id', + 'content-id', + 'title', + ); + + expect(camera.getEndpoints({ view: 'media', media })?.ui).toEqual({ + endpoint: 'http://frigate/recording/front_door', + }); + }); + + it('should return events URL for clip/clips/snapshots/snapshot views', () => { + const camera = new FrigateCamera( + createCameraConfig({ + frigate: { + url: 'http://frigate', + camera_name: 'front_door', + }, + }), + mock(), + ); + expect(camera.getEndpoints({ view: 'clip' })?.ui?.endpoint).toBe( + 'http://frigate/events?camera=front_door', + ); + expect(camera.getEndpoints({ view: 'clips' })?.ui?.endpoint).toBe( + 'http://frigate/events?camera=front_door', + ); + expect(camera.getEndpoints({ view: 'snapshots' })?.ui?.endpoint).toBe( + 'http://frigate/events?camera=front_door', + ); + expect(camera.getEndpoints({ view: 'snapshot' })?.ui?.endpoint).toBe( + 'http://frigate/events?camera=front_door', + ); + }); + + it('should return recordings URL for recording/recordings views', () => { + const camera = new FrigateCamera( + createCameraConfig({ + frigate: { + url: 'http://frigate', + camera_name: 'front_door', + }, + }), + mock(), + ); + expect(camera.getEndpoints({ view: 'recording' })?.ui?.endpoint).toBe( + 'http://frigate/recording/front_door', + ); + expect(camera.getEndpoints({ view: 'recordings' })?.ui?.endpoint).toBe( + 'http://frigate/recording/front_door', + ); + }); + + it('should return camera URL as default fallback', () => { + const camera = new FrigateCamera( + createCameraConfig({ + frigate: { + url: 'http://frigate', + camera_name: 'front_door', + }, + }), + mock(), + ); + expect(camera.getEndpoints({ view: 'timeline' })?.ui).toEqual({ + endpoint: 'http://frigate/#front_door', + }); + }); + }); + + describe('getGo2RTCStreamEndpoint', () => { + it('should return default frigate go2rtc paths', () => { + const camera = new FrigateCamera( + createCameraConfig({ + frigate: { + client_id: 'frigate', + camera_name: 'front_door', + }, + }), + mock(), + ); + const endpoints = camera.getEndpoints(); + expect(endpoints?.go2rtc).toEqual({ + endpoint: '/api/frigate/frigate/mse/api/ws?src=front_door', + sign: true, + }); + }); + }); + + describe('getJSMPEGEndpoint', () => { + it('should return default frigate jsmpeg path', () => { + const camera = new FrigateCamera( + createCameraConfig({ + frigate: { + client_id: 'frigate', + camera_name: 'front_door', + }, + }), + mock(), + ); + const endpoints = camera.getEndpoints(); + expect(endpoints?.jsmpeg).toEqual({ + endpoint: '/api/frigate/frigate/jsmpeg/front_door', + sign: true, + }); + }); + + it('should return null if no camera name is set', () => { + const camera = new FrigateCamera( + createCameraConfig({ + frigate: { + camera_name: '', + }, + }), + mock(), + ); + const endpoints = camera.getEndpoints(); + expect(endpoints?.jsmpeg).toBeUndefined(); + }); + }); + }); + describe('should handle events', () => { it('should subscribe', async () => { const camera = new FrigateCamera( @@ -654,6 +950,53 @@ describe('FrigateCamera', () => { expect(eventCallback).toHaveBeenCalledTimes(call ? 1 : 0); }); }); + + it('should ignore events when camera ID is not set', async () => { + const eventCallback = vi.fn(); + const camera = new FrigateCamera( + createCameraConfig({ + // Note: No 'id' is set here + frigate: { + camera_name: 'camera.front_door', + }, + }), + mock(), + { + eventCallback: eventCallback, + }, + ); + + const hass = createHASS(); + const eventWatcher = mock(); + await camera.initialize({ + hass: hass, + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: eventWatcher, + }); + + callEventWatcherCallback(eventWatcher, { + type: 'new', + before: { + camera: 'camera.front_door', + snapshot: null, + has_clip: false, + has_snapshot: false, + label: 'person', + current_zones: [], + }, + after: { + camera: 'camera.front_door', + snapshot: null, + has_clip: false, + has_snapshot: true, + label: 'person', + current_zones: [], + }, + }); + + expect(eventCallback).not.toHaveBeenCalled(); + }); }); }); diff --git a/tests/camera-manager/frigate/engine-frigate.test.ts b/tests/camera-manager/frigate/engine-frigate.test.ts index b9ed25d8..cb3e0f55 100644 --- a/tests/camera-manager/frigate/engine-frigate.test.ts +++ b/tests/camera-manager/frigate/engine-frigate.test.ts @@ -9,11 +9,10 @@ import { FrigateEvent, eventSchema } from '../../../src/camera-manager/frigate/t import { CameraManagerRequestCache } from '../../../src/camera-manager/types'; import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; import { CameraConfig } from '../../../src/config/schema/cameras'; -import { AdvancedCameraCardView } from '../../../src/config/schema/common/const'; import { RawAdvancedCameraCardConfig } from '../../../src/config/types'; import { ViewMedia, ViewMediaType } from '../../../src/view/item'; import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock'; -import { TestViewMedia, createCameraConfig, createHASS } from '../../test-utils'; +import { createCameraConfig, createHASS } from '../../test-utils'; const createEngine = (): FrigateCameraManagerEngine => { return new FrigateCameraManagerEngine( @@ -141,265 +140,3 @@ describe('getMediaDownloadPath', () => { expect(endpoint).toBeNull(); }); }); - -describe('getCameraEndpoints', () => { - it('should get basic endpoints', () => { - const endpoints = createEngine().getCameraEndpoints(createFrigateCameraConfig()); - - expect(endpoints).toEqual({ - go2rtc: { - endpoint: '/api/frigate/frigate/mse/api/ws?src=camera-1', - sign: true, - }, - jsmpeg: { - endpoint: '/api/frigate/frigate/jsmpeg/camera-1', - sign: true, - }, - webrtcCard: { - endpoint: 'camera.office', - }, - }); - }); - - describe('should get overridden go2rtc url', () => { - it('when local HA path', () => { - const endpoints = createEngine().getCameraEndpoints( - createFrigateCameraConfig({ - go2rtc: { - url: '/local/path', - }, - }), - ); - - expect(endpoints).toEqual( - expect.objectContaining({ - go2rtc: { - endpoint: '/local/path/api/ws?src=camera-1', - sign: true, - }, - }), - ); - }); - - it('when remote', () => { - const endpoints = createEngine().getCameraEndpoints( - createFrigateCameraConfig({ - go2rtc: { - url: 'https://my.custom.go2rtc', - }, - }), - ); - - expect(endpoints).toEqual( - expect.objectContaining({ - go2rtc: { - endpoint: 'https://my.custom.go2rtc/api/ws?src=camera-1', - sign: false, - }, - }), - ); - }); - }); - - it('should not set webrtc_card endpoint without camera name', () => { - const endpoints = createEngine().getCameraEndpoints(createCameraConfig()); - - expect(endpoints).not.toEqual( - expect.objectContaining({ - webrtcCard: expect.anything(), - }), - ); - }); - - describe('should include UI endpoint', () => { - it('with basic url', () => { - const endpoints = createEngine().getCameraEndpoints( - createCameraConfig({ - frigate: { - url: 'http://my.frigate', - }, - }), - ); - - expect(endpoints).not.toEqual( - expect.objectContaining({ - ui: { - url: 'http://my.frigate', - }, - }), - ); - }); - - it('with camera name', () => { - const endpoints = createEngine().getCameraEndpoints( - createCameraConfig({ - frigate: { - url: 'http://my.frigate', - camera_name: 'my-camera', - }, - }), - ); - - expect(endpoints).not.toEqual( - expect.objectContaining({ - ui: { - url: 'http://my.frigate/cameras/my-camera', - }, - }), - ); - }); - - describe('with event media type', () => { - it.each([[ViewMediaType.Clip], [ViewMediaType.Snapshot]])( - '%s', - (mediaType: ViewMediaType) => { - const endpoints = createEngine().getCameraEndpoints( - createCameraConfig({ - frigate: { - url: 'http://my.frigate', - camera_name: 'my-camera', - }, - }), - { - media: new TestViewMedia({ mediaType: mediaType }), - }, - ); - - expect(endpoints).not.toEqual( - expect.objectContaining({ - ui: { - url: 'http://my.frigate/events?camera=my-camera', - }, - }), - ); - }, - ); - }); - - describe('with recording media type', () => { - it('with start time', () => { - const startTime = new Date('2023-10-07T16:42:00'); - const endpoints = createEngine().getCameraEndpoints( - createCameraConfig({ - frigate: { - url: 'http://my.frigate', - camera_name: 'my-camera', - }, - }), - { - media: new TestViewMedia({ - mediaType: ViewMediaType.Recording, - startTime: startTime, - }), - }, - ); - - expect(endpoints).not.toEqual( - expect.objectContaining({ - ui: { - url: 'http://my.frigate/recording/my-camera/2023-10-07/16', - }, - }), - ); - }); - - it('without start time', () => { - const endpoints = createEngine().getCameraEndpoints( - createCameraConfig({ - frigate: { - url: 'http://my.frigate', - camera_name: 'my-camera', - }, - }), - { - media: new TestViewMedia({ mediaType: ViewMediaType.Recording }), - }, - ); - - expect(endpoints).not.toEqual( - expect.objectContaining({ - ui: { - url: 'http://my.frigate/recording/my-camera/', - }, - }), - ); - }); - }); - - describe('with view', () => { - it('live', () => { - const endpoints = createEngine().getCameraEndpoints( - createCameraConfig({ - frigate: { - url: 'http://my.frigate', - camera_name: 'my-camera', - }, - }), - { - view: 'live', - }, - ); - - expect(endpoints).not.toEqual( - expect.objectContaining({ - ui: { - url: 'http://my.frigate/cameras/my-camera', - }, - }), - ); - }); - - it.each([ - ['clip' as const], - ['clips' as const], - ['snapshot' as const], - ['snapshots' as const], - ])('%s', (viewName: AdvancedCameraCardView) => { - const endpoints = createEngine().getCameraEndpoints( - createCameraConfig({ - frigate: { - url: 'http://my.frigate', - camera_name: 'my-camera', - }, - }), - { - view: viewName, - }, - ); - - expect(endpoints).not.toEqual( - expect.objectContaining({ - ui: { - url: 'http://my.frigate/events?camera=my-camera', - }, - }), - ); - }); - - it.each([['recording' as const], ['recordings' as const]])( - '%s', - (viewName: AdvancedCameraCardView) => { - const endpoints = createEngine().getCameraEndpoints( - createCameraConfig({ - frigate: { - url: 'http://my.frigate', - camera_name: 'my-camera', - }, - }), - { - view: viewName, - }, - ); - - expect(endpoints).not.toEqual( - expect.objectContaining({ - ui: { - url: 'http://my.frigate/recording/my-camera/', - }, - }), - ); - }, - ); - }); - }); -}); diff --git a/tests/camera-manager/generic/engine-generic.test.ts b/tests/camera-manager/generic/engine-generic.test.ts index 9fb89297..bf7e0fde 100644 --- a/tests/camera-manager/generic/engine-generic.test.ts +++ b/tests/camera-manager/generic/engine-generic.test.ts @@ -295,21 +295,26 @@ describe('GenericCameraManagerEngine', () => { }); describe('should get camera endpoints', () => { - it('default', () => { - expect(createEngine().getCameraEndpoints(createGenericCameraConfig())).toBeNull(); + it('default', async () => { + const camera = await createEngine().createCamera( + createHASS(), + createGenericCameraConfig(), + ); + expect(camera.getEndpoints()).toBeNull(); }); - it('for go2rtc', () => { - expect( - createEngine().getCameraEndpoints( - createGenericCameraConfig({ - go2rtc: { - stream: 'stream', - url: '/local/path', - }, - }), - ), - ).toEqual({ + it('for go2rtc', async () => { + const camera = await createEngine().createCamera( + createHASS(), + createGenericCameraConfig({ + go2rtc: { + stream: 'stream', + url: '/local/path', + }, + }), + ); + + expect(camera.getEndpoints()).toEqual({ go2rtc: { endpoint: '/local/path/api/ws?src=stream', sign: true, @@ -317,14 +322,15 @@ describe('GenericCameraManagerEngine', () => { }); }); - it('for webrtc-card', () => { - expect( - createEngine().getCameraEndpoints( - createGenericCameraConfig({ - camera_entity: 'camera.office', - }), - ), - ).toEqual({ + it('for webrtc-card', async () => { + const camera = await createEngine().createCamera( + createHASS(), + createGenericCameraConfig({ + camera_entity: 'camera.office', + }), + ); + + expect(camera.getEndpoints()).toEqual({ webrtcCard: { endpoint: 'camera.office', }, diff --git a/tests/camera-manager/manager.test.ts b/tests/camera-manager/manager.test.ts index 20b529ad..7871c604 100644 --- a/tests/camera-manager/manager.test.ts +++ b/tests/camera-manager/manager.test.ts @@ -32,7 +32,7 @@ import { ViewFolder, ViewItem, ViewMedia } from '../../src/view/item.js'; import { ViewItemCapabilities } from '../../src/view/types.js'; import { TestViewMedia, - createCamera, + createInitializedCamera, createCameraConfig, createCapabilities, createCardAPI, @@ -253,7 +253,7 @@ describe('CameraManager', async () => { if (engineType) { vi.mocked(mockEngine.createCamera).mockImplementationOnce( async (_hass: HomeAssistant, cameraConfig: CameraConfig): Promise => - createCamera( + await createInitializedCamera( cameraConfig, mockEngine, camera.capabilties ?? createCapabilities(), @@ -984,12 +984,7 @@ describe('CameraManager', async () => { expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); - const result: CameraEndpoints = {}; - const context: CameraEndpointsContext = {}; - vi.mocked(engine.getCameraEndpoints).mockReturnValue(result); - - expect(manager.getCameraEndpoints('id', context)).toBe(result); - expect(engine.getCameraEndpoints).toBeCalledWith(expect.anything(), context); + expect(manager.getCameraEndpoints('id', { view: 'live' })).toBeDefined(); }); }); diff --git a/tests/camera-manager/motioneye/engine-motioneye.test.ts b/tests/camera-manager/motioneye/engine-motioneye.test.ts new file mode 100644 index 00000000..6e94a1c8 --- /dev/null +++ b/tests/camera-manager/motioneye/engine-motioneye.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { MotionEyeCameraManagerEngine } from '../../../src/camera-manager/motioneye/engine-motioneye'; +import { CameraManagerRequestCache, Engine } from '../../../src/camera-manager/types'; +import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; +import { BrowseMediaWalker } from '../../../src/ha/browse-media/walker'; +import { ResolvedMediaCache } from '../../../src/ha/resolved-media'; +import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock'; + +const createEngine = (): MotionEyeCameraManagerEngine => { + return new MotionEyeCameraManagerEngine( + new EntityRegistryManagerMock(), + mock(), + new BrowseMediaWalker(), + new ResolvedMediaCache(), + new CameraManagerRequestCache(), + ); +}; + +describe('MotionEyeCameraManagerEngine', () => { + it('should get correct engine type', () => { + const engine = createEngine(); + expect(engine.getEngineType()).toBe(Engine.MotionEye); + }); +}); diff --git a/tests/camera-manager/reolink/engine-reolink.test.ts b/tests/camera-manager/reolink/engine-reolink.test.ts index 6d9e02ef..9b2af4b5 100644 --- a/tests/camera-manager/reolink/engine-reolink.test.ts +++ b/tests/camera-manager/reolink/engine-reolink.test.ts @@ -32,7 +32,7 @@ import { ResolvedMediaCache } from '../../../src/ha/resolved-media'; import { homeAssistantWSRequest } from '../../../src/ha/ws-request'; import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock'; import { - createCamera, + createInitializedCamera, createCameraConfig, createHASS, createRegistryEntity, @@ -254,15 +254,11 @@ describe('ReolinkCameraManagerEngine', () => { expect(camera.getConfig()).toBe(config); expect(camera.getEngine()).toBe(engine); expect(camera.getCapabilities()?.getRawCapabilities()).toEqual({ - 'favorite-events': false, - 'favorite-recordings': false, + '2-way-audio': false, clips: true, 'remote-control-entity': true, live: true, menu: true, - recordings: false, - seek: false, - snapshots: false, substream: true, trigger: true, }); @@ -287,32 +283,39 @@ describe('ReolinkCameraManagerEngine', () => { }); describe('should get camera endpoints', () => { - it('should return ui endpoint', () => { - const cameraConfig = createCameraConfig({ - reolink: { - url: 'http://path-to-reolink', - }, - }); + it('should return ui endpoint', async () => { + const engine = createPopulatedEngine(); + const camera = await engine.createCamera( + createHASS(), + createCameraConfig({ + camera_entity: 'camera.office', + reolink: { + url: 'http://path-to-reolink', + }, + }), + ); - const engine = createEngine(); - expect(engine.getCameraEndpoints(cameraConfig)).toEqual( + expect(camera.getEndpoints()).toEqual( expect.objectContaining({ ui: { endpoint: 'http://path-to-reolink' }, }), ); }); - it('should return go2rtc endpoint', () => { - const cameraConfig = createCameraConfig({ - go2rtc: { - url: 'http://path-to-go2rtc', - stream: 'stream', - }, - }); + it('should return go2rtc endpoint', async () => { + const engine = createPopulatedEngine(); + const camera = await engine.createCamera( + createHASS(), + createCameraConfig({ + camera_entity: 'camera.office', + go2rtc: { + url: 'http://path-to-go2rtc', + stream: 'stream', + }, + }), + ); - const engine = createEngine(); - - expect(engine.getCameraEndpoints(cameraConfig)).toEqual( + expect(camera.getEndpoints()).toEqual( expect.objectContaining({ go2rtc: { endpoint: 'http://path-to-go2rtc/api/ws?src=stream', sign: false }, }), @@ -629,7 +632,9 @@ describe('ReolinkCameraManagerEngine', () => { const engine = createPopulatedEngine(); const store = new CameraManagerStore(); - store.addCamera(createCamera(createCameraConfig({ id: 'office' }), engine)); + store.addCamera( + await createInitializedCamera(createCameraConfig({ id: 'office' }), engine), + ); const hass = createHASS(); const events = await engine.getEvents(hass, store, { @@ -1136,7 +1141,9 @@ describe('ReolinkCameraManagerEngine', () => { const engine = createPopulatedEngine(); const store = new CameraManagerStore(); - store.addCamera(createCamera(createCameraConfig({ id: 'office' }), engine)); + store.addCamera( + await createInitializedCamera(createCameraConfig({ id: 'office' }), engine), + ); const metadata = await engine.getMediaMetadata(createHASS(), store, { type: QueryType.MediaMetadata, diff --git a/tests/camera-manager/store.test.ts b/tests/camera-manager/store.test.ts index d4ba7e7a..d9d7d560 100644 --- a/tests/camera-manager/store.test.ts +++ b/tests/camera-manager/store.test.ts @@ -6,9 +6,14 @@ import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-fact import { CameraManagerStore } from '../../src/camera-manager/store.js'; import { Engine } from '../../src/camera-manager/types.js'; import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js'; +import { DeviceRegistryManager } from '../../src/ha/registry/device/index.js'; import { EntityRegistryManager } from '../../src/ha/registry/entity/types.js'; import { ResolvedMediaCache } from '../../src/ha/resolved-media.js'; -import { TestViewMedia, createCameraConfig } from '../test-utils.js'; +import { + TestViewMedia, + createCameraConfig, + createInitializedCamera, +} from '../test-utils.js'; describe('CameraManagerStore', async () => { const configVisible = createCameraConfig({ @@ -19,7 +24,10 @@ describe('CameraManagerStore', async () => { hide: true, }); - const engineFactory = new CameraManagerEngineFactory(mock()); + const engineFactory = new CameraManagerEngineFactory( + mock(), + mock(), + ); const engineGeneric = await engineFactory.createEngine(Engine.Generic, { stateWatcher: mock(), @@ -269,7 +277,7 @@ describe('CameraManagerStore', async () => { expect(store.getAllDependentCameras('one')).toEqual(new Set(['one', 'two'])); }); - it('should return cameras with specific capabilities', () => { + it('should return cameras with specific capabilities', async () => { const store = new CameraManagerStore(); store.addCamera( new Camera( @@ -283,22 +291,18 @@ describe('CameraManagerStore', async () => { ), ); store.addCamera( - new Camera( + await createInitializedCamera( createCameraConfig({ id: 'two', }), engineGeneric, - { - capabilities: new Capabilities({ - clips: true, - }), - }, + new Capabilities({ clips: true }), ), ); expect(store.getAllDependentCameras('one', 'clips')).toEqual(new Set(['two'])); }); - it('should return cameras with specific capabilities inclusive of parent', () => { + it('should return cameras with specific capabilities inclusive of parent', async () => { const store = new CameraManagerStore(); store.addCamera( new Camera( @@ -312,16 +316,12 @@ describe('CameraManagerStore', async () => { ), ); store.addCamera( - new Camera( + await createInitializedCamera( createCameraConfig({ id: 'two', }), engineGeneric, - { - capabilities: new Capabilities({ - clips: true, - }), - }, + new Capabilities({ clips: true }), ), ); expect(store.getAllDependentCameras('one', 'clips', { inclusive: true })).toEqual( @@ -330,19 +330,15 @@ describe('CameraManagerStore', async () => { }); }); - it('getCameraIDsWithCapability', () => { + it('getCameraIDsWithCapability', async () => { const store = new CameraManagerStore(); store.addCamera( - new Camera( + await createInitializedCamera( createCameraConfig({ id: 'one', }), engineGeneric, - { - capabilities: new Capabilities({ - clips: true, - }), - }, + new Capabilities({ clips: true }), ), ); store.addCamera( diff --git a/tests/camera-manager/tplink/engine-tplink.test.ts b/tests/camera-manager/tplink/engine-tplink.test.ts index 274cd939..f8a32be9 100644 --- a/tests/camera-manager/tplink/engine-tplink.test.ts +++ b/tests/camera-manager/tplink/engine-tplink.test.ts @@ -45,15 +45,10 @@ describe('TPLinkCameraManagerEngine', () => { expect(camera.getConfig()).toBe(config); expect(camera.getEngine()).toBe(engine); expect(camera.getCapabilities()?.getRawCapabilities()).toEqual({ - 'favorite-events': false, - 'favorite-recordings': false, - clips: false, + '2-way-audio': false, 'remote-control-entity': true, live: true, menu: true, - recordings: false, - seek: false, - snapshots: false, substream: true, trigger: true, }); diff --git a/tests/camera-manager/utils/go2rtc-endpoint.test.ts b/tests/camera-manager/utils/go2rtc-endpoint.test.ts index 62d913fb..cb512a62 100644 --- a/tests/camera-manager/utils/go2rtc-endpoint.test.ts +++ b/tests/camera-manager/utils/go2rtc-endpoint.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { getDefaultGo2RTCEndpoint } from '../../../src/camera-manager/utils/go2rtc-endpoint.js'; +import { + getGo2RTCMetadataEndpoint, + getGo2RTCStreamEndpoint, +} from '../../../src/camera-manager/utils/go2rtc/endpoint.js'; import { createCameraConfig } from '../../test-utils.js'; -describe('getDefaultGo2RTCEndpoint', () => { +describe('getGo2RTCStreamEndpoint', () => { it('with local configuration', () => { expect( - getDefaultGo2RTCEndpoint( + getGo2RTCStreamEndpoint( createCameraConfig({ go2rtc: { stream: 'stream', @@ -21,7 +24,7 @@ describe('getDefaultGo2RTCEndpoint', () => { it('with remote configuration', () => { expect( - getDefaultGo2RTCEndpoint( + getGo2RTCStreamEndpoint( createCameraConfig({ go2rtc: { stream: 'stream', @@ -36,6 +39,45 @@ describe('getDefaultGo2RTCEndpoint', () => { }); it('without configuration', () => { - expect(getDefaultGo2RTCEndpoint(createCameraConfig())).toBeNull(); + expect(getGo2RTCStreamEndpoint(createCameraConfig())).toBeNull(); + }); +}); + +describe('getGo2RTCMetadataEndpoint', () => { + it('with local configuration', () => { + expect( + getGo2RTCMetadataEndpoint( + createCameraConfig({ + go2rtc: { + stream: 'stream', + url: '/local/path', + }, + }), + ), + ).toEqual({ + endpoint: '/local/path/api/streams?src=stream&video=all&audio=allµphone', + sign: true, + }); + }); + + it('with remote configuration', () => { + expect( + getGo2RTCMetadataEndpoint( + createCameraConfig({ + go2rtc: { + stream: 'stream', + url: 'https://my-custom-go2rtc', + }, + }), + ), + ).toEqual({ + endpoint: + 'https://my-custom-go2rtc/api/streams?src=stream&video=all&audio=allµphone', + sign: false, + }); + }); + + it('without configuration', () => { + expect(getGo2RTCMetadataEndpoint(createCameraConfig())).toBeNull(); }); }); diff --git a/tests/components-lib/menu-button-controller.test.ts b/tests/components-lib/menu-button-controller.test.ts index 99810650..8c70a8b4 100644 --- a/tests/components-lib/menu-button-controller.test.ts +++ b/tests/components-lib/menu-button-controller.test.ts @@ -961,19 +961,23 @@ describe('MenuButtonController', () => { }); describe('should have microphone button', () => { - it('with suitable loaded media', () => { + it('when camera has 2-way-audio capability', () => { const microphoneManager = mock(); vi.mocked(microphoneManager.isForbidden).mockReturnValue(false); vi.mocked(microphoneManager.isMuted).mockReturnValue(false); vi.mocked(microphoneManager.isSupported).mockReturnValue(true); - const buttons = calculateButtons(controller, { - microphoneManager: microphoneManager, - currentMediaLoadedInfo: createMediaLoadedInfo({ - capabilities: { - supports2WayAudio: true, + const cameraManager = createCameraManager( + createStore([ + { + cameraID: 'camera-1', + capabilities: createCapabilities({ '2-way-audio': true }), }, - }), + ]), + ); + const buttons = calculateButtons(controller, { + cameraManager, + microphoneManager: microphoneManager, }); expect(buttons).toContainEqual({ @@ -997,19 +1001,18 @@ describe('MenuButtonController', () => { }); }); - it('without suitable loaded media', () => { + it('when camera does not have 2-way-audio capability', () => { const microphoneManager = mock(); vi.mocked(microphoneManager.isForbidden).mockReturnValue(false); vi.mocked(microphoneManager.isMuted).mockReturnValue(false); vi.mocked(microphoneManager.isSupported).mockReturnValue(true); + const cameraManager = createCameraManager( + createStore([{ cameraID: 'camera-1', capabilities: createCapabilities() }]), + ); const buttons = calculateButtons(controller, { + cameraManager, microphoneManager: microphoneManager, - currentMediaLoadedInfo: createMediaLoadedInfo({ - capabilities: { - supports2WayAudio: false, - }, - }), }); expect(buttons).not.toEqual( @@ -1021,13 +1024,17 @@ describe('MenuButtonController', () => { const microphoneManager = mock(); vi.mocked(microphoneManager.isForbidden).mockReturnValue(true); - const buttons = calculateButtons(controller, { - microphoneManager: microphoneManager, - currentMediaLoadedInfo: createMediaLoadedInfo({ - capabilities: { - supports2WayAudio: true, + const cameraManager = createCameraManager( + createStore([ + { + cameraID: 'camera-1', + capabilities: createCapabilities({ '2-way-audio': true }), }, - }), + ]), + ); + const buttons = calculateButtons(controller, { + cameraManager, + microphoneManager: microphoneManager, }); expect(buttons).toContainEqual({ @@ -1046,13 +1053,17 @@ describe('MenuButtonController', () => { vi.mocked(microphoneManager.isMuted).mockReturnValue(true); vi.mocked(microphoneManager.isSupported).mockReturnValue(true); - const buttons = calculateButtons(controller, { - microphoneManager: microphoneManager, - currentMediaLoadedInfo: createMediaLoadedInfo({ - capabilities: { - supports2WayAudio: true, + const cameraManager = createCameraManager( + createStore([ + { + cameraID: 'camera-1', + capabilities: createCapabilities({ '2-way-audio': true }), }, - }), + ]), + ); + const buttons = calculateButtons(controller, { + cameraManager, + microphoneManager: microphoneManager, }); expect(buttons).toContainEqual({ @@ -1079,13 +1090,17 @@ describe('MenuButtonController', () => { vi.mocked(microphoneManager.isMuted).mockReturnValue(true); vi.mocked(microphoneManager.isSupported).mockReturnValue(false); - const buttons = calculateButtons(controller, { - microphoneManager: microphoneManager, - currentMediaLoadedInfo: createMediaLoadedInfo({ - capabilities: { - supports2WayAudio: true, + const cameraManager = createCameraManager( + createStore([ + { + cameraID: 'camera-1', + capabilities: createCapabilities({ '2-way-audio': true }), }, - }), + ]), + ); + const buttons = calculateButtons(controller, { + cameraManager, + microphoneManager: microphoneManager, }); expect(buttons).toContainEqual({ @@ -1104,13 +1119,17 @@ describe('MenuButtonController', () => { vi.mocked(microphoneManager.isMuted).mockReturnValue(true); vi.mocked(microphoneManager.isSupported).mockReturnValue(true); - const buttons = calculateButtons(controller, { - microphoneManager: microphoneManager, - currentMediaLoadedInfo: createMediaLoadedInfo({ - capabilities: { - supports2WayAudio: true, + const cameraManager = createCameraManager( + createStore([ + { + cameraID: 'camera-1', + capabilities: createCapabilities({ '2-way-audio': true }), }, - }), + ]), + ); + const buttons = calculateButtons(controller, { + cameraManager, + microphoneManager: microphoneManager, config: createConfig({ menu: { buttons: { microphone: { type: 'toggle' } } }, }), @@ -1136,13 +1155,17 @@ describe('MenuButtonController', () => { vi.mocked(microphoneManager.isMuted).mockReturnValue(false); vi.mocked(microphoneManager.isSupported).mockReturnValue(true); - const buttons = calculateButtons(controller, { - microphoneManager: microphoneManager, - currentMediaLoadedInfo: createMediaLoadedInfo({ - capabilities: { - supports2WayAudio: true, + const cameraManager = createCameraManager( + createStore([ + { + cameraID: 'camera-1', + capabilities: createCapabilities({ '2-way-audio': true }), }, - }), + ]), + ); + const buttons = calculateButtons(controller, { + cameraManager, + microphoneManager: microphoneManager, config: createConfig({ menu: { buttons: { microphone: { type: 'toggle' } } }, }), diff --git a/tests/ha/fetch.test.ts b/tests/ha/fetch.test.ts new file mode 100644 index 00000000..3748b813 --- /dev/null +++ b/tests/ha/fetch.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { homeAssistantSignAndFetch } from '../../src/ha/fetch'; +import { homeAssistantSignPath } from '../../src/ha/sign-path'; +import { AdvancedCameraCardError, Endpoint } from '../../src/types'; +import { createHASS } from '../test-utils'; + +vi.mock('../../src/ha/sign-path'); + +describe('homeAssistantSignAndFetch', () => { + const response = { + val: 10, + }; + const schema = z.object({ + val: z.number(), + }); + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock); + vi.mocked(homeAssistantSignPath).mockResolvedValue('http://signed'); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('should return parsed data on successful call with endpoint', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => response, + }); + + const endpoint: Endpoint = { endpoint: 'http://example.com' }; + expect(await homeAssistantSignAndFetch(createHASS(), endpoint, schema)).toEqual( + response, + ); + expect(homeAssistantSignPath).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledWith('http://example.com', {}); + }); + + it('should pass timeout signal when timeoutSeconds is provided', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => response, + }); + + const endpoint: Endpoint = { endpoint: 'http://example.com' }; + expect( + await homeAssistantSignAndFetch(createHASS(), endpoint, schema, { + timeoutSeconds: 5, + }), + ).toEqual(response); + expect(fetchMock).toHaveBeenCalledWith('http://example.com', { + signal: expect.any(AbortSignal), + }); + }); + + it('should sign path if requested', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => response, + }); + + const endpoint: Endpoint = { + endpoint: 'http://example.com', + sign: true, + }; + const hass = createHASS(); + expect(await homeAssistantSignAndFetch(hass, endpoint, schema)).toEqual(response); + expect(homeAssistantSignPath).toHaveBeenCalledWith(hass, 'http://example.com'); + expect(fetchMock).toHaveBeenCalledWith('http://signed', {}); + }); + + it('should throw on sign failure', async () => { + vi.mocked(homeAssistantSignPath).mockRejectedValueOnce(new Error('Sign failed')); + + const endpoint: Endpoint = { + endpoint: 'http://example.com', + sign: true, + }; + await expect( + homeAssistantSignAndFetch(createHASS(), endpoint, schema), + ).rejects.toThrow(/Could not sign Home Assistant URL/); + }); + + it('should throw if sign path returns null', async () => { + vi.mocked(homeAssistantSignPath).mockResolvedValue(null); + + const endpoint: Endpoint = { + endpoint: 'http://example.com', + sign: true, + }; + await expect( + homeAssistantSignAndFetch(createHASS(), endpoint, schema), + ).rejects.toThrow(/Could not sign Home Assistant URL/); + }); + + it('should throw on fetch failure', async () => { + fetchMock.mockRejectedValueOnce(new Error('Fetch failed')); + + const endpoint: Endpoint = { endpoint: 'http://example.com' }; + try { + await homeAssistantSignAndFetch(createHASS(), endpoint, schema); + expect.fail('Should have thrown'); + } catch (e) { + const error = e as AdvancedCameraCardError; + expect(error.message).toMatch(/Could not fetch URL/); + expect(error.context).toEqual({ + endpoint, + error: expect.any(Error), + }); + const context = error.context as { error: Error }; + expect(context.error.message).toBe('Fetch failed'); + } + }); + + it('should throw on non-ok response', async () => { + const response = { + ok: false, + status: 404, + statusText: 'Not Found', + } as Response; + fetchMock.mockResolvedValueOnce(response); + + const endpoint: Endpoint = { endpoint: 'http://example.com' }; + try { + await homeAssistantSignAndFetch(createHASS(), endpoint, schema); + expect.fail('Should have thrown'); + } catch (e) { + expect((e as AdvancedCameraCardError).message).toMatch( + /Failed to receive response/, + ); + expect((e as AdvancedCameraCardError).context).toEqual({ + endpoint, + response, + }); + } + }); + + it('should throw on JSON parse failure', async () => { + const response = { + ok: true, + json: async () => { + throw new Error('JSON error'); + }, + } as unknown as Response; + fetchMock.mockResolvedValueOnce(response); + + const endpoint: Endpoint = { endpoint: 'http://example.com' }; + try { + await homeAssistantSignAndFetch(createHASS(), endpoint, schema); + expect.fail('Should have thrown'); + } catch (e) { + const error = e as AdvancedCameraCardError; + expect(error.message).toMatch(/Received invalid response/); + expect(error.context).toEqual({ + endpoint, + response, + error: expect.any(Error), + }); + const context = error.context as { error: Error }; + expect(context.error.message).toBe('JSON error'); + } + }); + + it('should throw on schema validation failure', async () => { + const data = { val: 'string' }; + const response = { + ok: true, + json: async () => data, + } as unknown as Response; + fetchMock.mockResolvedValueOnce(response); + + const endpoint: Endpoint = { endpoint: 'http://example.com' }; + + try { + await homeAssistantSignAndFetch(createHASS(), endpoint, schema); + expect.fail('Should have thrown'); + } catch (e) { + expect((e as AdvancedCameraCardError).message).toMatch( + /Received invalid response/, + ); + expect((e as AdvancedCameraCardError).context).toMatchObject({ + endpoint, + data, + error: expect.any(z.ZodError), + }); + } + }); +}); diff --git a/tests/ha/web-proxy.test.ts b/tests/ha/web-proxy.test.ts index 46c4cb64..1e1c50cb 100644 --- a/tests/ha/web-proxy.test.ts +++ b/tests/ha/web-proxy.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { CameraProxyConfig } from '../../src/camera-manager/types.js'; import { addDynamicProxyURL, + createProxiedEndpointIfNecessary, getWebProxiedURL, shouldUseWebProxy, } from '../../src/ha/web-proxy.js'; @@ -115,3 +116,161 @@ describe('addDynamicProxyURL', () => { ); }); }); + +describe('createProxiedEndpointIfNecessary', () => { + const createProxyConfig = ( + config: Partial = {}, + ): CameraProxyConfig => ({ + media: true, + live: true, + ssl_verification: true, + ssl_ciphers: 'default', + dynamic: true, + ...config, + }); + + const testEndpoint = { endpoint: 'http://example.com/stream', sign: false }; + + it('should return original endpoint when proxyConfig is undefined', async () => { + const hass = createHASS(); + hass.config.components = ['hass_web_proxy']; + + const result = await createProxiedEndpointIfNecessary(hass, testEndpoint); + expect(result).toBe(testEndpoint); + }); + + it('should return original endpoint when proxy is not available', async () => { + const hass = createHASS(); + hass.config.components = []; + + const result = await createProxiedEndpointIfNecessary( + hass, + testEndpoint, + createProxyConfig(), + ); + expect(result).toBe(testEndpoint); + }); + + it('should return original endpoint when context is not enabled', async () => { + const hass = createHASS(); + hass.config.components = ['hass_web_proxy']; + + const result = await createProxiedEndpointIfNecessary( + hass, + testEndpoint, + createProxyConfig({ media: false }), + { context: 'media' }, + ); + expect(result).toBe(testEndpoint); + }); + + it('should return proxied endpoint with dynamic registration', async () => { + const hass = createHASS(); + hass.config.components = ['hass_web_proxy']; + + const result = await createProxiedEndpointIfNecessary( + hass, + testEndpoint, + createProxyConfig(), + { context: 'media', ttl: 300, openLimit: 5 }, + ); + + expect(hass.callService).toHaveBeenCalledWith( + 'hass_web_proxy', + 'create_proxied_url', + expect.objectContaining({ + url_pattern: 'http://example.com/stream', + ttl: 300, + open_limit: 5, + }), + ); + + expect(result).toEqual({ + endpoint: '/api/hass_web_proxy/v0/?url=http%3A%2F%2Fexample.com%2Fstream', + sign: true, + }); + }); + + it('should strip hash fragment when registering dynamic proxy', async () => { + const hass = createHASS(); + hass.config.components = ['hass_web_proxy']; + + const endpointWithHash = { + endpoint: 'http://example.com/stream#fragment', + sign: false, + }; + + await createProxiedEndpointIfNecessary(hass, endpointWithHash, createProxyConfig()); + + expect(hass.callService).toHaveBeenCalledWith( + 'hass_web_proxy', + 'create_proxied_url', + expect.objectContaining({ + url_pattern: 'http://example.com/stream', + }), + ); + }); + + it('should return proxied endpoint without dynamic registration', async () => { + const hass = createHASS(); + hass.config.components = ['hass_web_proxy']; + + const result = await createProxiedEndpointIfNecessary( + hass, + testEndpoint, + createProxyConfig({ dynamic: false }), + ); + + expect(hass.callService).not.toHaveBeenCalled(); + expect(result).toEqual({ + endpoint: '/api/hass_web_proxy/v0/?url=http%3A%2F%2Fexample.com%2Fstream', + sign: true, + }); + }); + + it('should return websocket proxied endpoint', async () => { + const hass = createHASS(); + hass.config.components = ['hass_web_proxy']; + + const result = await createProxiedEndpointIfNecessary( + hass, + testEndpoint, + createProxyConfig({ dynamic: false }), + { websocket: true }, + ); + + expect(result).toEqual({ + endpoint: '/api/hass_web_proxy/v0/ws?url=http%3A%2F%2Fexample.com%2Fstream', + sign: true, + }); + }); + + it('should use live context when specified', async () => { + const hass = createHASS(); + hass.config.components = ['hass_web_proxy']; + + const result = await createProxiedEndpointIfNecessary( + hass, + testEndpoint, + createProxyConfig({ media: false, live: true }), + { context: 'live' }, + ); + + expect(result.endpoint).toContain('/api/hass_web_proxy/'); + }); + + it('should default openLimit to 0 when not specified', async () => { + const hass = createHASS(); + hass.config.components = ['hass_web_proxy']; + + await createProxiedEndpointIfNecessary(hass, testEndpoint, createProxyConfig()); + + expect(hass.callService).toHaveBeenCalledWith( + 'hass_web_proxy', + 'create_proxied_url', + expect.objectContaining({ + open_limit: 0, + }), + ); + }); +}); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 5e16b4ce..91228d4b 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -88,12 +88,18 @@ export const createConfig = ( return advancedCameraCardConfigSchema.parse(createRawConfig(config)); }; -export const createCamera = ( +export const createInitializedCamera = async ( config: CameraConfig, engine: CameraManagerEngine, capabilities?: Capabilities, -): Camera => { - return new Camera(config, engine, { capabilities: capabilities }); +): Promise => { + const camera = new Camera(config, engine); + await camera.initialize({ + hass: createHASS(), + stateWatcher: mock(), + ...(capabilities ? { capabilityOptions: { capabilities } } : {}), + }); + return camera; }; export const createHASS = (states?: HassEntities, user?: CurrentUser): HomeAssistant => { @@ -215,6 +221,10 @@ export const createStore = ( const store = new CameraManagerStore(); for (const cameraProps of cameras ?? []) { const eventCallback = cameraProps.eventCallback ?? vi.fn(); + const capabilities = + cameraProps.capabilities === undefined + ? createCapabilities() + : cameraProps.capabilities ?? undefined; const camera = new Camera( cameraProps.config ?? createCameraConfig(), cameraProps.engine ?? @@ -222,13 +232,7 @@ export const createStore = ( mock(), eventCallback, ), - { - capabilities: - cameraProps.capabilities === undefined - ? createCapabilities() - : cameraProps.capabilities ?? undefined, - eventCallback: eventCallback, - }, + { eventCallback, capabilities }, ); camera.setID(cameraProps.cameraID); store.addCamera(camera); diff --git a/tests/utils/audio.test.ts b/tests/utils/audio.test.ts index 54ab1482..0739d1e8 100644 --- a/tests/utils/audio.test.ts +++ b/tests/utils/audio.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; -import { AudioProperties, mayHaveAudio } from '../../src/utils/audio'; +import { + addAudioTracksMuteStateListener, + AudioProperties, + has2WayAudio, + hasAudio, + mayHaveAudio, +} from '../../src/utils/audio'; // @vitest-environment jsdom describe('mayHaveAudio', () => { @@ -9,9 +15,9 @@ describe('mayHaveAudio', () => { expect(mayHaveAudio(element)).toBeTruthy(); }); - it('should not detect audio when mozHasAudio undefined', () => { + it('should not detect audio when mozHasAudio false', () => { const element: HTMLVideoElement & AudioProperties = document.createElement('video'); - element.mozHasAudio = undefined; + element.mozHasAudio = false; expect(mayHaveAudio(element)).toBeFalsy(); }); @@ -64,3 +70,264 @@ describe('mayHaveAudio', () => { expect(mayHaveAudio(element)).toBeTruthy(); }); }); + +describe('hasAudio', () => { + const createMockVideo = (): HTMLVideoElement & AudioProperties => { + return {} as HTMLVideoElement & AudioProperties; + }; + + const createMockReceiver = (trackKind: string, muted = false): RTCRtpReceiver => { + return { + track: { kind: trackKind, muted }, + } as unknown as RTCRtpReceiver; + }; + + const createMockPeerConnection = (receivers: RTCRtpReceiver[]): RTCPeerConnection => { + return { + getReceivers: () => receivers, + } as unknown as RTCPeerConnection; + }; + + describe('WebRTC receiver detection', () => { + it('should detect audio when there is an unmuted audio receiver', () => { + const pc = createMockPeerConnection([ + createMockReceiver('video'), + createMockReceiver('audio', false), + ]); + expect(hasAudio(createMockVideo(), pc, '')).toBe(true); + }); + + it('should not detect audio when audio receiver is muted', () => { + const pc = createMockPeerConnection([ + createMockReceiver('video'), + createMockReceiver('audio', true), + ]); + expect(hasAudio(createMockVideo(), pc, '')).toBe(false); + }); + + it('should not detect audio when only video receivers exist', () => { + const pc = createMockPeerConnection([createMockReceiver('video')]); + expect(hasAudio(createMockVideo(), pc, '')).toBe(false); + }); + + it('should fall back to mayHaveAudio when no receivers yet', () => { + const pc = createMockPeerConnection([]); + // Empty receivers means connection not established, falls back to mayHaveAudio + // With no properties set on video, mayHaveAudio returns true (generous default) + expect(hasAudio(createMockVideo(), pc, '')).toBe(true); + }); + }); + + describe('MSE codec detection', () => { + it('should detect audio when mseCodecs contains mp4a', () => { + expect(hasAudio(createMockVideo(), null, 'avc1.640029,mp4a.40.2')).toBe(true); + }); + + it('should detect audio when mseCodecs contains opus', () => { + expect(hasAudio(createMockVideo(), null, 'avc1.640029,opus')).toBe(true); + }); + + it('should detect audio when mseCodecs contains flac', () => { + expect(hasAudio(createMockVideo(), null, 'avc1.640029,flac')).toBe(true); + }); + + it('should not detect audio when mseCodecs contains only video codecs', () => { + expect(hasAudio(createMockVideo(), null, 'avc1.640029,hvc1.1.6.L153.B0')).toBe( + false, + ); + }); + }); + + describe('fallback to mayHaveAudio', () => { + it('should fall back to mayHaveAudio when no SDP or mseCodecs', () => { + // With no properties set, mayHaveAudio returns true (generous default) + expect(hasAudio(createMockVideo(), null, '')).toBe(true); + }); + + it('should use mayHaveAudio when mozHasAudio is false', () => { + const video = createMockVideo(); + video.mozHasAudio = false; + expect(hasAudio(video, null, '')).toBe(false); + }); + }); +}); + +describe('has2WayAudio', () => { + const createMockTransceiver = ( + trackKind: string | null, + direction: RTCRtpTransceiverDirection, + ): RTCRtpTransceiver => { + return { + sender: { + track: trackKind ? { kind: trackKind } : null, + }, + direction, + } as unknown as RTCRtpTransceiver; + }; + + const createMockPeerConnection = ( + transceivers: RTCRtpTransceiver[], + ): RTCPeerConnection => { + return { + getTransceivers: () => transceivers, + } as unknown as RTCPeerConnection; + }; + + it('should return false for null peer connection', () => { + expect(has2WayAudio(null)).toBe(false); + }); + + it('should return false when no transceivers', () => { + const pc = createMockPeerConnection([]); + expect(has2WayAudio(pc)).toBe(false); + }); + + it('should return true when audio transceiver is sendonly', () => { + const pc = createMockPeerConnection([createMockTransceiver('audio', 'sendonly')]); + expect(has2WayAudio(pc)).toBe(true); + }); + + it('should return true when audio transceiver is sendrecv', () => { + const pc = createMockPeerConnection([createMockTransceiver('audio', 'sendrecv')]); + expect(has2WayAudio(pc)).toBe(true); + }); + + it('should return false when audio transceiver is recvonly', () => { + const pc = createMockPeerConnection([createMockTransceiver('audio', 'recvonly')]); + expect(has2WayAudio(pc)).toBe(false); + }); + + it('should return false when audio transceiver is inactive', () => { + const pc = createMockPeerConnection([createMockTransceiver('audio', 'inactive')]); + expect(has2WayAudio(pc)).toBe(false); + }); + + it('should return false when only video transceiver with sendonly', () => { + const pc = createMockPeerConnection([createMockTransceiver('video', 'sendonly')]); + expect(has2WayAudio(pc)).toBe(false); + }); + + it('should return false when transceiver has no track', () => { + const pc = createMockPeerConnection([createMockTransceiver(null, 'sendonly')]); + expect(has2WayAudio(pc)).toBe(false); + }); + + it('should return true when mixed transceivers include sendonly audio', () => { + const pc = createMockPeerConnection([ + createMockTransceiver('video', 'recvonly'), + createMockTransceiver('audio', 'recvonly'), + createMockTransceiver('audio', 'sendonly'), + ]); + expect(has2WayAudio(pc)).toBe(true); + }); +}); + +describe('addAudioTracksMuteStateListener', () => { + interface MockTrack { + kind: string; + muted: boolean; + listeners: Map void>>; + addEventListener: (event: string, handler: () => void) => void; + removeEventListener: (event: string, handler: () => void) => void; + triggerEvent: (event: string) => void; + } + + const createMockTrack = (kind: string, muted: boolean): MockTrack => { + const listeners = new Map void>>(); + return { + kind, + muted, + listeners, + addEventListener: (event, handler) => { + if (!listeners.has(event)) { + listeners.set(event, new Set()); + } + listeners.get(event)?.add(handler); + }, + removeEventListener: (event, handler) => { + listeners.get(event)?.delete(handler); + }, + triggerEvent: (event) => { + listeners.get(event)?.forEach((h) => h()); + }, + }; + }; + + const createMockPeerConnection = (tracks: MockTrack[]): RTCPeerConnection => { + return { + getReceivers: () => tracks.map((t) => ({ track: t })), + } as unknown as RTCPeerConnection; + }; + + it('should return null for null peer connection', () => { + const callback = vi.fn(); + expect(addAudioTracksMuteStateListener(null, callback)).toBe(null); + expect(callback).not.toHaveBeenCalled(); + }); + + it('should return null when no audio tracks', () => { + const callback = vi.fn(); + const pc = createMockPeerConnection([createMockTrack('video', false)]); + expect(addAudioTracksMuteStateListener(pc, callback)).toBe(null); + expect(callback).not.toHaveBeenCalled(); + }); + + it('should call callback with true when any track unmutes', () => { + const callback = vi.fn(); + const track1 = createMockTrack('audio', true); + const track2 = createMockTrack('audio', true); + const pc = createMockPeerConnection([track1, track2]); + + addAudioTracksMuteStateListener(pc, callback); + + // Unmute first track - now has audio + track1.muted = false; + track1.triggerEvent('unmute'); + expect(callback).toHaveBeenCalledWith(true); + }); + + it('should call callback with false when all tracks become muted', () => { + const callback = vi.fn(); + const track1 = createMockTrack('audio', false); + const track2 = createMockTrack('audio', false); + const pc = createMockPeerConnection([track1, track2]); + + addAudioTracksMuteStateListener(pc, callback); + + // Mute first track - still have an unmuted track, no callback yet + track1.muted = true; + track1.triggerEvent('mute'); + expect(callback).not.toHaveBeenCalled(); + + // Mute second track - all muted now + track2.muted = true; + track2.triggerEvent('mute'); + expect(callback).toHaveBeenCalledWith(false); + }); + + it('should not call callback if state has not changed', () => { + const callback = vi.fn(); + const track = createMockTrack('audio', true); + const pc = createMockPeerConnection([track]); + + addAudioTracksMuteStateListener(pc, callback); + + // Trigger unmute but don't actually change muted state + track.triggerEvent('unmute'); + expect(callback).not.toHaveBeenCalled(); + }); + + it('should remove listeners on cleanup', () => { + const callback = vi.fn(); + const track = createMockTrack('audio', true); + const pc = createMockPeerConnection([track]); + + const cleanup = addAudioTracksMuteStateListener(pc, callback); + cleanup?.(); + + // Change state and trigger - should not call callback + track.muted = false; + track.triggerEvent('unmute'); + expect(callback).not.toHaveBeenCalled(); + }); +});