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:
@@ -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µphone',
|
||||
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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,13 +3,21 @@ import { mock } from 'vitest-mock-extended';
|
||||
import { CameraManagerEngine } from '../../../src/camera-manager/engine';
|
||||
import { FrigateCamera } from '../../../src/camera-manager/frigate/camera';
|
||||
import { FrigateEventWatcher } from '../../../src/camera-manager/frigate/event-watcher';
|
||||
import {
|
||||
FrigateEventViewMedia,
|
||||
FrigateRecordingViewMedia,
|
||||
} from '../../../src/camera-manager/frigate/media';
|
||||
import { getPTZInfo } from '../../../src/camera-manager/frigate/requests';
|
||||
import { FrigateEventChange } from '../../../src/camera-manager/frigate/types';
|
||||
import {
|
||||
eventSchema,
|
||||
FrigateEventChange,
|
||||
} from '../../../src/camera-manager/frigate/types';
|
||||
import { ActionsExecutor } from '../../../src/card-controller/actions/types';
|
||||
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
|
||||
import { PTZAction } from '../../../src/config/schema/actions/custom/ptz';
|
||||
import { CameraTriggerEventType } from '../../../src/config/schema/cameras';
|
||||
import { Entity, EntityRegistryManager } from '../../../src/ha/registry/entity/types';
|
||||
import { ViewMediaType } from '../../../src/view/item';
|
||||
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
|
||||
import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils';
|
||||
|
||||
@@ -297,6 +305,294 @@ describe('FrigateCamera', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEndpoints', () => {
|
||||
describe('getUIEndpoint', () => {
|
||||
it('should return null when no frigate URL is set', () => {
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
url: '',
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
expect(camera.getEndpoints()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return frigate URL when no camera name is set', () => {
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
url: 'http://frigate',
|
||||
camera_name: '',
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
expect(camera.getEndpoints({ view: 'live' })?.ui).toEqual({
|
||||
endpoint: 'http://frigate',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return camera URL for live view', () => {
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
url: 'http://frigate',
|
||||
camera_name: 'front_door',
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
expect(camera.getEndpoints({ view: 'live' })?.ui).toEqual({
|
||||
endpoint: 'http://frigate/#front_door',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return events URL for clip media', () => {
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
url: 'http://frigate',
|
||||
camera_name: 'front_door',
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const event = eventSchema.parse({
|
||||
camera: 'front_door',
|
||||
id: 'event-id',
|
||||
label: 'person',
|
||||
start_time: 100,
|
||||
end_time: 200,
|
||||
has_clip: true,
|
||||
has_snapshot: true,
|
||||
retain_indefinitely: false,
|
||||
false_positive: false,
|
||||
sub_label: '',
|
||||
top_score: 0.8,
|
||||
zones: [],
|
||||
});
|
||||
const media = new FrigateEventViewMedia(
|
||||
ViewMediaType.Clip,
|
||||
'front_door',
|
||||
event,
|
||||
'content-id',
|
||||
'thumbnail',
|
||||
);
|
||||
|
||||
expect(camera.getEndpoints({ view: 'media', media })?.ui).toEqual({
|
||||
endpoint: 'http://frigate/events?camera=front_door',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return events URL for snapshot media', () => {
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
url: 'http://frigate',
|
||||
camera_name: 'front_door',
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const event = eventSchema.parse({
|
||||
camera: 'front_door',
|
||||
id: 'event-id',
|
||||
label: 'person',
|
||||
start_time: 100,
|
||||
end_time: 200,
|
||||
has_clip: true,
|
||||
has_snapshot: true,
|
||||
retain_indefinitely: false,
|
||||
false_positive: false,
|
||||
sub_label: '',
|
||||
top_score: 0.8,
|
||||
zones: [],
|
||||
});
|
||||
const media = new FrigateEventViewMedia(
|
||||
ViewMediaType.Snapshot,
|
||||
'front_door',
|
||||
event,
|
||||
'content-id',
|
||||
'thumbnail',
|
||||
);
|
||||
|
||||
expect(camera.getEndpoints({ view: 'media', media })?.ui).toEqual({
|
||||
endpoint: 'http://frigate/events?camera=front_door',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return recordings URL with time for recording media with startTime', () => {
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
url: 'http://frigate',
|
||||
camera_name: 'front_door',
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const media = new FrigateRecordingViewMedia(
|
||||
ViewMediaType.Recording,
|
||||
'front_door',
|
||||
{
|
||||
cameraID: 'front_door',
|
||||
startTime: new Date('2023-01-01T10:00:00Z'),
|
||||
endTime: new Date('2023-01-01T11:00:00Z'),
|
||||
events: 0,
|
||||
},
|
||||
'recording-id',
|
||||
'content-id',
|
||||
'title',
|
||||
);
|
||||
|
||||
expect(camera.getEndpoints({ view: 'media', media })?.ui).toEqual({
|
||||
endpoint: 'http://frigate/recording/front_door/2023-01-01/10',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return recordings URL without time for recording media without startTime', () => {
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
url: 'http://frigate',
|
||||
camera_name: 'front_door',
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
// Create a media object where getStartTime returns null
|
||||
const media = new FrigateRecordingViewMedia(
|
||||
ViewMediaType.Recording,
|
||||
'front_door',
|
||||
{
|
||||
cameraID: 'front_door',
|
||||
// Forced null for test
|
||||
startTime: null as unknown as Date,
|
||||
endTime: new Date('2023-01-01T11:00:00Z'),
|
||||
events: 0,
|
||||
},
|
||||
'recording-id',
|
||||
'content-id',
|
||||
'title',
|
||||
);
|
||||
|
||||
expect(camera.getEndpoints({ view: 'media', media })?.ui).toEqual({
|
||||
endpoint: 'http://frigate/recording/front_door',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return events URL for clip/clips/snapshots/snapshot views', () => {
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
url: 'http://frigate',
|
||||
camera_name: 'front_door',
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
expect(camera.getEndpoints({ view: 'clip' })?.ui?.endpoint).toBe(
|
||||
'http://frigate/events?camera=front_door',
|
||||
);
|
||||
expect(camera.getEndpoints({ view: 'clips' })?.ui?.endpoint).toBe(
|
||||
'http://frigate/events?camera=front_door',
|
||||
);
|
||||
expect(camera.getEndpoints({ view: 'snapshots' })?.ui?.endpoint).toBe(
|
||||
'http://frigate/events?camera=front_door',
|
||||
);
|
||||
expect(camera.getEndpoints({ view: 'snapshot' })?.ui?.endpoint).toBe(
|
||||
'http://frigate/events?camera=front_door',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return recordings URL for recording/recordings views', () => {
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
url: 'http://frigate',
|
||||
camera_name: 'front_door',
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
expect(camera.getEndpoints({ view: 'recording' })?.ui?.endpoint).toBe(
|
||||
'http://frigate/recording/front_door',
|
||||
);
|
||||
expect(camera.getEndpoints({ view: 'recordings' })?.ui?.endpoint).toBe(
|
||||
'http://frigate/recording/front_door',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return camera URL as default fallback', () => {
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
url: 'http://frigate',
|
||||
camera_name: 'front_door',
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
expect(camera.getEndpoints({ view: 'timeline' })?.ui).toEqual({
|
||||
endpoint: 'http://frigate/#front_door',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGo2RTCStreamEndpoint', () => {
|
||||
it('should return default frigate go2rtc paths', () => {
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
client_id: 'frigate',
|
||||
camera_name: 'front_door',
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const endpoints = camera.getEndpoints();
|
||||
expect(endpoints?.go2rtc).toEqual({
|
||||
endpoint: '/api/frigate/frigate/mse/api/ws?src=front_door',
|
||||
sign: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getJSMPEGEndpoint', () => {
|
||||
it('should return default frigate jsmpeg path', () => {
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
client_id: 'frigate',
|
||||
camera_name: 'front_door',
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const endpoints = camera.getEndpoints();
|
||||
expect(endpoints?.jsmpeg).toEqual({
|
||||
endpoint: '/api/frigate/frigate/jsmpeg/front_door',
|
||||
sign: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null if no camera name is set', () => {
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
camera_name: '',
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const endpoints = camera.getEndpoints();
|
||||
expect(endpoints?.jsmpeg).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('should handle events', () => {
|
||||
it('should subscribe', async () => {
|
||||
const camera = new FrigateCamera(
|
||||
@@ -654,6 +950,53 @@ describe('FrigateCamera', () => {
|
||||
expect(eventCallback).toHaveBeenCalledTimes(call ? 1 : 0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore events when camera ID is not set', async () => {
|
||||
const eventCallback = vi.fn();
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
// Note: No 'id' is set here
|
||||
frigate: {
|
||||
camera_name: 'camera.front_door',
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
{
|
||||
eventCallback: eventCallback,
|
||||
},
|
||||
);
|
||||
|
||||
const hass = createHASS();
|
||||
const eventWatcher = mock<FrigateEventWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcher>(),
|
||||
frigateEventWatcher: eventWatcher,
|
||||
});
|
||||
|
||||
callEventWatcherCallback(eventWatcher, {
|
||||
type: 'new',
|
||||
before: {
|
||||
camera: 'camera.front_door',
|
||||
snapshot: null,
|
||||
has_clip: false,
|
||||
has_snapshot: false,
|
||||
label: 'person',
|
||||
current_zones: [],
|
||||
},
|
||||
after: {
|
||||
camera: 'camera.front_door',
|
||||
snapshot: null,
|
||||
has_clip: false,
|
||||
has_snapshot: true,
|
||||
label: 'person',
|
||||
current_zones: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(eventCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,11 +9,10 @@ import { FrigateEvent, eventSchema } from '../../../src/camera-manager/frigate/t
|
||||
import { CameraManagerRequestCache } from '../../../src/camera-manager/types';
|
||||
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
|
||||
import { CameraConfig } from '../../../src/config/schema/cameras';
|
||||
import { AdvancedCameraCardView } from '../../../src/config/schema/common/const';
|
||||
import { RawAdvancedCameraCardConfig } from '../../../src/config/types';
|
||||
import { ViewMedia, ViewMediaType } from '../../../src/view/item';
|
||||
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
|
||||
import { TestViewMedia, createCameraConfig, createHASS } from '../../test-utils';
|
||||
import { createCameraConfig, createHASS } from '../../test-utils';
|
||||
|
||||
const createEngine = (): FrigateCameraManagerEngine => {
|
||||
return new FrigateCameraManagerEngine(
|
||||
@@ -141,265 +140,3 @@ describe('getMediaDownloadPath', () => {
|
||||
expect(endpoint).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCameraEndpoints', () => {
|
||||
it('should get basic endpoints', () => {
|
||||
const endpoints = createEngine().getCameraEndpoints(createFrigateCameraConfig());
|
||||
|
||||
expect(endpoints).toEqual({
|
||||
go2rtc: {
|
||||
endpoint: '/api/frigate/frigate/mse/api/ws?src=camera-1',
|
||||
sign: true,
|
||||
},
|
||||
jsmpeg: {
|
||||
endpoint: '/api/frigate/frigate/jsmpeg/camera-1',
|
||||
sign: true,
|
||||
},
|
||||
webrtcCard: {
|
||||
endpoint: 'camera.office',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('should get overridden go2rtc url', () => {
|
||||
it('when local HA path', () => {
|
||||
const endpoints = createEngine().getCameraEndpoints(
|
||||
createFrigateCameraConfig({
|
||||
go2rtc: {
|
||||
url: '/local/path',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(endpoints).toEqual(
|
||||
expect.objectContaining({
|
||||
go2rtc: {
|
||||
endpoint: '/local/path/api/ws?src=camera-1',
|
||||
sign: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('when remote', () => {
|
||||
const endpoints = createEngine().getCameraEndpoints(
|
||||
createFrigateCameraConfig({
|
||||
go2rtc: {
|
||||
url: 'https://my.custom.go2rtc',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(endpoints).toEqual(
|
||||
expect.objectContaining({
|
||||
go2rtc: {
|
||||
endpoint: 'https://my.custom.go2rtc/api/ws?src=camera-1',
|
||||
sign: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not set webrtc_card endpoint without camera name', () => {
|
||||
const endpoints = createEngine().getCameraEndpoints(createCameraConfig());
|
||||
|
||||
expect(endpoints).not.toEqual(
|
||||
expect.objectContaining({
|
||||
webrtcCard: expect.anything(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('should include UI endpoint', () => {
|
||||
it('with basic url', () => {
|
||||
const endpoints = createEngine().getCameraEndpoints(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
url: 'http://my.frigate',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(endpoints).not.toEqual(
|
||||
expect.objectContaining({
|
||||
ui: {
|
||||
url: 'http://my.frigate',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('with camera name', () => {
|
||||
const endpoints = createEngine().getCameraEndpoints(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
url: 'http://my.frigate',
|
||||
camera_name: 'my-camera',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(endpoints).not.toEqual(
|
||||
expect.objectContaining({
|
||||
ui: {
|
||||
url: 'http://my.frigate/cameras/my-camera',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('with event media type', () => {
|
||||
it.each([[ViewMediaType.Clip], [ViewMediaType.Snapshot]])(
|
||||
'%s',
|
||||
(mediaType: ViewMediaType) => {
|
||||
const endpoints = createEngine().getCameraEndpoints(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
url: 'http://my.frigate',
|
||||
camera_name: 'my-camera',
|
||||
},
|
||||
}),
|
||||
{
|
||||
media: new TestViewMedia({ mediaType: mediaType }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(endpoints).not.toEqual(
|
||||
expect.objectContaining({
|
||||
ui: {
|
||||
url: 'http://my.frigate/events?camera=my-camera',
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('with recording media type', () => {
|
||||
it('with start time', () => {
|
||||
const startTime = new Date('2023-10-07T16:42:00');
|
||||
const endpoints = createEngine().getCameraEndpoints(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
url: 'http://my.frigate',
|
||||
camera_name: 'my-camera',
|
||||
},
|
||||
}),
|
||||
{
|
||||
media: new TestViewMedia({
|
||||
mediaType: ViewMediaType.Recording,
|
||||
startTime: startTime,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(endpoints).not.toEqual(
|
||||
expect.objectContaining({
|
||||
ui: {
|
||||
url: 'http://my.frigate/recording/my-camera/2023-10-07/16',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('without start time', () => {
|
||||
const endpoints = createEngine().getCameraEndpoints(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
url: 'http://my.frigate',
|
||||
camera_name: 'my-camera',
|
||||
},
|
||||
}),
|
||||
{
|
||||
media: new TestViewMedia({ mediaType: ViewMediaType.Recording }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(endpoints).not.toEqual(
|
||||
expect.objectContaining({
|
||||
ui: {
|
||||
url: 'http://my.frigate/recording/my-camera/',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with view', () => {
|
||||
it('live', () => {
|
||||
const endpoints = createEngine().getCameraEndpoints(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
url: 'http://my.frigate',
|
||||
camera_name: 'my-camera',
|
||||
},
|
||||
}),
|
||||
{
|
||||
view: 'live',
|
||||
},
|
||||
);
|
||||
|
||||
expect(endpoints).not.toEqual(
|
||||
expect.objectContaining({
|
||||
ui: {
|
||||
url: 'http://my.frigate/cameras/my-camera',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['clip' as const],
|
||||
['clips' as const],
|
||||
['snapshot' as const],
|
||||
['snapshots' as const],
|
||||
])('%s', (viewName: AdvancedCameraCardView) => {
|
||||
const endpoints = createEngine().getCameraEndpoints(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
url: 'http://my.frigate',
|
||||
camera_name: 'my-camera',
|
||||
},
|
||||
}),
|
||||
{
|
||||
view: viewName,
|
||||
},
|
||||
);
|
||||
|
||||
expect(endpoints).not.toEqual(
|
||||
expect.objectContaining({
|
||||
ui: {
|
||||
url: 'http://my.frigate/events?camera=my-camera',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([['recording' as const], ['recordings' as const]])(
|
||||
'%s',
|
||||
(viewName: AdvancedCameraCardView) => {
|
||||
const endpoints = createEngine().getCameraEndpoints(
|
||||
createCameraConfig({
|
||||
frigate: {
|
||||
url: 'http://my.frigate',
|
||||
camera_name: 'my-camera',
|
||||
},
|
||||
}),
|
||||
{
|
||||
view: viewName,
|
||||
},
|
||||
);
|
||||
|
||||
expect(endpoints).not.toEqual(
|
||||
expect.objectContaining({
|
||||
ui: {
|
||||
url: 'http://my.frigate/recording/my-camera/',
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -295,21 +295,26 @@ describe('GenericCameraManagerEngine', () => {
|
||||
});
|
||||
|
||||
describe('should get camera endpoints', () => {
|
||||
it('default', () => {
|
||||
expect(createEngine().getCameraEndpoints(createGenericCameraConfig())).toBeNull();
|
||||
it('default', async () => {
|
||||
const camera = await createEngine().createCamera(
|
||||
createHASS(),
|
||||
createGenericCameraConfig(),
|
||||
);
|
||||
expect(camera.getEndpoints()).toBeNull();
|
||||
});
|
||||
|
||||
it('for go2rtc', () => {
|
||||
expect(
|
||||
createEngine().getCameraEndpoints(
|
||||
createGenericCameraConfig({
|
||||
go2rtc: {
|
||||
stream: 'stream',
|
||||
url: '/local/path',
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
it('for go2rtc', async () => {
|
||||
const camera = await createEngine().createCamera(
|
||||
createHASS(),
|
||||
createGenericCameraConfig({
|
||||
go2rtc: {
|
||||
stream: 'stream',
|
||||
url: '/local/path',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(camera.getEndpoints()).toEqual({
|
||||
go2rtc: {
|
||||
endpoint: '/local/path/api/ws?src=stream',
|
||||
sign: true,
|
||||
@@ -317,14 +322,15 @@ describe('GenericCameraManagerEngine', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('for webrtc-card', () => {
|
||||
expect(
|
||||
createEngine().getCameraEndpoints(
|
||||
createGenericCameraConfig({
|
||||
camera_entity: 'camera.office',
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
it('for webrtc-card', async () => {
|
||||
const camera = await createEngine().createCamera(
|
||||
createHASS(),
|
||||
createGenericCameraConfig({
|
||||
camera_entity: 'camera.office',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(camera.getEndpoints()).toEqual({
|
||||
webrtcCard: {
|
||||
endpoint: 'camera.office',
|
||||
},
|
||||
|
||||
@@ -32,7 +32,7 @@ import { ViewFolder, ViewItem, ViewMedia } from '../../src/view/item.js';
|
||||
import { ViewItemCapabilities } from '../../src/view/types.js';
|
||||
import {
|
||||
TestViewMedia,
|
||||
createCamera,
|
||||
createInitializedCamera,
|
||||
createCameraConfig,
|
||||
createCapabilities,
|
||||
createCardAPI,
|
||||
@@ -253,7 +253,7 @@ describe('CameraManager', async () => {
|
||||
if (engineType) {
|
||||
vi.mocked(mockEngine.createCamera).mockImplementationOnce(
|
||||
async (_hass: HomeAssistant, cameraConfig: CameraConfig): Promise<Camera> =>
|
||||
createCamera(
|
||||
await createInitializedCamera(
|
||||
cameraConfig,
|
||||
mockEngine,
|
||||
camera.capabilties ?? createCapabilities(),
|
||||
@@ -984,12 +984,7 @@ describe('CameraManager', async () => {
|
||||
|
||||
expect(await manager.initializeCamerasFromConfig()).toBeTruthy();
|
||||
|
||||
const result: CameraEndpoints = {};
|
||||
const context: CameraEndpointsContext = {};
|
||||
vi.mocked(engine.getCameraEndpoints).mockReturnValue(result);
|
||||
|
||||
expect(manager.getCameraEndpoints('id', context)).toBe(result);
|
||||
expect(engine.getCameraEndpoints).toBeCalledWith(expect.anything(), context);
|
||||
expect(manager.getCameraEndpoints('id', { view: 'live' })).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { MotionEyeCameraManagerEngine } from '../../../src/camera-manager/motioneye/engine-motioneye';
|
||||
import { CameraManagerRequestCache, Engine } from '../../../src/camera-manager/types';
|
||||
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
|
||||
import { BrowseMediaWalker } from '../../../src/ha/browse-media/walker';
|
||||
import { ResolvedMediaCache } from '../../../src/ha/resolved-media';
|
||||
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
|
||||
|
||||
const createEngine = (): MotionEyeCameraManagerEngine => {
|
||||
return new MotionEyeCameraManagerEngine(
|
||||
new EntityRegistryManagerMock(),
|
||||
mock<StateWatcher>(),
|
||||
new BrowseMediaWalker(),
|
||||
new ResolvedMediaCache(),
|
||||
new CameraManagerRequestCache(),
|
||||
);
|
||||
};
|
||||
|
||||
describe('MotionEyeCameraManagerEngine', () => {
|
||||
it('should get correct engine type', () => {
|
||||
const engine = createEngine();
|
||||
expect(engine.getEngineType()).toBe(Engine.MotionEye);
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,7 @@ import { ResolvedMediaCache } from '../../../src/ha/resolved-media';
|
||||
import { homeAssistantWSRequest } from '../../../src/ha/ws-request';
|
||||
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
|
||||
import {
|
||||
createCamera,
|
||||
createInitializedCamera,
|
||||
createCameraConfig,
|
||||
createHASS,
|
||||
createRegistryEntity,
|
||||
@@ -254,15 +254,11 @@ describe('ReolinkCameraManagerEngine', () => {
|
||||
expect(camera.getConfig()).toBe(config);
|
||||
expect(camera.getEngine()).toBe(engine);
|
||||
expect(camera.getCapabilities()?.getRawCapabilities()).toEqual({
|
||||
'favorite-events': false,
|
||||
'favorite-recordings': false,
|
||||
'2-way-audio': false,
|
||||
clips: true,
|
||||
'remote-control-entity': true,
|
||||
live: true,
|
||||
menu: true,
|
||||
recordings: false,
|
||||
seek: false,
|
||||
snapshots: false,
|
||||
substream: true,
|
||||
trigger: true,
|
||||
});
|
||||
@@ -287,32 +283,39 @@ describe('ReolinkCameraManagerEngine', () => {
|
||||
});
|
||||
|
||||
describe('should get camera endpoints', () => {
|
||||
it('should return ui endpoint', () => {
|
||||
const cameraConfig = createCameraConfig({
|
||||
reolink: {
|
||||
url: 'http://path-to-reolink',
|
||||
},
|
||||
});
|
||||
it('should return ui endpoint', async () => {
|
||||
const engine = createPopulatedEngine();
|
||||
const camera = await engine.createCamera(
|
||||
createHASS(),
|
||||
createCameraConfig({
|
||||
camera_entity: 'camera.office',
|
||||
reolink: {
|
||||
url: 'http://path-to-reolink',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const engine = createEngine();
|
||||
expect(engine.getCameraEndpoints(cameraConfig)).toEqual(
|
||||
expect(camera.getEndpoints()).toEqual(
|
||||
expect.objectContaining({
|
||||
ui: { endpoint: 'http://path-to-reolink' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return go2rtc endpoint', () => {
|
||||
const cameraConfig = createCameraConfig({
|
||||
go2rtc: {
|
||||
url: 'http://path-to-go2rtc',
|
||||
stream: 'stream',
|
||||
},
|
||||
});
|
||||
it('should return go2rtc endpoint', async () => {
|
||||
const engine = createPopulatedEngine();
|
||||
const camera = await engine.createCamera(
|
||||
createHASS(),
|
||||
createCameraConfig({
|
||||
camera_entity: 'camera.office',
|
||||
go2rtc: {
|
||||
url: 'http://path-to-go2rtc',
|
||||
stream: 'stream',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const engine = createEngine();
|
||||
|
||||
expect(engine.getCameraEndpoints(cameraConfig)).toEqual(
|
||||
expect(camera.getEndpoints()).toEqual(
|
||||
expect.objectContaining({
|
||||
go2rtc: { endpoint: 'http://path-to-go2rtc/api/ws?src=stream', sign: false },
|
||||
}),
|
||||
@@ -629,7 +632,9 @@ describe('ReolinkCameraManagerEngine', () => {
|
||||
const engine = createPopulatedEngine();
|
||||
|
||||
const store = new CameraManagerStore();
|
||||
store.addCamera(createCamera(createCameraConfig({ id: 'office' }), engine));
|
||||
store.addCamera(
|
||||
await createInitializedCamera(createCameraConfig({ id: 'office' }), engine),
|
||||
);
|
||||
|
||||
const hass = createHASS();
|
||||
const events = await engine.getEvents(hass, store, {
|
||||
@@ -1136,7 +1141,9 @@ describe('ReolinkCameraManagerEngine', () => {
|
||||
const engine = createPopulatedEngine();
|
||||
|
||||
const store = new CameraManagerStore();
|
||||
store.addCamera(createCamera(createCameraConfig({ id: 'office' }), engine));
|
||||
store.addCamera(
|
||||
await createInitializedCamera(createCameraConfig({ id: 'office' }), engine),
|
||||
);
|
||||
|
||||
const metadata = await engine.getMediaMetadata(createHASS(), store, {
|
||||
type: QueryType.MediaMetadata,
|
||||
|
||||
@@ -6,9 +6,14 @@ import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-fact
|
||||
import { CameraManagerStore } from '../../src/camera-manager/store.js';
|
||||
import { Engine } from '../../src/camera-manager/types.js';
|
||||
import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
|
||||
import { DeviceRegistryManager } from '../../src/ha/registry/device/index.js';
|
||||
import { EntityRegistryManager } from '../../src/ha/registry/entity/types.js';
|
||||
import { ResolvedMediaCache } from '../../src/ha/resolved-media.js';
|
||||
import { TestViewMedia, createCameraConfig } from '../test-utils.js';
|
||||
import {
|
||||
TestViewMedia,
|
||||
createCameraConfig,
|
||||
createInitializedCamera,
|
||||
} from '../test-utils.js';
|
||||
|
||||
describe('CameraManagerStore', async () => {
|
||||
const configVisible = createCameraConfig({
|
||||
@@ -19,7 +24,10 @@ describe('CameraManagerStore', async () => {
|
||||
hide: true,
|
||||
});
|
||||
|
||||
const engineFactory = new CameraManagerEngineFactory(mock<EntityRegistryManager>());
|
||||
const engineFactory = new CameraManagerEngineFactory(
|
||||
mock<EntityRegistryManager>(),
|
||||
mock<DeviceRegistryManager>(),
|
||||
);
|
||||
|
||||
const engineGeneric = await engineFactory.createEngine(Engine.Generic, {
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
@@ -269,7 +277,7 @@ describe('CameraManagerStore', async () => {
|
||||
expect(store.getAllDependentCameras('one')).toEqual(new Set(['one', 'two']));
|
||||
});
|
||||
|
||||
it('should return cameras with specific capabilities', () => {
|
||||
it('should return cameras with specific capabilities', async () => {
|
||||
const store = new CameraManagerStore();
|
||||
store.addCamera(
|
||||
new Camera(
|
||||
@@ -283,22 +291,18 @@ describe('CameraManagerStore', async () => {
|
||||
),
|
||||
);
|
||||
store.addCamera(
|
||||
new Camera(
|
||||
await createInitializedCamera(
|
||||
createCameraConfig({
|
||||
id: 'two',
|
||||
}),
|
||||
engineGeneric,
|
||||
{
|
||||
capabilities: new Capabilities({
|
||||
clips: true,
|
||||
}),
|
||||
},
|
||||
new Capabilities({ clips: true }),
|
||||
),
|
||||
);
|
||||
expect(store.getAllDependentCameras('one', 'clips')).toEqual(new Set(['two']));
|
||||
});
|
||||
|
||||
it('should return cameras with specific capabilities inclusive of parent', () => {
|
||||
it('should return cameras with specific capabilities inclusive of parent', async () => {
|
||||
const store = new CameraManagerStore();
|
||||
store.addCamera(
|
||||
new Camera(
|
||||
@@ -312,16 +316,12 @@ describe('CameraManagerStore', async () => {
|
||||
),
|
||||
);
|
||||
store.addCamera(
|
||||
new Camera(
|
||||
await createInitializedCamera(
|
||||
createCameraConfig({
|
||||
id: 'two',
|
||||
}),
|
||||
engineGeneric,
|
||||
{
|
||||
capabilities: new Capabilities({
|
||||
clips: true,
|
||||
}),
|
||||
},
|
||||
new Capabilities({ clips: true }),
|
||||
),
|
||||
);
|
||||
expect(store.getAllDependentCameras('one', 'clips', { inclusive: true })).toEqual(
|
||||
@@ -330,19 +330,15 @@ describe('CameraManagerStore', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('getCameraIDsWithCapability', () => {
|
||||
it('getCameraIDsWithCapability', async () => {
|
||||
const store = new CameraManagerStore();
|
||||
store.addCamera(
|
||||
new Camera(
|
||||
await createInitializedCamera(
|
||||
createCameraConfig({
|
||||
id: 'one',
|
||||
}),
|
||||
engineGeneric,
|
||||
{
|
||||
capabilities: new Capabilities({
|
||||
clips: true,
|
||||
}),
|
||||
},
|
||||
new Capabilities({ clips: true }),
|
||||
),
|
||||
);
|
||||
store.addCamera(
|
||||
|
||||
@@ -45,15 +45,10 @@ describe('TPLinkCameraManagerEngine', () => {
|
||||
expect(camera.getConfig()).toBe(config);
|
||||
expect(camera.getEngine()).toBe(engine);
|
||||
expect(camera.getCapabilities()?.getRawCapabilities()).toEqual({
|
||||
'favorite-events': false,
|
||||
'favorite-recordings': false,
|
||||
clips: false,
|
||||
'2-way-audio': false,
|
||||
'remote-control-entity': true,
|
||||
live: true,
|
||||
menu: true,
|
||||
recordings: false,
|
||||
seek: false,
|
||||
snapshots: false,
|
||||
substream: true,
|
||||
trigger: true,
|
||||
});
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getDefaultGo2RTCEndpoint } from '../../../src/camera-manager/utils/go2rtc-endpoint.js';
|
||||
import {
|
||||
getGo2RTCMetadataEndpoint,
|
||||
getGo2RTCStreamEndpoint,
|
||||
} from '../../../src/camera-manager/utils/go2rtc/endpoint.js';
|
||||
import { createCameraConfig } from '../../test-utils.js';
|
||||
|
||||
describe('getDefaultGo2RTCEndpoint', () => {
|
||||
describe('getGo2RTCStreamEndpoint', () => {
|
||||
it('with local configuration', () => {
|
||||
expect(
|
||||
getDefaultGo2RTCEndpoint(
|
||||
getGo2RTCStreamEndpoint(
|
||||
createCameraConfig({
|
||||
go2rtc: {
|
||||
stream: 'stream',
|
||||
@@ -21,7 +24,7 @@ describe('getDefaultGo2RTCEndpoint', () => {
|
||||
|
||||
it('with remote configuration', () => {
|
||||
expect(
|
||||
getDefaultGo2RTCEndpoint(
|
||||
getGo2RTCStreamEndpoint(
|
||||
createCameraConfig({
|
||||
go2rtc: {
|
||||
stream: 'stream',
|
||||
@@ -36,6 +39,45 @@ describe('getDefaultGo2RTCEndpoint', () => {
|
||||
});
|
||||
|
||||
it('without configuration', () => {
|
||||
expect(getDefaultGo2RTCEndpoint(createCameraConfig())).toBeNull();
|
||||
expect(getGo2RTCStreamEndpoint(createCameraConfig())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGo2RTCMetadataEndpoint', () => {
|
||||
it('with local configuration', () => {
|
||||
expect(
|
||||
getGo2RTCMetadataEndpoint(
|
||||
createCameraConfig({
|
||||
go2rtc: {
|
||||
stream: 'stream',
|
||||
url: '/local/path',
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
endpoint: '/local/path/api/streams?src=stream&video=all&audio=allµphone',
|
||||
sign: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('with remote configuration', () => {
|
||||
expect(
|
||||
getGo2RTCMetadataEndpoint(
|
||||
createCameraConfig({
|
||||
go2rtc: {
|
||||
stream: 'stream',
|
||||
url: 'https://my-custom-go2rtc',
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
endpoint:
|
||||
'https://my-custom-go2rtc/api/streams?src=stream&video=all&audio=allµphone',
|
||||
sign: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('without configuration', () => {
|
||||
expect(getGo2RTCMetadataEndpoint(createCameraConfig())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -961,19 +961,23 @@ describe('MenuButtonController', () => {
|
||||
});
|
||||
|
||||
describe('should have microphone button', () => {
|
||||
it('with suitable loaded media', () => {
|
||||
it('when camera has 2-way-audio capability', () => {
|
||||
const microphoneManager = mock<MicrophoneManager>();
|
||||
vi.mocked(microphoneManager.isForbidden).mockReturnValue(false);
|
||||
vi.mocked(microphoneManager.isMuted).mockReturnValue(false);
|
||||
vi.mocked(microphoneManager.isSupported).mockReturnValue(true);
|
||||
|
||||
const buttons = calculateButtons(controller, {
|
||||
microphoneManager: microphoneManager,
|
||||
currentMediaLoadedInfo: createMediaLoadedInfo({
|
||||
capabilities: {
|
||||
supports2WayAudio: true,
|
||||
const cameraManager = createCameraManager(
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera-1',
|
||||
capabilities: createCapabilities({ '2-way-audio': true }),
|
||||
},
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const buttons = calculateButtons(controller, {
|
||||
cameraManager,
|
||||
microphoneManager: microphoneManager,
|
||||
});
|
||||
|
||||
expect(buttons).toContainEqual({
|
||||
@@ -997,19 +1001,18 @@ describe('MenuButtonController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('without suitable loaded media', () => {
|
||||
it('when camera does not have 2-way-audio capability', () => {
|
||||
const microphoneManager = mock<MicrophoneManager>();
|
||||
vi.mocked(microphoneManager.isForbidden).mockReturnValue(false);
|
||||
vi.mocked(microphoneManager.isMuted).mockReturnValue(false);
|
||||
vi.mocked(microphoneManager.isSupported).mockReturnValue(true);
|
||||
|
||||
const cameraManager = createCameraManager(
|
||||
createStore([{ cameraID: 'camera-1', capabilities: createCapabilities() }]),
|
||||
);
|
||||
const buttons = calculateButtons(controller, {
|
||||
cameraManager,
|
||||
microphoneManager: microphoneManager,
|
||||
currentMediaLoadedInfo: createMediaLoadedInfo({
|
||||
capabilities: {
|
||||
supports2WayAudio: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(buttons).not.toEqual(
|
||||
@@ -1021,13 +1024,17 @@ describe('MenuButtonController', () => {
|
||||
const microphoneManager = mock<MicrophoneManager>();
|
||||
vi.mocked(microphoneManager.isForbidden).mockReturnValue(true);
|
||||
|
||||
const buttons = calculateButtons(controller, {
|
||||
microphoneManager: microphoneManager,
|
||||
currentMediaLoadedInfo: createMediaLoadedInfo({
|
||||
capabilities: {
|
||||
supports2WayAudio: true,
|
||||
const cameraManager = createCameraManager(
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera-1',
|
||||
capabilities: createCapabilities({ '2-way-audio': true }),
|
||||
},
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const buttons = calculateButtons(controller, {
|
||||
cameraManager,
|
||||
microphoneManager: microphoneManager,
|
||||
});
|
||||
|
||||
expect(buttons).toContainEqual({
|
||||
@@ -1046,13 +1053,17 @@ describe('MenuButtonController', () => {
|
||||
vi.mocked(microphoneManager.isMuted).mockReturnValue(true);
|
||||
vi.mocked(microphoneManager.isSupported).mockReturnValue(true);
|
||||
|
||||
const buttons = calculateButtons(controller, {
|
||||
microphoneManager: microphoneManager,
|
||||
currentMediaLoadedInfo: createMediaLoadedInfo({
|
||||
capabilities: {
|
||||
supports2WayAudio: true,
|
||||
const cameraManager = createCameraManager(
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera-1',
|
||||
capabilities: createCapabilities({ '2-way-audio': true }),
|
||||
},
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const buttons = calculateButtons(controller, {
|
||||
cameraManager,
|
||||
microphoneManager: microphoneManager,
|
||||
});
|
||||
|
||||
expect(buttons).toContainEqual({
|
||||
@@ -1079,13 +1090,17 @@ describe('MenuButtonController', () => {
|
||||
vi.mocked(microphoneManager.isMuted).mockReturnValue(true);
|
||||
vi.mocked(microphoneManager.isSupported).mockReturnValue(false);
|
||||
|
||||
const buttons = calculateButtons(controller, {
|
||||
microphoneManager: microphoneManager,
|
||||
currentMediaLoadedInfo: createMediaLoadedInfo({
|
||||
capabilities: {
|
||||
supports2WayAudio: true,
|
||||
const cameraManager = createCameraManager(
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera-1',
|
||||
capabilities: createCapabilities({ '2-way-audio': true }),
|
||||
},
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const buttons = calculateButtons(controller, {
|
||||
cameraManager,
|
||||
microphoneManager: microphoneManager,
|
||||
});
|
||||
|
||||
expect(buttons).toContainEqual({
|
||||
@@ -1104,13 +1119,17 @@ describe('MenuButtonController', () => {
|
||||
vi.mocked(microphoneManager.isMuted).mockReturnValue(true);
|
||||
vi.mocked(microphoneManager.isSupported).mockReturnValue(true);
|
||||
|
||||
const buttons = calculateButtons(controller, {
|
||||
microphoneManager: microphoneManager,
|
||||
currentMediaLoadedInfo: createMediaLoadedInfo({
|
||||
capabilities: {
|
||||
supports2WayAudio: true,
|
||||
const cameraManager = createCameraManager(
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera-1',
|
||||
capabilities: createCapabilities({ '2-way-audio': true }),
|
||||
},
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const buttons = calculateButtons(controller, {
|
||||
cameraManager,
|
||||
microphoneManager: microphoneManager,
|
||||
config: createConfig({
|
||||
menu: { buttons: { microphone: { type: 'toggle' } } },
|
||||
}),
|
||||
@@ -1136,13 +1155,17 @@ describe('MenuButtonController', () => {
|
||||
vi.mocked(microphoneManager.isMuted).mockReturnValue(false);
|
||||
vi.mocked(microphoneManager.isSupported).mockReturnValue(true);
|
||||
|
||||
const buttons = calculateButtons(controller, {
|
||||
microphoneManager: microphoneManager,
|
||||
currentMediaLoadedInfo: createMediaLoadedInfo({
|
||||
capabilities: {
|
||||
supports2WayAudio: true,
|
||||
const cameraManager = createCameraManager(
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera-1',
|
||||
capabilities: createCapabilities({ '2-way-audio': true }),
|
||||
},
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const buttons = calculateButtons(controller, {
|
||||
cameraManager,
|
||||
microphoneManager: microphoneManager,
|
||||
config: createConfig({
|
||||
menu: { buttons: { microphone: { type: 'toggle' } } },
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import { homeAssistantSignAndFetch } from '../../src/ha/fetch';
|
||||
import { homeAssistantSignPath } from '../../src/ha/sign-path';
|
||||
import { AdvancedCameraCardError, Endpoint } from '../../src/types';
|
||||
import { createHASS } from '../test-utils';
|
||||
|
||||
vi.mock('../../src/ha/sign-path');
|
||||
|
||||
describe('homeAssistantSignAndFetch', () => {
|
||||
const response = {
|
||||
val: 10,
|
||||
};
|
||||
const schema = z.object({
|
||||
val: z.number(),
|
||||
});
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.mocked(homeAssistantSignPath).mockResolvedValue('http://signed');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return parsed data on successful call with endpoint', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => response,
|
||||
});
|
||||
|
||||
const endpoint: Endpoint = { endpoint: 'http://example.com' };
|
||||
expect(await homeAssistantSignAndFetch(createHASS(), endpoint, schema)).toEqual(
|
||||
response,
|
||||
);
|
||||
expect(homeAssistantSignPath).not.toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledWith('http://example.com', {});
|
||||
});
|
||||
|
||||
it('should pass timeout signal when timeoutSeconds is provided', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => response,
|
||||
});
|
||||
|
||||
const endpoint: Endpoint = { endpoint: 'http://example.com' };
|
||||
expect(
|
||||
await homeAssistantSignAndFetch(createHASS(), endpoint, schema, {
|
||||
timeoutSeconds: 5,
|
||||
}),
|
||||
).toEqual(response);
|
||||
expect(fetchMock).toHaveBeenCalledWith('http://example.com', {
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
});
|
||||
|
||||
it('should sign path if requested', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => response,
|
||||
});
|
||||
|
||||
const endpoint: Endpoint = {
|
||||
endpoint: 'http://example.com',
|
||||
sign: true,
|
||||
};
|
||||
const hass = createHASS();
|
||||
expect(await homeAssistantSignAndFetch(hass, endpoint, schema)).toEqual(response);
|
||||
expect(homeAssistantSignPath).toHaveBeenCalledWith(hass, 'http://example.com');
|
||||
expect(fetchMock).toHaveBeenCalledWith('http://signed', {});
|
||||
});
|
||||
|
||||
it('should throw on sign failure', async () => {
|
||||
vi.mocked(homeAssistantSignPath).mockRejectedValueOnce(new Error('Sign failed'));
|
||||
|
||||
const endpoint: Endpoint = {
|
||||
endpoint: 'http://example.com',
|
||||
sign: true,
|
||||
};
|
||||
await expect(
|
||||
homeAssistantSignAndFetch(createHASS(), endpoint, schema),
|
||||
).rejects.toThrow(/Could not sign Home Assistant URL/);
|
||||
});
|
||||
|
||||
it('should throw if sign path returns null', async () => {
|
||||
vi.mocked(homeAssistantSignPath).mockResolvedValue(null);
|
||||
|
||||
const endpoint: Endpoint = {
|
||||
endpoint: 'http://example.com',
|
||||
sign: true,
|
||||
};
|
||||
await expect(
|
||||
homeAssistantSignAndFetch(createHASS(), endpoint, schema),
|
||||
).rejects.toThrow(/Could not sign Home Assistant URL/);
|
||||
});
|
||||
|
||||
it('should throw on fetch failure', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error('Fetch failed'));
|
||||
|
||||
const endpoint: Endpoint = { endpoint: 'http://example.com' };
|
||||
try {
|
||||
await homeAssistantSignAndFetch(createHASS(), endpoint, schema);
|
||||
expect.fail('Should have thrown');
|
||||
} catch (e) {
|
||||
const error = e as AdvancedCameraCardError;
|
||||
expect(error.message).toMatch(/Could not fetch URL/);
|
||||
expect(error.context).toEqual({
|
||||
endpoint,
|
||||
error: expect.any(Error),
|
||||
});
|
||||
const context = error.context as { error: Error };
|
||||
expect(context.error.message).toBe('Fetch failed');
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw on non-ok response', async () => {
|
||||
const response = {
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
} as Response;
|
||||
fetchMock.mockResolvedValueOnce(response);
|
||||
|
||||
const endpoint: Endpoint = { endpoint: 'http://example.com' };
|
||||
try {
|
||||
await homeAssistantSignAndFetch(createHASS(), endpoint, schema);
|
||||
expect.fail('Should have thrown');
|
||||
} catch (e) {
|
||||
expect((e as AdvancedCameraCardError).message).toMatch(
|
||||
/Failed to receive response/,
|
||||
);
|
||||
expect((e as AdvancedCameraCardError).context).toEqual({
|
||||
endpoint,
|
||||
response,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw on JSON parse failure', async () => {
|
||||
const response = {
|
||||
ok: true,
|
||||
json: async () => {
|
||||
throw new Error('JSON error');
|
||||
},
|
||||
} as unknown as Response;
|
||||
fetchMock.mockResolvedValueOnce(response);
|
||||
|
||||
const endpoint: Endpoint = { endpoint: 'http://example.com' };
|
||||
try {
|
||||
await homeAssistantSignAndFetch(createHASS(), endpoint, schema);
|
||||
expect.fail('Should have thrown');
|
||||
} catch (e) {
|
||||
const error = e as AdvancedCameraCardError;
|
||||
expect(error.message).toMatch(/Received invalid response/);
|
||||
expect(error.context).toEqual({
|
||||
endpoint,
|
||||
response,
|
||||
error: expect.any(Error),
|
||||
});
|
||||
const context = error.context as { error: Error };
|
||||
expect(context.error.message).toBe('JSON error');
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw on schema validation failure', async () => {
|
||||
const data = { val: 'string' };
|
||||
const response = {
|
||||
ok: true,
|
||||
json: async () => data,
|
||||
} as unknown as Response;
|
||||
fetchMock.mockResolvedValueOnce(response);
|
||||
|
||||
const endpoint: Endpoint = { endpoint: 'http://example.com' };
|
||||
|
||||
try {
|
||||
await homeAssistantSignAndFetch(createHASS(), endpoint, schema);
|
||||
expect.fail('Should have thrown');
|
||||
} catch (e) {
|
||||
expect((e as AdvancedCameraCardError).message).toMatch(
|
||||
/Received invalid response/,
|
||||
);
|
||||
expect((e as AdvancedCameraCardError).context).toMatchObject({
|
||||
endpoint,
|
||||
data,
|
||||
error: expect.any(z.ZodError),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { CameraProxyConfig } from '../../src/camera-manager/types.js';
|
||||
import {
|
||||
addDynamicProxyURL,
|
||||
createProxiedEndpointIfNecessary,
|
||||
getWebProxiedURL,
|
||||
shouldUseWebProxy,
|
||||
} from '../../src/ha/web-proxy.js';
|
||||
@@ -115,3 +116,161 @@ describe('addDynamicProxyURL', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createProxiedEndpointIfNecessary', () => {
|
||||
const createProxyConfig = (
|
||||
config: Partial<CameraProxyConfig> = {},
|
||||
): CameraProxyConfig => ({
|
||||
media: true,
|
||||
live: true,
|
||||
ssl_verification: true,
|
||||
ssl_ciphers: 'default',
|
||||
dynamic: true,
|
||||
...config,
|
||||
});
|
||||
|
||||
const testEndpoint = { endpoint: 'http://example.com/stream', sign: false };
|
||||
|
||||
it('should return original endpoint when proxyConfig is undefined', async () => {
|
||||
const hass = createHASS();
|
||||
hass.config.components = ['hass_web_proxy'];
|
||||
|
||||
const result = await createProxiedEndpointIfNecessary(hass, testEndpoint);
|
||||
expect(result).toBe(testEndpoint);
|
||||
});
|
||||
|
||||
it('should return original endpoint when proxy is not available', async () => {
|
||||
const hass = createHASS();
|
||||
hass.config.components = [];
|
||||
|
||||
const result = await createProxiedEndpointIfNecessary(
|
||||
hass,
|
||||
testEndpoint,
|
||||
createProxyConfig(),
|
||||
);
|
||||
expect(result).toBe(testEndpoint);
|
||||
});
|
||||
|
||||
it('should return original endpoint when context is not enabled', async () => {
|
||||
const hass = createHASS();
|
||||
hass.config.components = ['hass_web_proxy'];
|
||||
|
||||
const result = await createProxiedEndpointIfNecessary(
|
||||
hass,
|
||||
testEndpoint,
|
||||
createProxyConfig({ media: false }),
|
||||
{ context: 'media' },
|
||||
);
|
||||
expect(result).toBe(testEndpoint);
|
||||
});
|
||||
|
||||
it('should return proxied endpoint with dynamic registration', async () => {
|
||||
const hass = createHASS();
|
||||
hass.config.components = ['hass_web_proxy'];
|
||||
|
||||
const result = await createProxiedEndpointIfNecessary(
|
||||
hass,
|
||||
testEndpoint,
|
||||
createProxyConfig(),
|
||||
{ context: 'media', ttl: 300, openLimit: 5 },
|
||||
);
|
||||
|
||||
expect(hass.callService).toHaveBeenCalledWith(
|
||||
'hass_web_proxy',
|
||||
'create_proxied_url',
|
||||
expect.objectContaining({
|
||||
url_pattern: 'http://example.com/stream',
|
||||
ttl: 300,
|
||||
open_limit: 5,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
endpoint: '/api/hass_web_proxy/v0/?url=http%3A%2F%2Fexample.com%2Fstream',
|
||||
sign: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should strip hash fragment when registering dynamic proxy', async () => {
|
||||
const hass = createHASS();
|
||||
hass.config.components = ['hass_web_proxy'];
|
||||
|
||||
const endpointWithHash = {
|
||||
endpoint: 'http://example.com/stream#fragment',
|
||||
sign: false,
|
||||
};
|
||||
|
||||
await createProxiedEndpointIfNecessary(hass, endpointWithHash, createProxyConfig());
|
||||
|
||||
expect(hass.callService).toHaveBeenCalledWith(
|
||||
'hass_web_proxy',
|
||||
'create_proxied_url',
|
||||
expect.objectContaining({
|
||||
url_pattern: 'http://example.com/stream',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return proxied endpoint without dynamic registration', async () => {
|
||||
const hass = createHASS();
|
||||
hass.config.components = ['hass_web_proxy'];
|
||||
|
||||
const result = await createProxiedEndpointIfNecessary(
|
||||
hass,
|
||||
testEndpoint,
|
||||
createProxyConfig({ dynamic: false }),
|
||||
);
|
||||
|
||||
expect(hass.callService).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
endpoint: '/api/hass_web_proxy/v0/?url=http%3A%2F%2Fexample.com%2Fstream',
|
||||
sign: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return websocket proxied endpoint', async () => {
|
||||
const hass = createHASS();
|
||||
hass.config.components = ['hass_web_proxy'];
|
||||
|
||||
const result = await createProxiedEndpointIfNecessary(
|
||||
hass,
|
||||
testEndpoint,
|
||||
createProxyConfig({ dynamic: false }),
|
||||
{ websocket: true },
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
endpoint: '/api/hass_web_proxy/v0/ws?url=http%3A%2F%2Fexample.com%2Fstream',
|
||||
sign: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use live context when specified', async () => {
|
||||
const hass = createHASS();
|
||||
hass.config.components = ['hass_web_proxy'];
|
||||
|
||||
const result = await createProxiedEndpointIfNecessary(
|
||||
hass,
|
||||
testEndpoint,
|
||||
createProxyConfig({ media: false, live: true }),
|
||||
{ context: 'live' },
|
||||
);
|
||||
|
||||
expect(result.endpoint).toContain('/api/hass_web_proxy/');
|
||||
});
|
||||
|
||||
it('should default openLimit to 0 when not specified', async () => {
|
||||
const hass = createHASS();
|
||||
hass.config.components = ['hass_web_proxy'];
|
||||
|
||||
await createProxiedEndpointIfNecessary(hass, testEndpoint, createProxyConfig());
|
||||
|
||||
expect(hass.callService).toHaveBeenCalledWith(
|
||||
'hass_web_proxy',
|
||||
'create_proxied_url',
|
||||
expect.objectContaining({
|
||||
open_limit: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+14
-10
@@ -88,12 +88,18 @@ export const createConfig = (
|
||||
return advancedCameraCardConfigSchema.parse(createRawConfig(config));
|
||||
};
|
||||
|
||||
export const createCamera = (
|
||||
export const createInitializedCamera = async (
|
||||
config: CameraConfig,
|
||||
engine: CameraManagerEngine,
|
||||
capabilities?: Capabilities,
|
||||
): Camera => {
|
||||
return new Camera(config, engine, { capabilities: capabilities });
|
||||
): Promise<Camera> => {
|
||||
const camera = new Camera(config, engine);
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
...(capabilities ? { capabilityOptions: { capabilities } } : {}),
|
||||
});
|
||||
return camera;
|
||||
};
|
||||
|
||||
export const createHASS = (states?: HassEntities, user?: CurrentUser): HomeAssistant => {
|
||||
@@ -215,6 +221,10 @@ export const createStore = (
|
||||
const store = new CameraManagerStore();
|
||||
for (const cameraProps of cameras ?? []) {
|
||||
const eventCallback = cameraProps.eventCallback ?? vi.fn();
|
||||
const capabilities =
|
||||
cameraProps.capabilities === undefined
|
||||
? createCapabilities()
|
||||
: cameraProps.capabilities ?? undefined;
|
||||
const camera = new Camera(
|
||||
cameraProps.config ?? createCameraConfig(),
|
||||
cameraProps.engine ??
|
||||
@@ -222,13 +232,7 @@ export const createStore = (
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
eventCallback,
|
||||
),
|
||||
{
|
||||
capabilities:
|
||||
cameraProps.capabilities === undefined
|
||||
? createCapabilities()
|
||||
: cameraProps.capabilities ?? undefined,
|
||||
eventCallback: eventCallback,
|
||||
},
|
||||
{ eventCallback, capabilities },
|
||||
);
|
||||
camera.setID(cameraProps.cameraID);
|
||||
store.addCamera(camera);
|
||||
|
||||
+270
-3
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AudioProperties, mayHaveAudio } from '../../src/utils/audio';
|
||||
import {
|
||||
addAudioTracksMuteStateListener,
|
||||
AudioProperties,
|
||||
has2WayAudio,
|
||||
hasAudio,
|
||||
mayHaveAudio,
|
||||
} from '../../src/utils/audio';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('mayHaveAudio', () => {
|
||||
@@ -9,9 +15,9 @@ describe('mayHaveAudio', () => {
|
||||
expect(mayHaveAudio(element)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not detect audio when mozHasAudio undefined', () => {
|
||||
it('should not detect audio when mozHasAudio false', () => {
|
||||
const element: HTMLVideoElement & AudioProperties = document.createElement('video');
|
||||
element.mozHasAudio = undefined;
|
||||
element.mozHasAudio = false;
|
||||
expect(mayHaveAudio(element)).toBeFalsy();
|
||||
});
|
||||
|
||||
@@ -64,3 +70,264 @@ describe('mayHaveAudio', () => {
|
||||
expect(mayHaveAudio(element)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasAudio', () => {
|
||||
const createMockVideo = (): HTMLVideoElement & AudioProperties => {
|
||||
return {} as HTMLVideoElement & AudioProperties;
|
||||
};
|
||||
|
||||
const createMockReceiver = (trackKind: string, muted = false): RTCRtpReceiver => {
|
||||
return {
|
||||
track: { kind: trackKind, muted },
|
||||
} as unknown as RTCRtpReceiver;
|
||||
};
|
||||
|
||||
const createMockPeerConnection = (receivers: RTCRtpReceiver[]): RTCPeerConnection => {
|
||||
return {
|
||||
getReceivers: () => receivers,
|
||||
} as unknown as RTCPeerConnection;
|
||||
};
|
||||
|
||||
describe('WebRTC receiver detection', () => {
|
||||
it('should detect audio when there is an unmuted audio receiver', () => {
|
||||
const pc = createMockPeerConnection([
|
||||
createMockReceiver('video'),
|
||||
createMockReceiver('audio', false),
|
||||
]);
|
||||
expect(hasAudio(createMockVideo(), pc, '')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not detect audio when audio receiver is muted', () => {
|
||||
const pc = createMockPeerConnection([
|
||||
createMockReceiver('video'),
|
||||
createMockReceiver('audio', true),
|
||||
]);
|
||||
expect(hasAudio(createMockVideo(), pc, '')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not detect audio when only video receivers exist', () => {
|
||||
const pc = createMockPeerConnection([createMockReceiver('video')]);
|
||||
expect(hasAudio(createMockVideo(), pc, '')).toBe(false);
|
||||
});
|
||||
|
||||
it('should fall back to mayHaveAudio when no receivers yet', () => {
|
||||
const pc = createMockPeerConnection([]);
|
||||
// Empty receivers means connection not established, falls back to mayHaveAudio
|
||||
// With no properties set on video, mayHaveAudio returns true (generous default)
|
||||
expect(hasAudio(createMockVideo(), pc, '')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MSE codec detection', () => {
|
||||
it('should detect audio when mseCodecs contains mp4a', () => {
|
||||
expect(hasAudio(createMockVideo(), null, 'avc1.640029,mp4a.40.2')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect audio when mseCodecs contains opus', () => {
|
||||
expect(hasAudio(createMockVideo(), null, 'avc1.640029,opus')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect audio when mseCodecs contains flac', () => {
|
||||
expect(hasAudio(createMockVideo(), null, 'avc1.640029,flac')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not detect audio when mseCodecs contains only video codecs', () => {
|
||||
expect(hasAudio(createMockVideo(), null, 'avc1.640029,hvc1.1.6.L153.B0')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallback to mayHaveAudio', () => {
|
||||
it('should fall back to mayHaveAudio when no SDP or mseCodecs', () => {
|
||||
// With no properties set, mayHaveAudio returns true (generous default)
|
||||
expect(hasAudio(createMockVideo(), null, '')).toBe(true);
|
||||
});
|
||||
|
||||
it('should use mayHaveAudio when mozHasAudio is false', () => {
|
||||
const video = createMockVideo();
|
||||
video.mozHasAudio = false;
|
||||
expect(hasAudio(video, null, '')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('has2WayAudio', () => {
|
||||
const createMockTransceiver = (
|
||||
trackKind: string | null,
|
||||
direction: RTCRtpTransceiverDirection,
|
||||
): RTCRtpTransceiver => {
|
||||
return {
|
||||
sender: {
|
||||
track: trackKind ? { kind: trackKind } : null,
|
||||
},
|
||||
direction,
|
||||
} as unknown as RTCRtpTransceiver;
|
||||
};
|
||||
|
||||
const createMockPeerConnection = (
|
||||
transceivers: RTCRtpTransceiver[],
|
||||
): RTCPeerConnection => {
|
||||
return {
|
||||
getTransceivers: () => transceivers,
|
||||
} as unknown as RTCPeerConnection;
|
||||
};
|
||||
|
||||
it('should return false for null peer connection', () => {
|
||||
expect(has2WayAudio(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when no transceivers', () => {
|
||||
const pc = createMockPeerConnection([]);
|
||||
expect(has2WayAudio(pc)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when audio transceiver is sendonly', () => {
|
||||
const pc = createMockPeerConnection([createMockTransceiver('audio', 'sendonly')]);
|
||||
expect(has2WayAudio(pc)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when audio transceiver is sendrecv', () => {
|
||||
const pc = createMockPeerConnection([createMockTransceiver('audio', 'sendrecv')]);
|
||||
expect(has2WayAudio(pc)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when audio transceiver is recvonly', () => {
|
||||
const pc = createMockPeerConnection([createMockTransceiver('audio', 'recvonly')]);
|
||||
expect(has2WayAudio(pc)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when audio transceiver is inactive', () => {
|
||||
const pc = createMockPeerConnection([createMockTransceiver('audio', 'inactive')]);
|
||||
expect(has2WayAudio(pc)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when only video transceiver with sendonly', () => {
|
||||
const pc = createMockPeerConnection([createMockTransceiver('video', 'sendonly')]);
|
||||
expect(has2WayAudio(pc)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when transceiver has no track', () => {
|
||||
const pc = createMockPeerConnection([createMockTransceiver(null, 'sendonly')]);
|
||||
expect(has2WayAudio(pc)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when mixed transceivers include sendonly audio', () => {
|
||||
const pc = createMockPeerConnection([
|
||||
createMockTransceiver('video', 'recvonly'),
|
||||
createMockTransceiver('audio', 'recvonly'),
|
||||
createMockTransceiver('audio', 'sendonly'),
|
||||
]);
|
||||
expect(has2WayAudio(pc)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addAudioTracksMuteStateListener', () => {
|
||||
interface MockTrack {
|
||||
kind: string;
|
||||
muted: boolean;
|
||||
listeners: Map<string, Set<() => void>>;
|
||||
addEventListener: (event: string, handler: () => void) => void;
|
||||
removeEventListener: (event: string, handler: () => void) => void;
|
||||
triggerEvent: (event: string) => void;
|
||||
}
|
||||
|
||||
const createMockTrack = (kind: string, muted: boolean): MockTrack => {
|
||||
const listeners = new Map<string, Set<() => void>>();
|
||||
return {
|
||||
kind,
|
||||
muted,
|
||||
listeners,
|
||||
addEventListener: (event, handler) => {
|
||||
if (!listeners.has(event)) {
|
||||
listeners.set(event, new Set());
|
||||
}
|
||||
listeners.get(event)?.add(handler);
|
||||
},
|
||||
removeEventListener: (event, handler) => {
|
||||
listeners.get(event)?.delete(handler);
|
||||
},
|
||||
triggerEvent: (event) => {
|
||||
listeners.get(event)?.forEach((h) => h());
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createMockPeerConnection = (tracks: MockTrack[]): RTCPeerConnection => {
|
||||
return {
|
||||
getReceivers: () => tracks.map((t) => ({ track: t })),
|
||||
} as unknown as RTCPeerConnection;
|
||||
};
|
||||
|
||||
it('should return null for null peer connection', () => {
|
||||
const callback = vi.fn();
|
||||
expect(addAudioTracksMuteStateListener(null, callback)).toBe(null);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null when no audio tracks', () => {
|
||||
const callback = vi.fn();
|
||||
const pc = createMockPeerConnection([createMockTrack('video', false)]);
|
||||
expect(addAudioTracksMuteStateListener(pc, callback)).toBe(null);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call callback with true when any track unmutes', () => {
|
||||
const callback = vi.fn();
|
||||
const track1 = createMockTrack('audio', true);
|
||||
const track2 = createMockTrack('audio', true);
|
||||
const pc = createMockPeerConnection([track1, track2]);
|
||||
|
||||
addAudioTracksMuteStateListener(pc, callback);
|
||||
|
||||
// Unmute first track - now has audio
|
||||
track1.muted = false;
|
||||
track1.triggerEvent('unmute');
|
||||
expect(callback).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should call callback with false when all tracks become muted', () => {
|
||||
const callback = vi.fn();
|
||||
const track1 = createMockTrack('audio', false);
|
||||
const track2 = createMockTrack('audio', false);
|
||||
const pc = createMockPeerConnection([track1, track2]);
|
||||
|
||||
addAudioTracksMuteStateListener(pc, callback);
|
||||
|
||||
// Mute first track - still have an unmuted track, no callback yet
|
||||
track1.muted = true;
|
||||
track1.triggerEvent('mute');
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
// Mute second track - all muted now
|
||||
track2.muted = true;
|
||||
track2.triggerEvent('mute');
|
||||
expect(callback).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should not call callback if state has not changed', () => {
|
||||
const callback = vi.fn();
|
||||
const track = createMockTrack('audio', true);
|
||||
const pc = createMockPeerConnection([track]);
|
||||
|
||||
addAudioTracksMuteStateListener(pc, callback);
|
||||
|
||||
// Trigger unmute but don't actually change muted state
|
||||
track.triggerEvent('unmute');
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove listeners on cleanup', () => {
|
||||
const callback = vi.fn();
|
||||
const track = createMockTrack('audio', true);
|
||||
const pc = createMockPeerConnection([track]);
|
||||
|
||||
const cleanup = addAudioTracksMuteStateListener(pc, callback);
|
||||
cleanup?.();
|
||||
|
||||
// Change state and trigger - should not call callback
|
||||
track.muted = false;
|
||||
track.triggerEvent('unmute');
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user