fix: Cache 2-way audio detection to avoid re-probing (#2716)
- Closes: #2299
This commit is contained in:
@@ -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
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user