diff --git a/docs/usage/2-way-audio.md b/docs/usage/2-way-audio.md index 9f6d07a2..dc54307e 100644 --- a/docs/usage/2-way-audio.md +++ b/docs/usage/2-way-audio.md @@ -30,6 +30,11 @@ If detection never succeeds for a camera, the `go2rtc` stream itself may not offer 2-way audio -- see [`go2rtc` live provider configuration](../configuration/cameras/live-provider.md?id=go2rtc). +Detection requires `go2rtc` to connect to the camera, so a successful result is +cached by the card for 5 minutes rather than detected again every time the card +loads. Reload your browser after changing the `go2rtc` configuration to have the +card detect changes immediately. + ## Example configuration ```yaml diff --git a/src/go2rtc/audio.ts b/src/go2rtc/audio.ts index bf45df46..2b995e86 100644 --- a/src/go2rtc/audio.ts +++ b/src/go2rtc/audio.ts @@ -1,3 +1,6 @@ +import { add } from 'date-fns'; + +import { ExpiringEqualityCache } from '../cache/expiring-cache'; import type { EnabledProxyConfig } from '../config/schema/common/proxy'; import { homeAssistantSignAndFetch } from '../ha/fetch'; import type { HomeAssistant } from '../ha/types'; @@ -6,20 +9,17 @@ import type { Endpoint } from '../types'; import { errorToConsole } from '../utils/basic'; import { go2RTCStreamInfoSchema, type Go2RTCStreamInfo } from './types'; -const getGo2RTCStreamMetadata = async ( - hass: HomeAssistant, - endpoint: Endpoint, - timeoutSeconds: number, -): Promise => { - try { - return await homeAssistantSignAndFetch(hass, endpoint, go2RTCStreamInfoSchema, { - timeoutSeconds, - }); - } catch (e) { - errorToConsole(e); - return null; - } -}; +// Cache 2-way capabilities: these only changes when go2rtc configuration or +// camera hardware changes. +const TWO_WAY_AUDIO_CACHE_SECONDS = 5 * 60; + +// Page-scoped because Home Assistant builds a new card on dashboard navigation, +// and every fetch makes go2rtc connect to the camera. +// See: https://github.com/dermotduffy/advanced-camera-card/issues/2299 +const twoWayAudioSupportCache = new ExpiringEqualityCache< + string, + Promise +>(); const streamSupports2WayAudio = (streamInfo: Go2RTCStreamInfo | null): boolean => { if (!streamInfo?.producers) { @@ -35,18 +35,35 @@ const streamSupports2WayAudio = (streamInfo: Go2RTCStreamInfo | null): boolean = ); }; -/** - * 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 resolved proxy configuration for live streams. - * @returns True if supports 2-way audio, false otherwise. - */ +const fetch2WayAudioSupport = async ( + hass: HomeAssistant, + metadataFetchTimeoutSeconds: number, + go2rtcMetadataEndpoint: Endpoint, + proxyConfig?: EnabledProxyConfig, +): Promise => { + const endpoint = await createProxiedEndpointIfNecessary( + hass, + go2rtcMetadataEndpoint, + proxyConfig, + { openLimit: 1 }, + ); + if (!endpoint) { + return null; + } + + try { + return streamSupports2WayAudio( + await homeAssistantSignAndFetch(hass, endpoint, go2RTCStreamInfoSchema, { + timeoutSeconds: metadataFetchTimeoutSeconds, + }), + ); + } catch (e) { + errorToConsole(e); + return null; + } +}; + +// Caller must verify the live provider is go2rtc before calling. export const supports2WayAudio = async ( hass: HomeAssistant, metadataFetchTimeoutSeconds: number, @@ -57,20 +74,30 @@ export const supports2WayAudio = async ( return false; } - const endpoint = await createProxiedEndpointIfNecessary( - hass, - go2rtcMetadataEndpoint, - proxyConfig, - { openLimit: 1 }, - ); - if (!endpoint) { - return false; + const key = go2rtcMetadataEndpoint.endpoint; + const cachedPromise = twoWayAudioSupportCache.get(key); + if (cachedPromise) { + return (await cachedPromise) ?? false; } - const streamInfo = await getGo2RTCStreamMetadata( + const request = fetch2WayAudioSupport( hass, - endpoint, metadataFetchTimeoutSeconds, + go2rtcMetadataEndpoint, + proxyConfig, ); - return streamSupports2WayAudio(streamInfo); + twoWayAudioSupportCache.set( + key, + request, + add(new Date(), { seconds: TWO_WAY_AUDIO_CACHE_SECONDS }), + ); + + const isSupported = await request; + + // Inconclusive results are evicted so the next caller retries. + if (isSupported === null) { + twoWayAudioSupportCache.delete(key); + } + + return isSupported ?? false; }; diff --git a/tests/go2rtc/audio.test.ts b/tests/go2rtc/audio.test.ts index 60c60ecc..10c67687 100644 --- a/tests/go2rtc/audio.test.ts +++ b/tests/go2rtc/audio.test.ts @@ -1,4 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { add } from 'date-fns'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { supports2WayAudio } from '../../src/go2rtc/audio'; @@ -12,10 +13,15 @@ vi.mock('../../src/ha/web-proxy'); describe('supports2WayAudio', () => { const hass = mock(); - const endpoint: Endpoint = { endpoint: 'http://go2rtc', sign: true }; + + // Answers are cached by endpoint for the life of the page, so each test uses + // a unique endpoint. + let endpointCount = 0; + let endpoint: Endpoint; beforeEach(() => { vi.clearAllMocks(); + endpoint = { endpoint: `http://go2rtc-${++endpointCount}`, sign: true }; }); it('should return false if no endpoint provided', async () => { @@ -126,4 +132,106 @@ describe('supports2WayAudio', () => { expect(await supports2WayAudio(hass, 2, endpoint)).toBe(false); }); + + describe('should cache answers', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('should reuse an answer rather than fetch again', async () => { + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint); + vi.mocked(homeAssistantSignAndFetch).mockResolvedValue({ + producers: [{ medias: ['audio,sendonly,opus'] }], + }); + + expect(await supports2WayAudio(hass, 2, endpoint)).toBe(true); + expect(await supports2WayAudio(hass, 2, endpoint)).toBe(true); + + expect(homeAssistantSignAndFetch).toHaveBeenCalledTimes(1); + }); + + it('should fetch once for simultaneous callers', async () => { + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint); + vi.mocked(homeAssistantSignAndFetch).mockResolvedValue({ + producers: [{ medias: ['audio,sendonly,opus'] }], + }); + + expect( + await Promise.all([ + supports2WayAudio(hass, 2, endpoint), + supports2WayAudio(hass, 2, endpoint), + ]), + ).toEqual([true, true]); + + expect(homeAssistantSignAndFetch).toHaveBeenCalledTimes(1); + }); + + it('should keep answers for different endpoints separate', async () => { + const otherEndpoint: Endpoint = { endpoint: 'http://go2rtc-other', sign: true }; + + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint); + vi.mocked(homeAssistantSignAndFetch) + .mockResolvedValueOnce({ producers: [{ medias: ['audio,sendonly,opus'] }] }) + .mockResolvedValueOnce({ producers: [{ medias: ['video,sendonly,h264'] }] }); + + expect(await supports2WayAudio(hass, 2, endpoint)).toBe(true); + expect(await supports2WayAudio(hass, 2, otherEndpoint)).toBe(false); + }); + + it('should fetch again after the answer expires', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-28T12:00:00Z')); + + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint); + vi.mocked(homeAssistantSignAndFetch).mockResolvedValue({ + producers: [{ medias: ['audio,sendonly,opus'] }], + }); + + expect(await supports2WayAudio(hass, 2, endpoint)).toBe(true); + + vi.setSystemTime(add(new Date(), { minutes: 6 })); + + expect(await supports2WayAudio(hass, 2, endpoint)).toBe(true); + expect(homeAssistantSignAndFetch).toHaveBeenCalledTimes(2); + }); + + it('should not reuse a failed fetch', async () => { + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint); + vi.mocked(homeAssistantSignAndFetch).mockRejectedValue(new Error('fetch error')); + + const spy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + expect(await supports2WayAudio(hass, 2, endpoint)).toBe(false); + expect(await supports2WayAudio(hass, 2, endpoint)).toBe(false); + + expect(homeAssistantSignAndFetch).toHaveBeenCalledTimes(2); + spy.mockRestore(); + }); + + it('should return false to simultaneous callers when the fetch fails', async () => { + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint); + vi.mocked(homeAssistantSignAndFetch).mockRejectedValue(new Error('fetch error')); + + const spy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + expect( + await Promise.all([ + supports2WayAudio(hass, 2, endpoint), + supports2WayAudio(hass, 2, endpoint), + ]), + ).toEqual([false, false]); + + expect(homeAssistantSignAndFetch).toHaveBeenCalledTimes(1); + spy.mockRestore(); + }); + + it('should not reuse an unavailable proxied endpoint', async () => { + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(null); + + expect(await supports2WayAudio(hass, 2, endpoint)).toBe(false); + expect(await supports2WayAudio(hass, 2, endpoint)).toBe(false); + + expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(2); + }); + }); });