fix: Improve 2-way audio detection (#2293)

- For Frigate cameras, you must be running at least [integration
v5.12.0](https://github.com/blakeblackshear/frigate-hass-integration/releases/tag/v5.12.0).
 - Closes: #2191 

Includes a significant refactor of cameras, and the introduction of a
`2-way-audio` capability that is dynamically fetched from `go2rtc`. One
gotcha is if you previously had a substream with 2-way audio, you may
need to modify

```yaml
 - camera_entity: camera.foo
    capabilities:
      disable_except:
        - substream
```

... to ...

```yaml
 - camera_entity: camera.foo
    capabilities:
      disable_except:
        - substream
        - 2-way-audio
```
This commit is contained in:
Dermot Duffy
2025-12-20 13:06:09 -08:00
committed by GitHub
parent 588ed6c98a
commit 1e821f09c6
47 changed files with 2240 additions and 958 deletions
+121 -24
View File
@@ -4,13 +4,18 @@ import { Camera } from '../../src/camera-manager/camera.js';
import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic.js';
import { CameraProxyConfig } from '../../src/camera-manager/types.js';
import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
import { liveProviderSupports2WayAudio } from '../../src/utils/live-provider.js';
import {
callStateWatcherCallback,
createCameraConfig,
createCapabilities,
createHASS,
createInitializedCamera,
createStateEntity,
} from '../test-utils.js';
vi.mock('../../src/utils/live-provider.js');
describe('Camera', () => {
it('should get config', async () => {
const config = createCameraConfig();
@@ -24,12 +29,10 @@ describe('Camera', () => {
describe('should get capabilities', async () => {
it('when populated', async () => {
const capabilities = createCapabilities();
const camera = new Camera(
const camera = await createInitializedCamera(
createCameraConfig(),
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
{
capabilities: capabilities,
},
capabilities,
);
expect(camera.getCapabilities()).toBe(capabilities);
});
@@ -72,29 +75,85 @@ describe('Camera', () => {
);
});
it('should initialize and destroy', async () => {
const camera = new Camera(
createCameraConfig({
triggers: {
entities: ['camera.foo'],
},
}),
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
{
capabilities: createCapabilities({ trigger: true }),
},
);
describe('initialize', () => {
it('should initialize and destroy', async () => {
const camera = new Camera(
createCameraConfig({
triggers: {
entities: ['camera.foo'],
},
}),
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
);
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
await camera.initialize({
stateWatcher: stateWatcher,
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
await camera.initialize({
hass: createHASS(),
stateWatcher: stateWatcher,
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
});
expect(stateWatcher.subscribe).toBeCalledWith(expect.any(Function), [
'camera.foo',
]);
await camera.destroy();
expect(stateWatcher.unsubscribe).toBeCalled();
});
expect(stateWatcher.subscribe).toBeCalledWith(expect.any(Function), ['camera.foo']);
it('should set capabilities and use go2rtc metadata endpoint', async () => {
const camera = new Camera(
createCameraConfig({
go2rtc: {
url: 'http://go2rtc',
stream: 'stream',
},
}),
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
);
await camera.destroy();
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true);
expect(stateWatcher.unsubscribe).toBeCalled();
await camera.initialize({
hass: createHASS(),
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
});
expect(liveProviderSupports2WayAudio).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
{
endpoint:
'http://go2rtc/api/streams?src=stream&video=all&audio=all&microphone',
sign: false,
},
expect.anything(),
);
expect(camera.getCapabilities()?.has('2-way-audio')).toBe(true);
});
it('should set capabilities when go2rtc metadata endpoint fails', async () => {
const camera = new Camera(
createCameraConfig({
go2rtc: {
url: 'http://go2rtc',
stream: 'stream',
},
}),
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
);
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(false);
await camera.initialize({
hass: createHASS(),
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
});
expect(camera.getCapabilities()?.has('2-way-audio')).toBe(false);
});
});
describe('should handle trigger state changes', () => {
@@ -120,14 +179,15 @@ describe('Camera', () => {
}),
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
{
capabilities: createCapabilities({ trigger: true }),
eventCallback: eventCallback,
},
);
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
await camera.initialize({
hass: createHASS(),
stateWatcher: stateWatcher,
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
});
expect(stateWatcher.subscribe).toBeCalled();
@@ -157,14 +217,15 @@ describe('Camera', () => {
}),
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
{
capabilities: createCapabilities({ trigger: false }),
eventCallback: eventCallback,
},
);
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
await camera.initialize({
hass: createHASS(),
stateWatcher: stateWatcher,
capabilityOptions: { capabilities: createCapabilities({ trigger: false }) },
});
expect(stateWatcher.subscribe).not.toBeCalled();
@@ -331,4 +392,40 @@ describe('Camera', () => {
},
);
});
describe('getEndpoints', () => {
it('should return null when no endpoints are available', () => {
const camera = new Camera(
createCameraConfig({
go2rtc: { stream: '' },
camera_entity: '',
}),
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
);
expect(camera.getEndpoints()).toBeNull();
});
it('should correctly merge endpoints', async () => {
const camera = new Camera(
createCameraConfig({
go2rtc: {
url: 'http://go2rtc',
stream: 'stream',
},
camera_entity: 'camera.foo',
}),
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
);
expect(camera.getEndpoints()).toEqual({
go2rtc: {
endpoint: 'http://go2rtc/api/ws?src=stream',
sign: false,
},
webrtcCard: {
endpoint: 'camera.foo',
},
});
});
});
});
+344 -1
View File
@@ -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',
},
+3 -8
View File
@@ -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,
+19 -23
View File
@@ -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&microphone',
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&microphone',
sign: false,
});
});
it('without configuration', () => {
expect(getGo2RTCMetadataEndpoint(createCameraConfig())).toBeNull();
});
});