fix: Cache 2-way audio detection to avoid re-probing (#2716)

- Closes: #2299
This commit is contained in:
Dermot Duffy
2026-08-28 21:23:49 -07:00
committed by GitHub
parent 8ee69be58b
commit fb53681978
3 changed files with 179 additions and 39 deletions
+5
View File
@@ -30,6 +30,11 @@ If detection never succeeds for a camera, the `go2rtc` stream itself may not
offer 2-way audio -- see offer 2-way audio -- see
[`go2rtc` live provider configuration](../configuration/cameras/live-provider.md?id=go2rtc). [`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 ## Example configuration
```yaml ```yaml
+64 -37
View File
@@ -1,3 +1,6 @@
import { add } from 'date-fns';
import { ExpiringEqualityCache } from '../cache/expiring-cache';
import type { EnabledProxyConfig } from '../config/schema/common/proxy'; import type { EnabledProxyConfig } from '../config/schema/common/proxy';
import { homeAssistantSignAndFetch } from '../ha/fetch'; import { homeAssistantSignAndFetch } from '../ha/fetch';
import type { HomeAssistant } from '../ha/types'; import type { HomeAssistant } from '../ha/types';
@@ -6,20 +9,17 @@ import type { Endpoint } from '../types';
import { errorToConsole } from '../utils/basic'; import { errorToConsole } from '../utils/basic';
import { go2RTCStreamInfoSchema, type Go2RTCStreamInfo } from './types'; import { go2RTCStreamInfoSchema, type Go2RTCStreamInfo } from './types';
const getGo2RTCStreamMetadata = async ( // Cache 2-way capabilities: these only changes when go2rtc configuration or
hass: HomeAssistant, // camera hardware changes.
endpoint: Endpoint, const TWO_WAY_AUDIO_CACHE_SECONDS = 5 * 60;
timeoutSeconds: number,
): Promise<Go2RTCStreamInfo | null> => { // Page-scoped because Home Assistant builds a new card on dashboard navigation,
try { // and every fetch makes go2rtc connect to the camera.
return await homeAssistantSignAndFetch(hass, endpoint, go2RTCStreamInfoSchema, { // See: https://github.com/dermotduffy/advanced-camera-card/issues/2299
timeoutSeconds, const twoWayAudioSupportCache = new ExpiringEqualityCache<
}); string,
} catch (e) { Promise<boolean | null>
errorToConsole(e); >();
return null;
}
};
const streamSupports2WayAudio = (streamInfo: Go2RTCStreamInfo | null): boolean => { const streamSupports2WayAudio = (streamInfo: Go2RTCStreamInfo | null): boolean => {
if (!streamInfo?.producers) { if (!streamInfo?.producers) {
@@ -35,18 +35,35 @@ const streamSupports2WayAudio = (streamInfo: Go2RTCStreamInfo | null): boolean =
); );
}; };
/** const fetch2WayAudioSupport = async (
* Fetch go2rtc metadata and determine if the stream supports 2-way audio. hass: HomeAssistant,
* Handles proxy transformation if proxy config requires it. metadataFetchTimeoutSeconds: number,
* Returns false if the endpoint is not available or fetch fails. go2rtcMetadataEndpoint: Endpoint,
* proxyConfig?: EnabledProxyConfig,
* Note: Caller is responsible for checking if live_provider is 'go2rtc' before calling. ): Promise<boolean | null> => {
* const endpoint = await createProxiedEndpointIfNecessary(
* @param hass Home Assistant instance. hass,
* @param go2rtcMetadataEndpoint The go2rtc metadata endpoint. go2rtcMetadataEndpoint,
* @param proxyConfig The resolved proxy configuration for live streams. proxyConfig,
* @returns True if supports 2-way audio, false otherwise. { 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 ( export const supports2WayAudio = async (
hass: HomeAssistant, hass: HomeAssistant,
metadataFetchTimeoutSeconds: number, metadataFetchTimeoutSeconds: number,
@@ -57,20 +74,30 @@ export const supports2WayAudio = async (
return false; return false;
} }
const endpoint = await createProxiedEndpointIfNecessary( const key = go2rtcMetadataEndpoint.endpoint;
hass, const cachedPromise = twoWayAudioSupportCache.get(key);
go2rtcMetadataEndpoint, if (cachedPromise) {
proxyConfig, return (await cachedPromise) ?? false;
{ openLimit: 1 },
);
if (!endpoint) {
return false;
} }
const streamInfo = await getGo2RTCStreamMetadata( const request = fetch2WayAudioSupport(
hass, hass,
endpoint,
metadataFetchTimeoutSeconds, 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;
}; };
+110 -2
View File
@@ -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 { mock } from 'vitest-mock-extended';
import { supports2WayAudio } from '../../src/go2rtc/audio'; import { supports2WayAudio } from '../../src/go2rtc/audio';
@@ -12,10 +13,15 @@ vi.mock('../../src/ha/web-proxy');
describe('supports2WayAudio', () => { describe('supports2WayAudio', () => {
const hass = mock<HomeAssistant>(); const hass = mock<HomeAssistant>();
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(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
endpoint = { endpoint: `http://go2rtc-${++endpointCount}`, sign: true };
}); });
it('should return false if no endpoint provided', async () => { it('should return false if no endpoint provided', async () => {
@@ -126,4 +132,106 @@ describe('supports2WayAudio', () => {
expect(await supports2WayAudio(hass, 2, endpoint)).toBe(false); 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);
});
});
}); });