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
```
This commit is contained in:
Dermot Duffy
2025-12-20 13:06:09 -08:00
committed by GitHub
parent 588ed6c98a
commit 1e821f09c6
47 changed files with 2240 additions and 958 deletions
+121 -24
View File
@@ -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<StateWatcherSubscriptionInterface>()),
{
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<StateWatcherSubscriptionInterface>()),
{
capabilities: createCapabilities({ trigger: true }),
},
);
describe('initialize', () => {
it('should initialize and destroy', async () => {
const camera = new Camera(
createCameraConfig({
triggers: {
entities: ['camera.foo'],
},
}),
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
);
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
await camera.initialize({
stateWatcher: stateWatcher,
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
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<StateWatcherSubscriptionInterface>()),
);
await camera.destroy();
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true);
expect(stateWatcher.unsubscribe).toBeCalled();
await camera.initialize({
hass: createHASS(),
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
});
expect(liveProviderSupports2WayAudio).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
{
endpoint:
'http://go2rtc/api/streams?src=stream&video=all&audio=all&microphone',
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<StateWatcherSubscriptionInterface>()),
);
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(false);
await camera.initialize({
hass: createHASS(),
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
});
expect(camera.getCapabilities()?.has('2-way-audio')).toBe(false);
});
});
describe('should handle trigger state changes', () => {
@@ -120,14 +179,15 @@ describe('Camera', () => {
}),
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
{
capabilities: createCapabilities({ trigger: true }),
eventCallback: eventCallback,
},
);
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
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<StateWatcherSubscriptionInterface>()),
{
capabilities: createCapabilities({ trigger: false }),
eventCallback: eventCallback,
},
);
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
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<StateWatcherSubscriptionInterface>()),
);
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<StateWatcherSubscriptionInterface>()),
);
expect(camera.getEndpoints()).toEqual({
go2rtc: {
endpoint: 'http://go2rtc/api/ws?src=stream',
sign: false,
},
webrtcCard: {
endpoint: 'camera.foo',
},
});
});
});
});