Add out-of-the-box Frigate PTZ support.

This commit is contained in:
Dermot Duffy
2023-10-29 14:10:41 -07:00
parent fa43d2e439
commit e12912fa85
74 changed files with 3981 additions and 1514 deletions
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { Camera } from '../../src/camera-manager/camera.js';
import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic.js';
import { createCameraCapabilities, createCameraConfig } from '../test-utils.js';
describe('Camera', () => {
it('should get config', async () => {
const config = createCameraConfig();
const camera = new Camera(
config,
new GenericCameraManagerEngine(),
createCameraCapabilities(),
);
expect(camera.getConfig()).toBe(config);
});
it('should get capabilities', async () => {
const capabilities = createCameraCapabilities();
const camera = new Camera(
createCameraConfig(),
new GenericCameraManagerEngine(),
capabilities,
);
expect(camera.getCapabilities()).toBe(capabilities);
});
it('should get engine', async () => {
const engine = new GenericCameraManagerEngine();
const camera = new Camera(createCameraConfig(), engine, createCameraCapabilities());
expect(camera.getEngine()).toBe(engine);
});
it('should set and get id', async () => {
const config = createCameraConfig();
const camera = new Camera(
config,
new GenericCameraManagerEngine(),
createCameraCapabilities(),
);
camera.setID('foo');
expect(camera.getID()).toBe('foo');
expect(camera.getConfig().id).toBe('foo');
});
it('should throw without id', async () => {
const config = createCameraConfig();
const camera = new Camera(
config,
new GenericCameraManagerEngine(),
createCameraCapabilities(),
);
expect(() => camera.getID()).toThrowError(
'Could not determine camera id for the following ' +
"camera, may need to set 'id' parameter manually",
);
});
});
@@ -9,6 +9,7 @@ import { FrigateEvent, eventSchema } from '../../../src/camera-manager/frigate/t
import {
CameraConfig,
FrigateCardView,
PTZAction,
RawFrigateCardConfig,
} from '../../../src/config/types';
import { ViewMedia } from '../../../src/view/media';
@@ -392,3 +393,101 @@ describe('getCameraEndpoints', () => {
});
});
});
describe('executePTZAction', () => {
describe('preset', () => {
it('should reject without preset argument', () => {
const hass = createHASS();
const cameraConfig = createCameraConfig({ camera_entity: 'camera.office' });
createEngine().executePTZAction(hass, cameraConfig, 'preset');
expect(hass.callService).not.toBeCalled();
});
it('should succeed', () => {
const hass = createHASS();
const cameraConfig = createCameraConfig({ camera_entity: 'camera.office' });
createEngine().executePTZAction(hass, cameraConfig, 'preset', {
preset: 'preset-foo',
});
expect(hass.callService).toBeCalledWith('frigate', 'ptz', {
entity_id: 'camera.office',
action: 'preset',
argument: 'preset-foo',
});
});
});
describe('zoom', () => {
describe.each([['zoom_in' as const], ['zoom_out' as const]])(
'%s',
(actionName: PTZAction) => {
it('start', () => {
const hass = createHASS();
const cameraConfig = createCameraConfig({ camera_entity: 'camera.office' });
createEngine().executePTZAction(hass, cameraConfig, actionName);
expect(hass.callService).toBeCalledWith('frigate', 'ptz', {
entity_id: 'camera.office',
action: 'zoom',
argument: actionName === 'zoom_in' ? 'in' : 'out',
});
});
it('stop', () => {
const hass = createHASS();
const cameraConfig = createCameraConfig({ camera_entity: 'camera.office' });
createEngine().executePTZAction(hass, cameraConfig, actionName, {
phase: 'stop',
});
expect(hass.callService).toBeCalledWith('frigate', 'ptz', {
entity_id: 'camera.office',
action: 'stop',
});
});
},
);
});
describe('move', () => {
describe.each([
['left' as const],
['right' as const],
['up' as const],
['down' as const],
])('%s', (actionName: PTZAction) => {
it('start', () => {
const hass = createHASS();
const cameraConfig = createCameraConfig({ camera_entity: 'camera.office' });
createEngine().executePTZAction(hass, cameraConfig, actionName);
expect(hass.callService).toBeCalledWith('frigate', 'ptz', {
entity_id: 'camera.office',
action: 'move',
argument: actionName,
});
});
it('stop', () => {
const hass = createHASS();
const cameraConfig = createCameraConfig({ camera_entity: 'camera.office' });
createEngine().executePTZAction(hass, cameraConfig, actionName, {
phase: 'stop',
});
expect(hass.callService).toBeCalledWith('frigate', 'ptz', {
entity_id: 'camera.office',
action: 'stop',
});
});
});
});
});
@@ -0,0 +1,218 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
getEvents,
getEventSummary,
getPTZInfo,
getRecordingSegments,
getRecordingsSummary,
retainEvent,
} from '../../../src/camera-manager/frigate/requests';
import {
EventSummary,
eventSummarySchema,
FrigateEvent,
frigateEventsSchema,
ptzInfoSchema,
recordingSegmentsSchema,
recordingSummarySchema,
retainResultSchema,
} from '../../../src/camera-manager/frigate/types';
import { RecordingSegment } from '../../../src/camera-manager/types';
import { homeAssistantWSRequest } from '../../../src/utils/ha';
import { createFrigateEvent, createHASS } from '../../test-utils';
vi.mock('../../../src/utils/ha');
describe('frigate requests', () => {
afterEach(() => {
vi.resetAllMocks();
});
it('should get recordings summary', async () => {
const recordingSummary = {
events: 0,
hours: [],
day: new Date(),
};
const hass = createHASS();
vi.mocked(homeAssistantWSRequest).mockResolvedValue(recordingSummary);
expect(await getRecordingsSummary(hass, 'clientID', 'camera.office')).toBe(
recordingSummary,
);
expect(homeAssistantWSRequest).toBeCalledWith(
hass,
recordingSummarySchema,
expect.objectContaining({
type: 'frigate/recordings/summary',
instance_id: 'clientID',
camera: 'camera.office',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}),
true,
);
});
it('should get recordings segments', async () => {
const recordingSegments: RecordingSegment[] = [
{
start_time: 0,
end_time: 1,
id: 'foo',
},
];
const hass = createHASS();
vi.mocked(homeAssistantWSRequest).mockResolvedValue(recordingSegments);
expect(
await getRecordingSegments(hass, {
instance_id: 'clientID',
camera: 'camera.office',
after: 1,
before: 0,
}),
).toBe(recordingSegments);
expect(homeAssistantWSRequest).toBeCalledWith(
hass,
recordingSegmentsSchema,
expect.objectContaining({
type: 'frigate/recordings/get',
instance_id: 'clientID',
camera: 'camera.office',
after: 1,
before: 0,
}),
true,
);
});
describe('should retain event', async () => {
it('successfully', async () => {
vi.mocked(homeAssistantWSRequest).mockResolvedValue({
success: true,
message: 'success',
});
const hass = createHASS();
retainEvent(hass, 'clientID', 'eventID', true);
expect(homeAssistantWSRequest).toBeCalledWith(
hass,
retainResultSchema,
expect.objectContaining({
type: 'frigate/event/retain',
instance_id: 'clientID',
event_id: 'eventID',
retain: true,
}),
true,
);
});
it('unsuccessfully', async () => {
vi.mocked(homeAssistantWSRequest).mockResolvedValue({
success: false,
message: 'failed',
});
const hass = createHASS();
await expect(retainEvent(hass, 'clientID', 'eventID', true)).rejects.toThrowError(
/Could not retain event/,
);
expect(homeAssistantWSRequest).toBeCalledWith(
hass,
retainResultSchema,
expect.objectContaining({
type: 'frigate/event/retain',
instance_id: 'clientID',
event_id: 'eventID',
retain: true,
}),
true,
);
});
});
it('should get events', async () => {
const events: FrigateEvent[] = [createFrigateEvent()];
const hass = createHASS();
vi.mocked(homeAssistantWSRequest).mockResolvedValue(events);
expect(
await getEvents(hass, {
instance_id: 'clientID',
cameras: ['camera.office'],
labels: ['person'],
zones: ['zone'],
after: 0,
before: 1,
limit: 10,
has_clip: true,
has_snapshot: true,
favorites: true,
}),
).toBe(events);
expect(homeAssistantWSRequest).toBeCalledWith(
hass,
frigateEventsSchema,
expect.objectContaining({
type: 'frigate/events/get',
instance_id: 'clientID',
cameras: ['camera.office'],
labels: ['person'],
zones: ['zone'],
after: 0,
before: 1,
limit: 10,
has_clip: true,
has_snapshot: true,
favorites: true,
}),
true,
);
});
it('should get event summary', async () => {
const eventSummary: EventSummary = [
{
camera: 'camera.office',
day: '2023-10-29',
label: 'person',
sub_label: null,
zones: ['door'],
},
];
const hass = createHASS();
vi.mocked(homeAssistantWSRequest).mockResolvedValue(eventSummary);
expect(await getEventSummary(hass, 'clientID')).toBe(eventSummary);
expect(homeAssistantWSRequest).toBeCalledWith(
hass,
eventSummarySchema,
expect.objectContaining({
type: 'frigate/events/summary',
instance_id: 'clientID',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}),
true,
);
});
it('should get PTZ info', async () => {
const ptzInfo = [
{
name: 'camera.office',
features: ['zoom', 'zoom-r'],
presets: ['preset01', 'preset02'],
},
];
const hass = createHASS();
vi.mocked(homeAssistantWSRequest).mockResolvedValue(ptzInfo);
expect(await getPTZInfo(hass, 'clientID', 'camera.office')).toBe(ptzInfo);
expect(homeAssistantWSRequest).toBeCalledWith(
hass,
ptzInfoSchema,
expect.objectContaining({
type: 'frigate/ptz/info',
instance_id: 'clientID',
camera: 'camera.office',
}),
true,
);
});
});
@@ -9,6 +9,7 @@ import {
createCameraConfig,
createHASS,
createStateEntity,
createStore,
} from '../../test-utils';
const createEngine = (): GenericCameraManagerEngine => {
@@ -26,19 +27,29 @@ describe('GenericCameraManagerEngine', () => {
it('should initialize camera', async () => {
const config = createGenericCameraConfig();
expect(
await createEngine().initializeCamera(
createHASS(),
mock<EntityRegistryManager>(),
config,
),
).toEqual(config);
const camera = await createEngine().initializeCamera(
createHASS(),
mock<EntityRegistryManager>(),
config,
);
expect(camera.getConfig()).toEqual(config);
expect(camera.getCapabilities()).toEqual({
canFavoriteEvents: false,
canFavoriteRecordings: false,
canSeek: false,
supportsClips: false,
supportsRecordings: false,
supportsSnapshots: false,
supportsTimeline: false,
});
});
it('should generate default event query', () => {
const engine = createEngine();
expect(
createEngine().generateDefaultEventQuery(
new Map([['camera-1', createGenericCameraConfig()]]),
engine.generateDefaultEventQuery(
createStore([{ cameraID: 'camera-1', engine: engine }]),
new Set(['camera-1']),
{},
),
@@ -46,9 +57,10 @@ describe('GenericCameraManagerEngine', () => {
});
it('should generate default recording query', () => {
const engine = createEngine();
expect(
createEngine().generateDefaultRecordingQuery(
new Map([['camera-1', createGenericCameraConfig()]]),
engine.generateDefaultRecordingQuery(
createStore([{ cameraID: 'camera-1', engine: engine }]),
new Set(['camera-1']),
{},
),
@@ -56,9 +68,10 @@ describe('GenericCameraManagerEngine', () => {
});
it('should generate default recording segments query', () => {
const engine = createEngine();
expect(
createEngine().generateDefaultRecordingSegmentsQuery(
new Map([['camera-1', createGenericCameraConfig()]]),
engine.generateDefaultRecordingSegmentsQuery(
createStore([{ cameraID: 'camera-1', engine: engine }]),
new Set(['camera-1']),
{},
),
@@ -66,30 +79,33 @@ describe('GenericCameraManagerEngine', () => {
});
it('should get events', async () => {
const engine = createEngine();
expect(
await createEngine().getEvents(
await engine.getEvents(
createHASS(),
new Map([['camera-1', createGenericCameraConfig()]]),
createStore([{ cameraID: 'camera-1', engine: engine }]),
{ type: QueryType.Event, cameraIDs: new Set(['camera-1']) },
),
).toBeNull();
});
it('should get recordings', async () => {
const engine = createEngine();
expect(
await createEngine().getRecordings(
await engine.getRecordings(
createHASS(),
new Map([['camera-1', createGenericCameraConfig()]]),
createStore([{ cameraID: 'camera-1', engine: engine }]),
{ type: QueryType.Recording, cameraIDs: new Set(['camera-1']) },
),
).toBeNull();
});
it('should get recording segments', async () => {
const engine = createEngine();
expect(
await createEngine().getRecordingSegments(
await engine.getRecordingSegments(
createHASS(),
new Map([['camera-1', createGenericCameraConfig()]]),
createStore([{ cameraID: 'camera-1', engine: engine }]),
{
type: QueryType.RecordingSegments,
cameraIDs: new Set(['camera-1']),
@@ -101,10 +117,11 @@ describe('GenericCameraManagerEngine', () => {
});
it('should generate media from events', async () => {
const engine = createEngine();
expect(
createEngine().generateMediaFromEvents(
engine.generateMediaFromEvents(
createHASS(),
new Map([['camera-1', createGenericCameraConfig()]]),
createStore([{ cameraID: 'camera-1', engine: engine }]),
{
type: QueryType.Event,
cameraIDs: new Set(['camera-1']),
@@ -118,10 +135,11 @@ describe('GenericCameraManagerEngine', () => {
});
it('should generate media from recordings', async () => {
const engine = createEngine();
expect(
createEngine().generateMediaFromRecordings(
engine.generateMediaFromRecordings(
createHASS(),
new Map([['camera-1', createGenericCameraConfig()]]),
createStore([{ cameraID: 'camera-1', engine: engine }]),
{
type: QueryType.Recording,
cameraIDs: new Set(['camera-1']),
@@ -167,10 +185,11 @@ describe('GenericCameraManagerEngine', () => {
});
it('should get media seek time', async () => {
const engine = createEngine();
expect(
await createEngine().getMediaSeekTime(
await engine.getMediaSeekTime(
createHASS(),
new Map([['camera-1', createGenericCameraConfig()]]),
createStore([{ cameraID: 'camera-1', engine: engine }]),
new TestViewMedia(),
new Date(),
),
@@ -178,10 +197,11 @@ describe('GenericCameraManagerEngine', () => {
});
it('should get media metadata', async () => {
const engine = createEngine();
expect(
await createEngine().getMediaMetadata(
await engine.getMediaMetadata(
createHASS(),
new Map([['camera-1', createGenericCameraConfig()]]),
createStore([{ cameraID: 'camera-1', engine: engine }]),
{ type: QueryType.MediaMetadata, cameraIDs: new Set(['camera-1']) },
),
).toBeNull();
@@ -264,18 +284,6 @@ describe('GenericCameraManagerEngine', () => {
});
});
it('should get camera capabilities metadata', async () => {
expect(createEngine().getCameraCapabilities(createGenericCameraConfig())).toEqual({
canFavoriteEvents: false,
canFavoriteRecordings: false,
canSeek: false,
supportsClips: false,
supportsRecordings: false,
supportsSnapshots: false,
supportsTimeline: false,
});
});
it('should get media capabilities', () => {
expect(createEngine().getMediaCapabilities(new TestViewMedia())).toBeNull();
});
@@ -303,4 +311,10 @@ describe('GenericCameraManagerEngine', () => {
});
});
});
it('should execute PTZ action', () => {
const hass = createHASS();
createEngine().executePTZAction(hass, createCameraConfig(), 'left');
expect(hass.callService).not.toBeCalled();
});
});
+140 -45
View File
@@ -1,15 +1,23 @@
import { describe, expect, it } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { Camera } from '../../src/camera-manager/camera.js';
import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js';
import { CameraManagerStore } from '../../src/camera-manager/store.js';
import { Engine } from '../../src/camera-manager/types.js';
import { EntityRegistryManager } from '../../src/utils/ha/entity-registry/index.js';
import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media.js';
import { TestViewMedia, createCameraConfig } from '../test-utils.js';
import {
TestViewMedia,
createCameraCapabilities,
createCameraConfig,
} from '../test-utils.js';
describe('CameraManagerStore', async () => {
const config_visible = createCameraConfig();
const config_hidden = createCameraConfig({
const configVisible = createCameraConfig({
id: 'camera-visible',
});
const configHidden = createCameraConfig({
id: 'camera-hidden',
hide: true,
});
@@ -21,71 +29,100 @@ describe('CameraManagerStore', async () => {
const engineGeneric = await engineFactory.createEngine(Engine.Generic);
const engineFrigate = await engineFactory.createEngine(Engine.Frigate);
const setupStore = async (): Promise<CameraManagerStore> => {
const setupStore = (): CameraManagerStore => {
const store = new CameraManagerStore();
store.addCamera('camera-visible', config_visible, engineGeneric);
store.addCamera('camera-hidden', config_hidden, engineFrigate);
store.addCamera(
new Camera(configVisible, engineGeneric, createCameraCapabilities()),
);
store.addCamera(new Camera(configHidden, engineFrigate, createCameraCapabilities()));
return store;
};
it('getCameraConfig', async () => {
const store = await setupStore();
expect(store.getCameraConfig('camera-visible')).toBe(config_visible);
expect(store.getCameraConfig('camera-hidden')).toBe(config_hidden);
const store = setupStore();
expect(store.getCameraConfig('camera-visible')).toBe(configVisible);
expect(store.getCameraConfig('camera-hidden')).toBe(configHidden);
expect(store.getCameraConfig('camera-not-exist')).toBeNull();
});
it('hasCameraID', async () => {
const store = await setupStore();
const store = setupStore();
expect(store.hasCameraID('camera-visible')).toBeTruthy();
expect(store.hasCameraID('camera-hidden')).toBeTruthy();
});
it('hasVisibleCameraID', async () => {
const store = await setupStore();
expect(store.hasVisibleCameraID('camera-visible')).toBeTruthy();
expect(store.hasVisibleCameraID('camera-hidden')).toBeFalsy();
});
it('getCameraCount', async () => {
const store = await setupStore();
const store = setupStore();
expect(store.getCameraCount()).toBe(2);
});
it('getVisibleCameraCount', async () => {
const store = await setupStore();
const store = setupStore();
expect(store.getVisibleCameraCount()).toBe(1);
});
it('getCameras', async () => {
const store = await setupStore();
expect(store.getCameras()).toEqual(
new Map([
['camera-visible', config_visible],
['camera-hidden', config_hidden],
]),
);
describe('getCamera', async () => {
it('present', async () => {
const store = setupStore();
expect(store.getCamera('camera-visible')?.getConfig()).toEqual(configVisible);
});
it('absent', async () => {
const store = setupStore();
expect(store.getCamera('not-a-camera')).toBeNull();
});
});
it('getVisibleCameras', async () => {
const store = await setupStore();
expect(store.getVisibleCameras()).toEqual(
new Map([['camera-visible', config_visible]]),
);
describe('getCameraConfigs', async () => {
it('all', async () => {
const store = setupStore();
expect([...store.getCameraConfigs()]).toEqual([configVisible, configHidden]);
});
it('named', async () => {
const store = setupStore();
expect([...store.getCameraConfigs(['camera-visible', 'not-a-camera'])]).toEqual([
configVisible,
]);
});
});
describe('getCameraConfigEntries', async () => {
it('all', async () => {
const store = setupStore();
expect([...store.getCameraConfigEntries()]).toEqual([
['camera-visible', configVisible],
['camera-hidden', configHidden],
]);
});
it('named', async () => {
const store = setupStore();
expect([
...store.getCameraConfigEntries(['camera-visible', 'not-a-camera']),
]).toEqual([['camera-visible', configVisible]]);
});
});
it('getCameras', async () => {
const store = setupStore();
expect([...store.getCameras().keys()]).toEqual(['camera-visible', 'camera-hidden']);
expect(store.getCameras().get('camera-visible')?.getConfig()).toEqual(configVisible);
expect(store.getCameras().get('camera-hidden')?.getConfig()).toEqual(configHidden);
});
it('getCameraIDs', async () => {
const store = await setupStore();
const store = setupStore();
expect(store.getCameraIDs()).toEqual(new Set(['camera-visible', 'camera-hidden']));
});
it('getVisibleCameraIDs', async () => {
const store = await setupStore();
const store = setupStore();
expect(store.getVisibleCameraIDs()).toEqual(new Set(['camera-visible']));
});
it('reset', async () => {
const store = await setupStore();
const store = setupStore();
store.reset();
@@ -94,24 +131,24 @@ describe('CameraManagerStore', async () => {
});
it('getCameraConfigForMedia', async () => {
const store = await setupStore();
const store = setupStore();
const media_1 = new TestViewMedia({ cameraID: 'camera-visible' });
expect(store.getCameraConfigForMedia(media_1)).toBe(config_visible);
expect(store.getCameraConfigForMedia(media_1)).toBe(configVisible);
const media_2 = new TestViewMedia({ cameraID: 'camera-not-exist' });
expect(store.getCameraConfigForMedia(media_2)).toBeNull();
});
it('getEngineOfType', async () => {
const store = await setupStore();
const store = setupStore();
expect(store.getEngineOfType(Engine.Generic)).toBe(engineGeneric);
expect(store.getEngineOfType(Engine.Frigate)).toBe(engineFrigate);
expect(store.getEngineOfType(Engine.MotionEye)).toBeNull();
});
it('getEngineForCameraID', async () => {
const store = await setupStore();
const store = setupStore();
expect(store.getEngineForCameraID('camera-visible')).toBe(engineGeneric);
expect(store.getEngineForCameraID('camera-hidden')).toBe(engineFrigate);
expect(store.getEngineForCameraID('camera-not-exist')).toBeNull();
@@ -119,13 +156,23 @@ describe('CameraManagerStore', async () => {
describe('getEnginesForCameraIDs', async () => {
it('empty input', async () => {
const store = await setupStore();
const store = setupStore();
expect(store.getEnginesForCameraIDs(new Set())).toBeNull();
});
it('multiple cameras', async () => {
const store = await setupStore();
store.addCamera('camera-visible2', config_visible, engineGeneric);
const store = setupStore();
store.addCamera(
new Camera(
{
...configVisible,
id: 'camera-visible2',
},
engineGeneric,
createCameraCapabilities(),
),
);
expect(
store.getEnginesForCameraIDs(
new Set([
@@ -145,13 +192,61 @@ describe('CameraManagerStore', async () => {
});
it('getEngineForMedia', async () => {
const store = await setupStore();
const store = setupStore();
const media = new TestViewMedia({ cameraID: 'camera-visible' });
expect(store.getEngineForMedia(media)).toBe(engineGeneric);
});
it('getAllEngines', async () => {
const store = await setupStore();
expect(store.getAllEngines()).toEqual([engineGeneric, engineFrigate]);
describe('getAllDependentCameras', () => {
it('should return dependent cameras', () => {
const store = new CameraManagerStore();
store.addCamera(
new Camera(
createCameraConfig({
id: 'one',
dependencies: {
cameras: ['two', 'three'],
},
}),
engineGeneric,
createCameraCapabilities(),
),
);
store.addCamera(
new Camera(
createCameraConfig({
id: 'two',
}),
engineGeneric,
createCameraCapabilities(),
),
);
expect(store.getAllDependentCameras('one')).toEqual(new Set(['one', 'two']));
});
it('should return all cameras', () => {
const store = new CameraManagerStore();
store.addCamera(
new Camera(
createCameraConfig({
id: 'one',
dependencies: {
all_cameras: true,
},
}),
engineGeneric,
createCameraCapabilities(),
),
);
store.addCamera(
new Camera(
createCameraConfig({
id: 'two',
}),
engineGeneric,
createCameraCapabilities(),
),
);
expect(store.getAllDependentCameras('one')).toEqual(new Set(['one', 'two']));
});
});
});