From a15376602f99da30043df7b1a19043b9489d5cf4 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 17 Feb 2026 03:47:56 +0000 Subject: [PATCH] feat: Allow 2-way audio detection to be skipped and timed (#2355) - For #2313 --- docs/configuration/cameras/README.md | 9 +- docs/configuration/cameras/live-provider.md | 19 +- docs/usage/2-way-audio.md | 7 + src/camera-manager/camera.ts | 32 +++- src/camera-manager/utils/go2rtc/audio.ts | 15 +- src/config/schema/cameras.ts | 13 +- src/const.ts | 2 + src/editor.ts | 19 ++ src/localize/languages/en.json | 2 + src/utils/live-provider.ts | 8 +- tests/camera-manager/camera.test.ts | 170 ++++++++++++++++++ .../camera-manager/utils/go2rtc-audio.test.ts | 42 ++++- tests/config/types.test.ts | 3 + tests/utils/live-provider.test.ts | 30 +++- 14 files changed, 337 insertions(+), 34 deletions(-) diff --git a/docs/configuration/cameras/README.md b/docs/configuration/cameras/README.md index b7d1e85a..bef91149 100644 --- a/docs/configuration/cameras/README.md +++ b/docs/configuration/cameras/README.md @@ -46,10 +46,11 @@ cameras: # [...] ``` -| Option | Default | Description | -| ---------------- | ------- | ---------------------------------------------------------------------------------------------------------- | -| `disable` | | A list of camera capabilities to disable. By default all capabilities supported by the camera are enabled. | -| `disable_except` | | A list of camera capabilities to leave enabled if supported. Everything else will be disabled. | +| Option | Default | Description | +| ---------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `disable` | | A list of camera capabilities to disable. By default all capabilities supported by the camera are enabled. | +| `disable_except` | | A list of camera capabilities to leave enabled if supported. Everything else will be disabled. | +| `force` | | A list of capabilities to force-enable instead of auto-detecting them. Currently only supports `2-way-audio`. `disable` / `disable_except` take precedence over `force`. | ### Capabilities diff --git a/docs/configuration/cameras/live-provider.md b/docs/configuration/cameras/live-provider.md index 598c431a..3d5aa1aa 100644 --- a/docs/configuration/cameras/live-provider.md +++ b/docs/configuration/cameras/live-provider.md @@ -26,15 +26,23 @@ cameras: # [...] ``` -| Option | Default | Description | -| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `modes` | `[webrtc, mse, mp4, mjpeg]` | An ordered list of `go2rtc` modes to use. Valid values are `webrtc`, `mse`, `mp4` or `mjpeg` values. | -| `stream` | Determined by camera engine (e.g. `frigate` camera name). | A valid `go2rtc` stream name. | -| `url` | Determined by camera engine (e.g. the `frigate` engine will automatically generate a URL for the go2rtc backend that runs in the Frigate container). | The root `go2rtc` URL the card should stream the video from. This is only needed for non-Frigate usecases, or advanced Frigate usecases. Example: `http://my-custom-go2rtc:1984` | +| Option | Default | Description | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `metadata_fetch_timeout_seconds` | `2` | Timeout for the go2rtc stream metadata fetch (this is used to detect stream capabilities such as `2-way-audio`). | +| `modes` | `[webrtc, mse, mp4, mjpeg]` | An ordered list of `go2rtc` modes to use. Valid values are `webrtc`, `mse`, `mp4` or `mjpeg` values. | +| `stream` | Determined by camera engine (e.g. `frigate` camera name). | A valid `go2rtc` stream name. | +| `url` | Determined by camera engine (e.g. the `frigate` engine will automatically generate a URL for the go2rtc backend that runs in the Frigate container). | The root `go2rtc` URL the card should stream the video from. This is only needed for non-Frigate usecases, or advanced Frigate usecases. Example: `http://my-custom-go2rtc:1984` | > [!NOTE] > If `url` is manually set and `proxy.live` is set to `auto` on the camera (the default), the video stream will automatically be proxied via the Home Assistant process if the [hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration) is detected. See [proxying](./README.md?id=proxy). +> [!TIP] +> If you are certain your camera hardware supports 2-way audio but the +> microphone button is intermittently missing on load, try increasing +> `metadata_fetch_timeout_seconds` or use +> [`capabilities.force`](./README.md?id=capabilities) to skip metadata +> detection entirely. + ## `image` All configuration is under: @@ -119,6 +127,7 @@ cameras: - mjpeg stream: sitting_room url: 'https://my.custom.go2rtc.backend' + metadata_fetch_timeout_seconds: 2 - camera_entity: camera.office_jsmpeg live_provider: jsmpeg jsmpeg: diff --git a/docs/usage/2-way-audio.md b/docs/usage/2-way-audio.md index ed26cd3c..425fa35b 100644 --- a/docs/usage/2-way-audio.md +++ b/docs/usage/2-way-audio.md @@ -20,6 +20,11 @@ challenging. - Only the `webrtc` mode supports 2-way audio: - Must have microphone menu button enabled: +If your setup supports 2-way audio but detection is intermittent on load: + +- Increase `cameras[].go2rtc.metadata_fetch_timeout_seconds`. +- Or force the capability with `cameras[].capabilities.force: ['2-way-audio']`. + ## Example configuration ```yaml @@ -30,6 +35,8 @@ cameras: go2rtc: modes: - webrtc + # Optional: For slower cameras increase timeout (default: 2) + metadata_fetch_timeout_seconds: 10 menu: buttons: microphone: diff --git a/src/camera-manager/camera.ts b/src/camera-manager/camera.ts index 41d3294e..93c61855 100644 --- a/src/camera-manager/camera.ts +++ b/src/camera-manager/camera.ts @@ -85,15 +85,13 @@ export class Camera { ): Promise { const rawCapabilities = await this._getRawCapabilities(options); const config = this.getConfig(); - const has2WayAudio = await liveProviderSupports2WayAudio( - options.hass, - config, - this._getGo2RTCMetadataEndpoint(), - this.getProxyConfig(), - ); + const has2WayAudio = await this._has2WayAudioCapability(options.hass); return new Capabilities( - { ...rawCapabilities, '2-way-audio': has2WayAudio }, + { + ...rawCapabilities, + '2-way-audio': has2WayAudio, + }, { disable: config.capabilities?.disable, disableExcept: config.capabilities?.disable_except, @@ -101,6 +99,26 @@ export class Camera { ); } + protected async _has2WayAudioCapability(hass: HomeAssistant): Promise { + if (this._config.capabilities?.disable?.includes('2-way-audio')) { + return false; + } + const disableExcept = this._config.capabilities?.disable_except; + if (disableExcept?.length && !disableExcept.includes('2-way-audio')) { + return false; + } + if (this._config.capabilities?.force?.includes('2-way-audio')) { + return true; + } + return await liveProviderSupports2WayAudio( + hass, + this.getConfig(), + this.getConfig().go2rtc.metadata_fetch_timeout_seconds, + this._getGo2RTCMetadataEndpoint(), + this.getProxyConfig(), + ); + } + /** * Get raw capabilities for this camera. Subclasses should override * and call super._getRawCapabilities() to extend defaults. diff --git a/src/camera-manager/utils/go2rtc/audio.ts b/src/camera-manager/utils/go2rtc/audio.ts index 7c45ca66..2878ac4b 100644 --- a/src/camera-manager/utils/go2rtc/audio.ts +++ b/src/camera-manager/utils/go2rtc/audio.ts @@ -6,18 +6,14 @@ import { errorToConsole } from '../../../utils/basic'; import { CameraProxyConfig } from '../../types'; import { Go2RTCStreamInfo, go2RTCStreamInfoSchema } from './types'; -// Allow generous amount of time to fetch metadata (timeout matches that used in -// the Frigate frontend for the same call). -// See: https://github.com/dermotduffy/advanced-camera-card/issues/2313 -const GO2RTC_METADATA_TIMEOUT_SECONDS = 10; - const getGo2RTCStreamMetadata = async ( hass: HomeAssistant, endpoint: Endpoint, + timeoutSeconds: number, ): Promise => { try { return await homeAssistantSignAndFetch(hass, endpoint, go2RTCStreamInfoSchema, { - timeoutSeconds: GO2RTC_METADATA_TIMEOUT_SECONDS, + timeoutSeconds, }); } catch (e) { errorToConsole(e as Error); @@ -53,6 +49,7 @@ const streamSupports2WayAudio = (streamInfo: Go2RTCStreamInfo | null): boolean = */ export const supports2WayAudio = async ( hass: HomeAssistant, + metadataFetchTimeoutSeconds: number, go2rtcMetadataEndpoint?: Endpoint | null, proxyConfig?: CameraProxyConfig, ): Promise => { @@ -67,6 +64,10 @@ export const supports2WayAudio = async ( { context: 'live', openLimit: 1 }, ); - const streamInfo = await getGo2RTCStreamMetadata(hass, endpoint); + const streamInfo = await getGo2RTCStreamMetadata( + hass, + endpoint, + metadataFetchTimeoutSeconds, + ); return streamSupports2WayAudio(streamInfo); }; diff --git a/src/config/schema/cameras.ts b/src/config/schema/cameras.ts index c0d8fd61..0714640d 100644 --- a/src/config/schema/cameras.ts +++ b/src/config/schema/cameras.ts @@ -31,6 +31,11 @@ const LIVE_PROVIDERS = [ ] as const; export type LiveProvider = (typeof LIVE_PROVIDERS)[number]; +const go2rtcConfigDefault = { + // See: https://github.com/dermotduffy/advanced-camera-card/issues/2313 + metadata_fetch_timeout_seconds: 2, +}; + const go2rtcConfigSchema = z.object({ url: z .string() @@ -39,6 +44,11 @@ const go2rtcConfigSchema = z.object({ host: z.string().optional(), modes: z.enum(['webrtc', 'mse', 'mp4', 'mjpeg']).array().optional(), stream: z.string().optional(), + metadata_fetch_timeout_seconds: z + .number() + .int() + .positive() + .default(go2rtcConfigDefault.metadata_fetch_timeout_seconds), }); const webrtcCardConfigSchema = z @@ -219,6 +229,7 @@ export const cameraConfigSchema = z .object({ disable: z.enum(capabilityKeys).array().optional(), disable_except: z.enum(capabilityKeys).array().optional(), + force: z.enum(['2-way-audio']).array().optional(), }) .optional(), @@ -306,7 +317,7 @@ export const cameraConfigSchema = z // Live provider options. live_provider: z.enum(LIVE_PROVIDERS).default(cameraConfigDefault.live_provider), - go2rtc: go2rtcConfigSchema.optional(), + go2rtc: go2rtcConfigSchema.optional().default(go2rtcConfigDefault), image: imageBaseConfigSchema.optional().default(imageConfigDefault), jsmpeg: jsmpegConfigSchema.optional(), webrtc_card: webrtcCardConfigSchema.optional(), diff --git a/src/const.ts b/src/const.ts index 8c709131..c75cacf4 100644 --- a/src/const.ts +++ b/src/const.ts @@ -20,6 +20,8 @@ export const CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE = `${CONF_CAMERAS}.#.capabilities.disable` as const; export const CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE_EXCEPT = `${CONF_CAMERAS}.#.capabilities.disable_except` as const; +export const CONF_CAMERAS_ARRAY_CAPABILITIES_FORCE = + `${CONF_CAMERAS}.#.capabilities.force` as const; export const CONF_CAMERAS_ARRAY_CAST_METHOD = `${CONF_CAMERAS}.#.cast.method` as const; export const CONF_CAMERAS_ARRAY_CAST_DASHBOARD_DASHBOARD_PATH = `${CONF_CAMERAS}.#.cast.dashboard.dashboard_path` as const; diff --git a/src/editor.ts b/src/editor.ts index bfc1e05d..e71a4618 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -38,6 +38,7 @@ import { CONF_CAMERAS_ARRAY_CAMERA_ENTITY, CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE, CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE_EXCEPT, + CONF_CAMERAS_ARRAY_CAPABILITIES_FORCE, CONF_CAMERAS_ARRAY_CAST_DASHBOARD_DASHBOARD_PATH, CONF_CAMERAS_ARRAY_CAST_DASHBOARD_VIEW_PATH, CONF_CAMERAS_ARRAY_CAST_METHOD, @@ -946,6 +947,14 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard }, ]; + protected _forceableCapabilities: EditorSelectOption[] = [ + { value: '', label: '' }, + { + value: '2-way-audio', + label: localize('config.cameras.capabilities.capabilities.2-way-audio'), + }, + ]; + protected _defaultResetInteractionModes: EditorSelectOption[] = [ { value: '', label: '' }, { @@ -2703,6 +2712,16 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard multiple: true, }, )} + ${this._renderOptionSelector( + getArrayConfigPath( + CONF_CAMERAS_ARRAY_CAPABILITIES_FORCE, + cameraIndex, + ), + this._forceableCapabilities, + { + multiple: true, + }, + )} `, )} ${this._putInSubmenu( diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index ca038547..3722a395 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -30,6 +30,7 @@ "camera_entity": "Camera Entity", "capabilities": { "capabilities": { + "2-way-audio": "2-way Audio", "clips": "Clips", "favorite-events": "Favorite Events", "favorite-recordings": "Favorite Recordings", @@ -43,6 +44,7 @@ }, "disable": "Disable", "disable_except": "Disable except", + "force": "Force", "editor_label": "Camera capabilities" }, "cast": { diff --git a/src/utils/live-provider.ts b/src/utils/live-provider.ts index 483c82eb..fe41d330 100644 --- a/src/utils/live-provider.ts +++ b/src/utils/live-provider.ts @@ -25,6 +25,7 @@ export const getResolvedLiveProvider = ( export const liveProviderSupports2WayAudio = async ( hass: HomeAssistant, config: CameraConfig, + metadataFetchTimeoutSeconds: number, go2rtcMetadataEndpoint?: Endpoint | null, proxyConfig?: CameraProxyConfig, ): Promise => { @@ -32,5 +33,10 @@ export const liveProviderSupports2WayAudio = async ( return false; } - return gortcSupports2WayAudio(hass, go2rtcMetadataEndpoint, proxyConfig); + return gortcSupports2WayAudio( + hass, + metadataFetchTimeoutSeconds, + go2rtcMetadataEndpoint, + proxyConfig, + ); }; diff --git a/tests/camera-manager/camera.test.ts b/tests/camera-manager/camera.test.ts index 0c397e42..25a6bb8b 100644 --- a/tests/camera-manager/camera.test.ts +++ b/tests/camera-manager/camera.test.ts @@ -76,6 +76,10 @@ describe('Camera', () => { }); describe('initialize', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should initialize and destroy', async () => { const camera = new Camera( createCameraConfig({ @@ -123,6 +127,7 @@ describe('Camera', () => { expect(liveProviderSupports2WayAudio).toHaveBeenCalledWith( expect.anything(), expect.anything(), + 2, { endpoint: 'http://go2rtc/api/streams?src=stream&video=all&audio=allµphone', @@ -154,6 +159,171 @@ describe('Camera', () => { expect(camera.getCapabilities()?.has('2-way-audio')).toBe(false); }); + + it('should pass camera go2rtc metadata timeout', async () => { + const camera = new Camera( + createCameraConfig({ + go2rtc: { + url: 'http://go2rtc', + stream: 'stream', + metadata_fetch_timeout_seconds: 20, + }, + }), + new GenericCameraManagerEngine(mock()), + ); + + vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true); + + await camera.initialize({ + hass: createHASS(), + stateWatcher: mock(), + }); + + expect(liveProviderSupports2WayAudio).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 20, + expect.anything(), + expect.anything(), + ); + }); + + it('should force 2-way-audio capability true without metadata fetch', async () => { + const camera = new Camera( + createCameraConfig({ + capabilities: { + force: ['2-way-audio'], + }, + }), + new GenericCameraManagerEngine(mock()), + ); + + await camera.initialize({ + hass: createHASS(), + stateWatcher: mock(), + }); + + expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled(); + expect(camera.getCapabilities()?.has('2-way-audio')).toBe(true); + }); + + it('should prefer disable over force rules', async () => { + const camera = new Camera( + createCameraConfig({ + capabilities: { + disable: ['2-way-audio'], + force: ['2-way-audio'], + }, + }), + new GenericCameraManagerEngine(mock()), + ); + + await camera.initialize({ + hass: createHASS(), + stateWatcher: mock(), + }); + + expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled(); + expect(camera.getCapabilities()?.has('2-way-audio')).toBe(false); + }); + + it('should prefer disable_except over force rules', async () => { + const camera = new Camera( + createCameraConfig({ + capabilities: { + disable_except: ['substream'], + force: ['2-way-audio'], + }, + }), + new GenericCameraManagerEngine(mock()), + ); + + await camera.initialize({ + hass: createHASS(), + stateWatcher: mock(), + }); + + expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled(); + expect(camera.getCapabilities()?.has('2-way-audio')).toBe(false); + }); + + it('should not fetch metadata when 2-way-audio is disabled', async () => { + const camera = new Camera( + createCameraConfig({ + capabilities: { + disable: ['2-way-audio'], + }, + }), + new GenericCameraManagerEngine(mock()), + ); + + await camera.initialize({ + hass: createHASS(), + stateWatcher: mock(), + }); + + expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled(); + expect(camera.getCapabilities()?.has('2-way-audio')).toBe(false); + }); + + it('should not fetch metadata when disable_except excludes 2-way-audio', async () => { + const camera = new Camera( + createCameraConfig({ + capabilities: { + disable_except: ['substream'], + }, + }), + new GenericCameraManagerEngine(mock()), + ); + + await camera.initialize({ + hass: createHASS(), + stateWatcher: mock(), + }); + + expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled(); + expect(camera.getCapabilities()?.has('2-way-audio')).toBe(false); + }); + + it('should fetch metadata when disable_except includes 2-way-audio', async () => { + const camera = new Camera( + createCameraConfig({ + capabilities: { + disable_except: ['substream', '2-way-audio'], + }, + }), + new GenericCameraManagerEngine(mock()), + ); + vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true); + + await camera.initialize({ + hass: createHASS(), + stateWatcher: mock(), + }); + + expect(liveProviderSupports2WayAudio).toHaveBeenCalled(); + expect(camera.getCapabilities()?.has('2-way-audio')).toBe(true); + }); + + it('should fetch metadata when disable_except is empty', async () => { + const camera = new Camera( + createCameraConfig({ + capabilities: { + disable_except: [], + }, + }), + new GenericCameraManagerEngine(mock()), + ); + vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true); + + await camera.initialize({ + hass: createHASS(), + stateWatcher: mock(), + }); + + expect(liveProviderSupports2WayAudio).toHaveBeenCalled(); + expect(camera.getCapabilities()?.has('2-way-audio')).toBe(true); + }); }); describe('should handle trigger state changes', () => { diff --git a/tests/camera-manager/utils/go2rtc-audio.test.ts b/tests/camera-manager/utils/go2rtc-audio.test.ts index deb402b3..97dc9e62 100644 --- a/tests/camera-manager/utils/go2rtc-audio.test.ts +++ b/tests/camera-manager/utils/go2rtc-audio.test.ts @@ -18,7 +18,7 @@ describe('supports2WayAudio', () => { }); it('should return false if no endpoint provided', async () => { - expect(await supports2WayAudio(hass, null)).toBe(false); + expect(await supports2WayAudio(hass, 2, null)).toBe(false); }); it('should return false if fetch fails', async () => { @@ -26,7 +26,7 @@ describe('supports2WayAudio', () => { vi.mocked(homeAssistantSignAndFetch).mockRejectedValue(new Error('fetch error')); const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const result = await supports2WayAudio(hass, endpoint); + const result = await supports2WayAudio(hass, 2, endpoint); expect(result).toBe(false); expect(spy).toHaveBeenCalledWith('fetch error'); @@ -37,7 +37,35 @@ describe('supports2WayAudio', () => { vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint); vi.mocked(homeAssistantSignAndFetch).mockResolvedValue({ producers: undefined }); - expect(await supports2WayAudio(hass, endpoint)).toBe(false); + expect(await supports2WayAudio(hass, 2, endpoint)).toBe(false); + }); + + it('should use default metadata fetch timeout', async () => { + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint); + vi.mocked(homeAssistantSignAndFetch).mockResolvedValue({ producers: [] }); + + await supports2WayAudio(hass, 2, endpoint); + + expect(homeAssistantSignAndFetch).toHaveBeenCalledWith( + hass, + endpoint, + expect.anything(), + { timeoutSeconds: 2 }, + ); + }); + + it('should use custom metadata fetch timeout when provided', async () => { + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint); + vi.mocked(homeAssistantSignAndFetch).mockResolvedValue({ producers: [] }); + + await supports2WayAudio(hass, 15, endpoint); + + expect(homeAssistantSignAndFetch).toHaveBeenCalledWith( + hass, + endpoint, + expect.anything(), + { timeoutSeconds: 15 }, + ); }); it('should return false if no producer supports audio', async () => { @@ -50,7 +78,7 @@ describe('supports2WayAudio', () => { ], }); - expect(await supports2WayAudio(hass, endpoint)).toBe(false); + expect(await supports2WayAudio(hass, 2, endpoint)).toBe(false); }); it('should return true if producer supports audio and sendonly', async () => { @@ -63,7 +91,7 @@ describe('supports2WayAudio', () => { ], }); - expect(await supports2WayAudio(hass, endpoint)).toBe(true); + expect(await supports2WayAudio(hass, 2, endpoint)).toBe(true); }); it('should return true if producer supports audio and sendrecv', async () => { @@ -76,7 +104,7 @@ describe('supports2WayAudio', () => { ], }); - expect(await supports2WayAudio(hass, endpoint)).toBe(true); + expect(await supports2WayAudio(hass, 2, endpoint)).toBe(true); }); it('should handle missing medias in producer', async () => { @@ -89,6 +117,6 @@ describe('supports2WayAudio', () => { ], }); - expect(await supports2WayAudio(hass, endpoint)).toBe(false); + expect(await supports2WayAudio(hass, 2, endpoint)).toBe(false); }); }); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 2f753685..2aa35571 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -23,6 +23,9 @@ describe('config defaults', () => { frigate: { client_id: 'frigate', }, + go2rtc: { + metadata_fetch_timeout_seconds: 2, + }, image: { mode: 'auto', refresh_seconds: 1, diff --git a/tests/utils/live-provider.test.ts b/tests/utils/live-provider.test.ts index e448df12..95d7edf1 100644 --- a/tests/utils/live-provider.test.ts +++ b/tests/utils/live-provider.test.ts @@ -71,7 +71,11 @@ describe('live-provider utils', () => { live_provider: 'ha', }); const hass = createHASS(); - const result = await liveProviderSupports2WayAudio(hass, config); + const result = await liveProviderSupports2WayAudio( + hass, + config, + config.go2rtc.metadata_fetch_timeout_seconds, + ); expect(result).toBe(false); }); @@ -82,10 +86,32 @@ describe('live-provider utils', () => { const hass = createHASS(); vi.mocked(go2rtcAudio.supports2WayAudio).mockResolvedValue(true); - const result = await liveProviderSupports2WayAudio(hass, config); + const result = await liveProviderSupports2WayAudio( + hass, + config, + config.go2rtc.metadata_fetch_timeout_seconds, + ); expect(result).toBe(true); expect(go2rtcAudio.supports2WayAudio).toHaveBeenCalledWith( hass, + 2, + undefined, + undefined, + ); + }); + + it('should pass metadata fetch timeout through to go2rtc detection', async () => { + const config = createCameraConfig({ + live_provider: 'go2rtc', + }); + const hass = createHASS(); + vi.mocked(go2rtcAudio.supports2WayAudio).mockResolvedValue(true); + + await liveProviderSupports2WayAudio(hass, config, 30); + + expect(go2rtcAudio.supports2WayAudio).toHaveBeenCalledWith( + hass, + 30, undefined, undefined, );