Files
advanced-camera-card/tests/camera-manager/camera.test.ts
T
Dermot Duffy 11cc543406 fix: Hold the 2-way audio backchannel open for the shortest possible time (#2697)
The card claimed a camera's ONVIF audio backchannel in two places that
had
nothing to do with a call: the capability probe at camera init
(`&microphone`),
and a pre-armed `sendonly` audio transceiver on every live WebRTC offer.
Merely
looking at a dashboard occupied the camera's speaker line. Outbound
audio now
travels on its own audio-only WebRTC connection, opened when a call is
answered
and closed when it ends.

- The backchannel is claimed only for the duration of a call. Idle
viewing
  claims nothing.
- Two-way audio now works in `mse`, `mp4` and `mjpeg` modes (note: the
outbound
  audio still traverses WebRTC).
- No renegotiation and no video blink at call start or end.
- A call that cannot carry audio now reports it and ends, instead of
showing a
  live microphone that goes nowhere.
- `live.microphone.always_connected` is now purely about the browser
microphone
  permission prompt.
- Call setup measured at 66ms (LAN) and ~260ms (cellular) for ICE and
DTLS, plus
  ~300ms for `go2rtc` to open an RTSP backchannel.

Verified against a live Frigate + `go2rtc` instance, and by unit tests
at 100%
coverage.

 - Closes #2691
 - Closes #2039
 - Closes #2178

Ref #2299 -- the probe no longer opens a backchannel, but it still runs
per
camera on every load and reconnect, and still dials the camera on the
direct-`go2rtc` path. Caching remains to be done.

Ref AlexxIT/go2rtc#1860 -- once a call has opened a backchannel,
`go2rtc` keeps
that media set up on the camera's RTSP session for the life of the
producer.

Diagnoses #2678
2026-08-21 20:13:58 -07:00

1208 lines
38 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { Camera } from '../../src/camera-manager/camera.js';
import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic.js';
import type { CameraProxyConfig } from '../../src/camera-manager/types.js';
import type { EventWatcherSubscriptionInterface } from '../../src/card-controller/hass/event-watcher.js';
import type { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
import { liveProviderSupports2WayAudio } from '../../src/utils/live-provider.js';
import type * as LiveProviderUtils from '../../src/utils/live-provider.js';
import { createCameraConfig } from '../config/test-utils';
import { EntityRegistryManagerMock } from '../ha/registry/entity/mock.js';
import {
callEventWatcherCallback,
callStateWatcherCallback,
createHASS,
createHASSEvent,
createHASSManager,
createRegistryEntity,
createStateEntity,
} from '../test-utils.js';
import { createCapabilities, createInitializedCamera } from './test-utils';
// Partially mock to keep the real pure helpers (e.g. `isGo2RTCLiveProvider`
// used by `getProxyConfig`) while mocking the async metadata fetch.
vi.mock('../../src/utils/live-provider.js', async (importOriginal) => ({
...(await importOriginal<typeof LiveProviderUtils>()),
liveProviderSupports2WayAudio: vi.fn(),
}));
describe('Camera', () => {
it('should get config', async () => {
const config = createCameraConfig();
const camera = new Camera(
config,
new GenericCameraManagerEngine(createHASSManager()),
);
expect(camera.getConfig()).toBe(config);
});
describe('should get capabilities', async () => {
it('when populated', async () => {
const capabilities = createCapabilities();
const camera = await createInitializedCamera(
createCameraConfig(),
new GenericCameraManagerEngine(createHASSManager()),
capabilities,
);
expect(camera.getCapabilities()).toBe(capabilities);
});
it('when unpopulated', async () => {
const camera = new Camera(
createCameraConfig(),
new GenericCameraManagerEngine(createHASSManager()),
);
expect(camera.getCapabilities()).toBeNull();
});
});
it('should get engine', async () => {
const engine = new GenericCameraManagerEngine(createHASSManager());
const camera = new Camera(createCameraConfig(), engine);
expect(camera.getEngine()).toBe(engine);
});
it('should set and get id', async () => {
const camera = new Camera(
createCameraConfig(),
new GenericCameraManagerEngine(createHASSManager()),
);
camera.setID('foo');
expect(camera.getID()).toBe('foo');
expect(camera.getConfig().id).toBe('foo');
});
it('should throw without id', async () => {
const camera = new Camera(
createCameraConfig(),
new GenericCameraManagerEngine(createHASSManager()),
);
expect(() => camera.getID()).toThrow(
'Could not determine camera id for the following ' +
"camera, may need to set 'id' parameter manually",
);
});
describe('initialize', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should initialize and destroy', async () => {
const camera = new Camera(
createCameraConfig({
triggers: {
entities: ['camera.foo'],
},
}),
new GenericCameraManagerEngine(createHASSManager()),
);
expect(camera.isInitialized()).toBe(false);
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
await camera.initialize({
hassManager: createHASSManager({ stateWatcher }),
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
});
expect(camera.isInitialized()).toBe(true);
expect(stateWatcher.subscribe).toHaveBeenCalledWith(expect.any(Function), [
'camera.foo',
]);
await camera.destroy();
expect(stateWatcher.unsubscribe).toHaveBeenCalled();
});
it('should skip initialization when hass is unavailable', async () => {
const camera = new Camera(
createCameraConfig({
triggers: {
entities: ['camera.foo'],
},
}),
new GenericCameraManagerEngine(createHASSManager()),
);
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
await camera.initialize({
hassManager: createHASSManager({ hass: null, stateWatcher }),
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
});
expect(stateWatcher.subscribe).not.toHaveBeenCalled();
expect(camera.getCapabilities()).toBeNull();
expect(camera.isInitialized()).toBe(false);
});
it('should set capabilities and use go2rtc metadata endpoint', async () => {
const camera = new Camera(
createCameraConfig({
go2rtc: {
url: 'http://go2rtc',
stream: 'stream',
},
}),
new GenericCameraManagerEngine(createHASSManager()),
);
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true);
await camera.initialize({
hassManager: createHASSManager(),
});
expect(liveProviderSupports2WayAudio).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
2,
{
endpoint: 'http://go2rtc/api/streams?src=stream&video=all&audio=all',
sign: false,
},
expect.objectContaining({
dynamic: true,
ssl_verification: true,
ssl_ciphers: 'default',
enabled: false,
}),
);
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(createHASSManager()),
);
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(false);
await camera.initialize({
hassManager: createHASSManager(),
});
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(createHASSManager()),
);
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true);
await camera.initialize({
hassManager: createHASSManager(),
});
expect(liveProviderSupports2WayAudio).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
20,
expect.anything(),
expect.objectContaining({ enabled: false }),
);
});
it('should pass proxy config when web proxy is available', async () => {
const camera = new Camera(
createCameraConfig({
go2rtc: {
url: 'http://go2rtc',
stream: 'stream',
},
proxy: {
live: true,
},
}),
new GenericCameraManagerEngine(createHASSManager()),
);
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true);
const hass = createHASS();
hass.config.components = ['hass_web_proxy'];
await camera.initialize({
hassManager: createHASSManager({ hass }),
});
expect(liveProviderSupports2WayAudio).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
2,
expect.anything(),
{
dynamic: true,
ssl_verification: true,
ssl_ciphers: 'default',
live: true,
media: false,
enabled: true,
enforce: true,
},
);
expect(camera.getCapabilities()?.has('2-way-audio')).toBe(true);
});
it('should return live proxy config', () => {
const camera = new Camera(
createCameraConfig({
proxy: { live: true },
}),
new GenericCameraManagerEngine(createHASSManager()),
);
expect(camera.getLiveProxyConfig()).toEqual(
expect.objectContaining({ enabled: true, enforce: true }),
);
});
it('should return media proxy config', () => {
const camera = new Camera(
createCameraConfig({
proxy: { media: true },
}),
new GenericCameraManagerEngine(createHASSManager()),
);
expect(camera.getMediaProxyConfig()).toEqual(
expect.objectContaining({ enabled: true, enforce: true }),
);
});
it('should not enforce live proxy config when live proxying is auto', () => {
const camera = new Camera(
createCameraConfig({
live_provider: 'go2rtc',
go2rtc: { url: 'http://go2rtc' },
}),
new GenericCameraManagerEngine(createHASSManager()),
);
expect(camera.getLiveProxyConfig()).toEqual(
expect.objectContaining({ enabled: true, enforce: false }),
);
});
it('should not enforce media proxy config when media proxying is auto', () => {
const camera = new Camera(
createCameraConfig({
proxy: { media: 'auto' },
}),
new GenericCameraManagerEngine(createHASSManager()),
);
expect(camera.getMediaProxyConfig()).toEqual(
expect.objectContaining({ enabled: false, enforce: false }),
);
});
it('should force 2-way-audio capability true without metadata fetch', async () => {
const camera = new Camera(
createCameraConfig({
capabilities: {
force: ['2-way-audio'],
},
}),
new GenericCameraManagerEngine(createHASSManager()),
);
await camera.initialize({
hassManager: createHASSManager(),
});
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(createHASSManager()),
);
await camera.initialize({
hassManager: createHASSManager(),
});
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(createHASSManager()),
);
await camera.initialize({
hassManager: createHASSManager(),
});
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(createHASSManager()),
);
await camera.initialize({
hassManager: createHASSManager(),
});
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(createHASSManager()),
);
await camera.initialize({
hassManager: createHASSManager(),
});
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(createHASSManager()),
);
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true);
await camera.initialize({
hassManager: createHASSManager(),
});
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(createHASSManager()),
);
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true);
await camera.initialize({
hassManager: createHASSManager(),
});
expect(liveProviderSupports2WayAudio).toHaveBeenCalled();
expect(camera.getCapabilities()?.has('2-way-audio')).toBe(true);
});
describe('entity resolution', () => {
it('should resolve entity when camera_entity and registry manager are provided', async () => {
const cameraEntity = createRegistryEntity({
entity_id: 'camera.front_door',
device_id: 'device_1',
});
const camera = new Camera(
createCameraConfig({ camera_entity: 'camera.front_door' }),
new GenericCameraManagerEngine(createHASSManager()),
);
await camera.initialize({
hassManager: createHASSManager(),
entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]),
});
expect(camera.getEntity()).toEqual(cameraEntity);
});
it('should leave entity null when camera_entity is unset', async () => {
const camera = new Camera(
createCameraConfig(),
new GenericCameraManagerEngine(createHASSManager()),
);
await camera.initialize({
hassManager: createHASSManager(),
entityRegistryManager: new EntityRegistryManagerMock(),
});
expect(camera.getEntity()).toBeNull();
});
it('should leave entity null when entityRegistryManager is not provided', async () => {
const camera = new Camera(
createCameraConfig({ camera_entity: 'camera.front_door' }),
new GenericCameraManagerEngine(createHASSManager()),
);
await camera.initialize({
hassManager: createHASSManager(),
});
expect(camera.getEntity()).toBeNull();
});
});
describe('trigger discovery', () => {
describe('doorbell', () => {
const initializeDoorbellCamera = async (options?: {
doorbell?: boolean;
deviceID?: string | null;
triggerCapability?: boolean;
userEntities?: string[];
omitRegistryManager?: boolean;
registryEntities?: ReturnType<typeof createRegistryEntity>[];
stateEntities?: Parameters<typeof createHASS>[0];
}): Promise<{
camera: Camera;
stateWatcher: StateWatcherSubscriptionInterface;
}> => {
const cameraEntity = createRegistryEntity({
entity_id: 'camera.front_door',
device_id: options?.deviceID === undefined ? 'device_1' : options.deviceID,
});
const doorbellEntity = createRegistryEntity({
entity_id: 'event.front_door_doorbell',
device_id: 'device_1',
});
const camera = new Camera(
createCameraConfig({
camera_entity: 'camera.front_door',
triggers: {
doorbell: options?.doorbell ?? true,
...(options?.userEntities && { entities: options.userEntities }),
},
}),
new GenericCameraManagerEngine(createHASSManager()),
);
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
const hass = createHASS(
options?.stateEntities ?? {
'event.front_door_doorbell': createStateEntity({
entity_id: 'event.front_door_doorbell',
attributes: { device_class: 'doorbell' },
}),
},
);
await camera.initialize({
hassManager: createHASSManager({ hass, stateWatcher }),
...(!options?.omitRegistryManager && {
entityRegistryManager: new EntityRegistryManagerMock(
options?.registryEntities ?? [cameraEntity, doorbellEntity],
),
}),
capabilityOptions: {
capabilities: createCapabilities({
trigger: options?.triggerCapability ?? true,
}),
},
});
return { camera, stateWatcher };
};
it('should auto-include doorbell event entity from camera device', async () => {
const { camera } = await initializeDoorbellCamera();
expect(camera.getConfig().triggers.entities).toEqual([
'event.front_door_doorbell',
]);
});
it('should skip discovery when triggers.doorbell is false', async () => {
const { camera } = await initializeDoorbellCamera({ doorbell: false });
expect(camera.getConfig().triggers.entities).toEqual([]);
});
it('should skip discovery when camera entity has no device_id', async () => {
const { camera } = await initializeDoorbellCamera({ deviceID: null });
expect(camera.getConfig().triggers.entities).toEqual([]);
});
it('should skip discovery when trigger capability is disabled', async () => {
const { camera } = await initializeDoorbellCamera({
triggerCapability: false,
});
expect(camera.getConfig().triggers.entities).toEqual([]);
});
it('should skip discovery when entityRegistryManager is not provided', async () => {
const { camera } = await initializeDoorbellCamera({
omitRegistryManager: true,
});
expect(camera.getConfig().triggers.entities).toEqual([]);
});
it('should de-duplicate against user-supplied entities', async () => {
const { camera } = await initializeDoorbellCamera({
userEntities: ['event.front_door_doorbell', 'binary_sensor.driveway'],
});
expect(camera.getConfig().triggers.entities).toEqual([
'event.front_door_doorbell',
'binary_sensor.driveway',
]);
});
it('should skip disabled event entities', async () => {
const { camera } = await initializeDoorbellCamera({
registryEntities: [
createRegistryEntity({
entity_id: 'camera.front_door',
device_id: 'device_1',
}),
createRegistryEntity({
entity_id: 'event.front_door_doorbell',
device_id: 'device_1',
disabled_by: 'user',
}),
],
});
expect(camera.getConfig().triggers.entities).toEqual([]);
});
it('should ignore non-doorbell event entities on the device', async () => {
const { camera } = await initializeDoorbellCamera({
registryEntities: [
createRegistryEntity({
entity_id: 'camera.front_door',
device_id: 'device_1',
}),
createRegistryEntity({
entity_id: 'event.front_door_button',
device_id: 'device_1',
}),
],
stateEntities: {
'event.front_door_button': createStateEntity({
entity_id: 'event.front_door_button',
attributes: { device_class: 'button' },
}),
},
});
expect(camera.getConfig().triggers.entities).toEqual([]);
});
it('should subscribe to discovered doorbell entities for state changes', async () => {
const { stateWatcher } = await initializeDoorbellCamera();
expect(stateWatcher.subscribe).toHaveBeenCalledWith(expect.any(Function), [
'event.front_door_doorbell',
]);
});
});
});
});
describe('should handle trigger state changes', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it.each([
['off' as const, 'on' as const, 'new' as const],
['on' as const, 'off' as const, 'end' as const],
])(
'from %s to %s',
async (stateFrom: string, stateTo: string, eventType: 'new' | 'end') => {
vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
const eventCallback = vi.fn();
const camera = new Camera(
createCameraConfig({
id: 'camera_1',
triggers: {
entities: ['binary_sensor.foo'],
},
}),
new GenericCameraManagerEngine(createHASSManager()),
{
eventCallback: eventCallback,
},
);
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
await camera.initialize({
hassManager: createHASSManager({ stateWatcher }),
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
});
expect(stateWatcher.subscribe).toHaveBeenCalled();
const diff = {
entityID: 'sensor.force_update',
oldState: createStateEntity({ state: stateFrom }),
newState: createStateEntity({ state: stateTo }),
};
callStateWatcherCallback(stateWatcher, diff);
expect(eventCallback).toHaveBeenCalledWith({
cameraID: 'camera_1',
id: 'sensor.force_update',
type: eventType,
});
},
);
it('should subscribe to configured triggers.events and dispatch momentary events', async () => {
const eventCallback = vi.fn();
const camera = new Camera(
createCameraConfig({
id: 'camera_1',
triggers: {
events: [{ event_type: 'zha_event' }],
},
}),
new GenericCameraManagerEngine(createHASSManager()),
{ eventCallback },
);
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
await camera.initialize({
hassManager: createHASSManager({ eventWatcher }),
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
});
expect(eventWatcher.subscribe).toHaveBeenCalledTimes(1);
const request = vi.mocked(eventWatcher.subscribe).mock.calls[0][0];
expect(request.event_type).toBe('zha_event');
expect(request.matcher).toBeUndefined();
callEventWatcherCallback(
eventWatcher,
createHASSEvent('zha_event', { command: 'press' }),
);
expect(eventCallback).toHaveBeenCalledWith({
cameraID: 'camera_1',
id: 'event:zha_event',
type: 'momentary',
});
await camera.destroy();
expect(eventWatcher.unsubscribe).toHaveBeenCalled();
});
it('should release earlier subscriptions when a later subscription throws', async () => {
const camera = new Camera(
createCameraConfig({
id: 'camera_1',
triggers: {
events: [{ event_type: 'zha_event' }],
},
}),
new GenericCameraManagerEngine(createHASSManager()),
);
const error = new Error('subscribe failed');
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
vi.mocked(eventWatcher.subscribe).mockImplementation(() => {
throw error;
});
await expect(
camera.initialize({
hassManager: createHASSManager({ stateWatcher, eventWatcher }),
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
}),
).rejects.toThrow(error);
// The state subscription was registered before the failure, so nothing
// else can release it.
expect(stateWatcher.subscribe).toHaveBeenCalled();
expect(stateWatcher.unsubscribe).toHaveBeenCalled();
});
it('should report the initialization failure when cleanup also fails', async () => {
const camera = new Camera(
createCameraConfig({
id: 'camera_1',
triggers: {
events: [{ event_type: 'zha_event' }],
},
}),
new GenericCameraManagerEngine(createHASSManager()),
);
const error = new Error('subscribe failed');
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
vi.mocked(stateWatcher.unsubscribe).mockRejectedValue(new Error('destroy failed'));
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
vi.mocked(eventWatcher.subscribe).mockImplementation(() => {
throw error;
});
await expect(
camera.initialize({
hassManager: createHASSManager({ stateWatcher, eventWatcher }),
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
}),
).rejects.toThrow(error);
expect(stateWatcher.unsubscribe).toHaveBeenCalled();
});
it('should attach a context-only matcher when only a context filter is set', async () => {
const camera = new Camera(
createCameraConfig({
id: 'camera_1',
triggers: {
events: [{ event_type: 'zha_event', context: { user_id: 'u-1' } }],
},
}),
new GenericCameraManagerEngine(createHASSManager()),
);
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
await camera.initialize({
hassManager: createHASSManager({ eventWatcher }),
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
});
const matcher = vi.mocked(eventWatcher.subscribe).mock.calls[0][0].matcher;
expect(matcher).toBeDefined();
expect(
matcher?.(
createHASSEvent('zha_event', {}, { id: 'i', user_id: 'u-1', parent_id: null }),
),
).toBe(true);
expect(
matcher?.(
createHASSEvent('zha_event', {}, { id: 'i', user_id: 'u-2', parent_id: null }),
),
).toBe(false);
});
it('should expand list-form event_type into one subscription per type', async () => {
const camera = new Camera(
createCameraConfig({
id: 'camera_1',
triggers: {
events: [{ event_type: ['zha_event', 'deconz_event'] }],
},
}),
new GenericCameraManagerEngine(createHASSManager()),
);
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
await camera.initialize({
hassManager: createHASSManager({ eventWatcher }),
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
});
expect(eventWatcher.subscribe).toHaveBeenCalledTimes(2);
expect(vi.mocked(eventWatcher.subscribe).mock.calls[0][0].event_type).toBe(
'zha_event',
);
expect(vi.mocked(eventWatcher.subscribe).mock.calls[1][0].event_type).toBe(
'deconz_event',
);
});
it('should attach a matcher when triggers.events entry has data filter', async () => {
const camera = new Camera(
createCameraConfig({
id: 'camera_1',
triggers: {
events: [{ event_type: 'zha_event', event_data: { command: 'press' } }],
},
}),
new GenericCameraManagerEngine(createHASSManager()),
);
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
await camera.initialize({
hassManager: createHASSManager({ eventWatcher }),
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
});
const matcher = vi.mocked(eventWatcher.subscribe).mock.calls[0][0].matcher;
expect(matcher).toBeDefined();
expect(
matcher?.(createHASSEvent('zha_event', { command: 'press', extra: 1 })),
).toBe(true);
expect(matcher?.(createHASSEvent('zha_event', { command: 'release' }))).toBe(
false,
);
});
it('should not subscribe to events when trigger capability is disabled', async () => {
const camera = new Camera(
createCameraConfig({
id: 'camera_1',
triggers: {
events: [{ event_type: 'zha_event' }],
},
}),
new GenericCameraManagerEngine(createHASSManager()),
);
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
await camera.initialize({
hassManager: createHASSManager({ eventWatcher }),
capabilityOptions: { capabilities: createCapabilities({ trigger: false }) },
});
expect(eventWatcher.subscribe).not.toHaveBeenCalled();
});
it('should not dispatch when the helper returns null', async () => {
vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
const eventCallback = vi.fn();
const camera = new Camera(
createCameraConfig({
id: 'camera_1',
triggers: {
entities: ['event.front_door_doorbell'],
},
}),
new GenericCameraManagerEngine(createHASSManager()),
{
eventCallback: eventCallback,
},
);
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
await camera.initialize({
hassManager: createHASSManager({ stateWatcher }),
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
});
callStateWatcherCallback(stateWatcher, {
entityID: 'event.front_door_doorbell',
oldState: createStateEntity({ state: 'unavailable' }),
newState: createStateEntity({ state: '2026-05-24T12:00:05.123+00:00' }),
});
expect(eventCallback).not.toHaveBeenCalled();
});
it('should dispatch a momentary event for an event entity fire', async () => {
vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
const eventCallback = vi.fn();
const camera = new Camera(
createCameraConfig({
id: 'camera_1',
triggers: {
entities: ['event.front_door_doorbell'],
},
}),
new GenericCameraManagerEngine(createHASSManager()),
{
eventCallback: eventCallback,
},
);
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
await camera.initialize({
hassManager: createHASSManager({ stateWatcher }),
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
});
callStateWatcherCallback(stateWatcher, {
entityID: 'event.front_door_doorbell',
oldState: createStateEntity({ state: '2026-05-24T12:00:00.000+00:00' }),
newState: createStateEntity({ state: '2026-05-24T12:00:05.123+00:00' }),
});
expect(eventCallback).toHaveBeenCalledTimes(1);
expect(eventCallback).toHaveBeenCalledWith({
cameraID: 'camera_1',
id: 'event.front_door_doorbell',
type: 'momentary',
});
});
it('should not trigger without trigger capability', async () => {
const eventCallback = vi.fn();
const camera = new Camera(
createCameraConfig({
id: 'camera_1',
triggers: {
entities: ['binary_sensor.foo'],
},
}),
new GenericCameraManagerEngine(createHASSManager()),
{
eventCallback: eventCallback,
},
);
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
await camera.initialize({
hassManager: createHASSManager({ stateWatcher }),
capabilityOptions: { capabilities: createCapabilities({ trigger: false }) },
});
expect(stateWatcher.subscribe).not.toHaveBeenCalled();
});
});
describe('should get proxy config', () => {
it.each([
[
'when unspecified',
{},
{
dynamic: true,
live: false,
media: false,
ssl_verification: true,
ssl_ciphers: 'default' as const,
},
],
[
'when media set to true',
{ proxy: { media: true } },
{
dynamic: true,
live: false,
media: true,
ssl_verification: true,
ssl_ciphers: 'default' as const,
},
],
[
'when media set to false',
{ proxy: { media: false as const } },
{
dynamic: true,
live: false,
media: false,
ssl_verification: true,
ssl_ciphers: 'default' as const,
},
],
[
'when media set to auto',
{ proxy: { media: 'auto' as const } },
{
dynamic: true,
live: false,
media: false,
ssl_verification: true,
ssl_ciphers: 'default' as const,
},
],
[
'when ssl_verification is set to auto',
{ proxy: { ssl_verification: 'auto' as const } },
{
dynamic: true,
live: false,
media: false,
ssl_verification: true,
ssl_ciphers: 'default' as const,
},
],
[
'when ssl_verification is set to true',
{ proxy: { ssl_verification: true } },
{
dynamic: true,
live: false,
media: false,
ssl_verification: true,
ssl_ciphers: 'default' as const,
},
],
[
'when ssl_verification is set to false',
{ proxy: { ssl_verification: false } },
{
dynamic: true,
live: false,
media: false,
ssl_verification: false,
ssl_ciphers: 'default' as const,
},
],
[
'when ssl_ciphers is set to auto',
{ proxy: { ssl_ciphers: 'auto' as const } },
{
dynamic: true,
live: false,
media: false,
ssl_verification: true,
ssl_ciphers: 'default' as const,
},
],
[
'when ssl_ciphers is set to modern',
{ proxy: { ssl_ciphers: 'modern' as const } },
{
dynamic: true,
live: false,
media: false,
ssl_verification: true,
ssl_ciphers: 'modern' as const,
},
],
[
'when dynamic is set to false',
{ proxy: { dynamic: false } },
{
dynamic: false,
live: false,
media: false,
ssl_verification: true,
ssl_ciphers: 'default' as const,
},
],
[
'when go2rtc has no url',
{ live_provider: 'go2rtc' },
{
dynamic: true,
live: false,
media: false,
ssl_verification: true,
ssl_ciphers: 'default' as const,
},
],
[
'when go2rtc has a url',
{ live_provider: 'go2rtc', go2rtc: { url: 'http://localhost:1984' } },
{
dynamic: true,
live: true,
media: false,
ssl_verification: true,
ssl_ciphers: 'default' as const,
},
],
[
'when go2rtc has a url but live is set to false',
{
live_provider: 'go2rtc',
go2rtc: { url: 'http://localhost:1984' },
proxy: { live: false },
},
{
dynamic: true,
live: false,
media: false,
ssl_verification: true,
ssl_ciphers: 'default' as const,
},
],
[
'when go2rtc-experimental has no url',
{ live_provider: 'go2rtc-experimental' },
{
dynamic: true,
live: false,
media: false,
ssl_verification: true,
ssl_ciphers: 'default' as const,
},
],
[
'when go2rtc-experimental has a url',
{
live_provider: 'go2rtc-experimental',
go2rtc: { url: 'http://localhost:1984' },
},
{
dynamic: true,
live: true,
media: false,
ssl_verification: true,
ssl_ciphers: 'default' as const,
},
],
])(
'%s',
(_name: string, cameraConfig: unknown, expectedResult: CameraProxyConfig) => {
const camera = new Camera(
createCameraConfig(cameraConfig),
new GenericCameraManagerEngine(createHASSManager()),
);
expect(camera.getProxyConfig()).toEqual(expectedResult);
},
);
});
describe('getEndpoints', () => {
it('should return null when no endpoints are available', () => {
const camera = new Camera(
createCameraConfig({
go2rtc: { stream: '' },
camera_entity: '',
}),
new GenericCameraManagerEngine(createHASSManager()),
);
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(createHASSManager()),
);
expect(camera.getEndpoints()).toEqual({
go2rtc: {
endpoint: 'http://go2rtc/api/ws?src=stream',
sign: false,
},
webrtcCard: {
endpoint: 'camera.foo',
},
});
});
});
});