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
+53 -50
View File
@@ -4,7 +4,11 @@ import { mock } from 'vitest-mock-extended';
import { actionSchema } from '../../src/config/types';
import {
convertActionToFrigateCardCustomAction,
createFrigateCardCustomAction,
createFrigateCardCameraAction,
createFrigateCardDisplayModeAction,
createFrigateCardMediaPlayerAction,
createFrigateCardShowPTZAction,
createFrigateCardSimpleAction,
frigateCardHandleAction,
frigateCardHandleActionConfig,
frigateCardHasAction,
@@ -37,13 +41,24 @@ describe('convertActionToFrigateCardCustomAction', () => {
});
});
describe('createFrigateCardCustomAction', () => {
it('should create camera_select', () => {
describe('createFrigateCardSimpleAction', () => {
it('should create general action', () => {
expect(
createFrigateCardCustomAction('camera_select', {
camera: 'camera',
createFrigateCardSimpleAction('clips', {
cardID: 'card_id',
}),
).toEqual({
action: 'fire-dom-event',
frigate_card_action: 'clips',
card_id: 'card_id',
});
});
});
describe('createFrigateCardCameraAction', () => {
it('should create camera_select', () => {
expect(
createFrigateCardCameraAction('camera_select', 'camera', { cardID: 'card_id' }),
).toEqual({
action: 'fire-dom-event',
camera: 'camera',
@@ -51,16 +66,12 @@ describe('createFrigateCardCustomAction', () => {
card_id: 'card_id',
});
});
});
it('should not create camera_select without camera', () => {
expect(createFrigateCardCustomAction('camera_select')).toBeNull();
});
describe('createFrigateCardMediaPlayerAction', () => {
it('should create media_player', () => {
expect(
createFrigateCardCustomAction('media_player', {
media_player: 'device',
media_player_action: 'play',
createFrigateCardMediaPlayerAction('device', 'play', {
cardID: 'card_id',
}),
).toEqual({
@@ -71,37 +82,12 @@ describe('createFrigateCardCustomAction', () => {
card_id: 'card_id',
});
});
});
it('should not create media_player without player or action', () => {
expect(
createFrigateCardCustomAction('media_player', {
media_player_action: 'play',
}),
).toBeNull();
expect(
createFrigateCardCustomAction('media_player', {
media_player: 'device',
}),
).toBeNull();
});
it('should create general action', () => {
expect(
createFrigateCardCustomAction('clips', {
cardID: 'card_id',
}),
).toEqual({
action: 'fire-dom-event',
frigate_card_action: 'clips',
card_id: 'card_id',
});
});
describe('createFrigateCardDisplayModeAction', () => {
it('should create display mode action', () => {
expect(
createFrigateCardCustomAction('display_mode_select', {
display_mode: 'grid',
createFrigateCardDisplayModeAction('grid', {
cardID: 'card_id',
}),
).toEqual({
@@ -111,9 +97,20 @@ describe('createFrigateCardCustomAction', () => {
card_id: 'card_id',
});
});
});
it('should not create display mode action without display mode', () => {
expect(createFrigateCardCustomAction('display_mode_select')).toBeNull();
describe('createFrigateCardShowPTZAction', () => {
it('should create show PTZ action', () => {
expect(
createFrigateCardShowPTZAction(true, {
cardID: 'card_id',
}),
).toEqual({
action: 'fire-dom-event',
frigate_card_action: 'show_ptz',
show_ptz: true,
card_id: 'card_id',
});
});
});
@@ -124,13 +121,13 @@ describe('getActionConfigGivenAction', () => {
});
it('should not handle undefined arguments', () => {
expect(getActionConfigGivenAction()).toBeUndefined();
expect(getActionConfigGivenAction()).toBeNull();
});
it('should not handle unknown interactions', () => {
expect(
getActionConfigGivenAction('triple_poke', { triple_poke_action: action }),
).toBeUndefined();
).toBeNull();
});
it('should handle tap actions', () => {
@@ -178,17 +175,23 @@ describe('frigateCardHandleActionConfig', () => {
});
it('should handle simple case', () => {
frigateCardHandleActionConfig(element, createHASS(), {}, 'tap', action);
const hass = createHASS();
frigateCardHandleActionConfig(element, hass, {}, 'tap', action);
expect(handleActionConfig).toBeCalled();
expect(handleActionConfig).toBeCalledWith(element, hass, {}, action);
});
it('should handle array case', () => {
frigateCardHandleActionConfig(element, createHASS(), {}, 'tap', [
action,
action,
action,
]);
const hass = createHASS();
frigateCardHandleActionConfig(element, hass, {}, 'tap', [action, action, action]);
expect(handleActionConfig).toBeCalledTimes(3);
expect(handleActionConfig).toBeCalledWith(element, hass, {}, action);
});
it('should handle null case', () => {
const hass = createHASS();
frigateCardHandleActionConfig(element, hass, {}, 'tap', null);
expect(handleActionConfig).toBeCalledWith(element, hass, {}, undefined);
});
});
+5 -53
View File
@@ -1,11 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CameraManager } from '../../src/camera-manager/manager.js';
import { CameraConfigs } from '../../src/camera-manager/types.js';
import { getAllDependentCameras, getCameraID } from '../../src/utils/camera.js';
import { createCameraConfig, createCameraManager } from '../test-utils.js';
vi.mock('../../src/camera-manager/manager.js');
import { getCameraID } from '../../src/utils/camera.js';
import { createCameraConfig } from '../test-utils.js';
describe('getCameraID', () => {
it('should get camera id with id', () => {
@@ -25,7 +20,9 @@ describe('getCameraID', () => {
expect(getCameraID(config)).toBe('foo');
});
it('should get camera id with go2rtc url and stream', () => {
const config = createCameraConfig({ go2rtc: { url: 'https://foo', stream: 'office' } });
const config = createCameraConfig({
go2rtc: { url: 'https://foo', stream: 'office' },
});
expect(getCameraID(config)).toBe('https://foo#office');
});
it('should get camera id with frigate camera_name', () => {
@@ -39,48 +36,3 @@ describe('getCameraID', () => {
expect(getCameraID(config)).toBe('');
});
});
describe('getAllDependentCameras', () => {
it('should return null without cameraManager', () => {
expect(getAllDependentCameras()).toBeNull();
});
it('should return null without cameraID', () => {
expect(getAllDependentCameras(mock<CameraManager>())).toBeNull();
});
it('should return dependent cameras', () => {
const cameraConfigs: CameraConfigs = new Map([
[
'one',
createCameraConfig({
dependencies: {
cameras: ['two', 'three'],
},
}),
],
['two', createCameraConfig({})],
]);
const cameraManager = createCameraManager({ configs: cameraConfigs });
expect(getAllDependentCameras(cameraManager, 'one')).toEqual(
new Set(['one', 'two']),
);
});
it('should return all cameras', () => {
const cameraConfigs: CameraConfigs = new Map([
[
'one',
createCameraConfig({
dependencies: {
all_cameras: true,
},
}),
],
['two', createCameraConfig({})],
]);
const cameraManager = createCameraManager({ configs: cameraConfigs });
expect(getAllDependentCameras(cameraManager, 'one')).toEqual(
new Set(['one', 'two']),
);
});
});
-1
View File
@@ -13,7 +13,6 @@ vi.mock('../../package.json', () => ({
gitDate: 'Wed, 6 Sep 2023 21:27:28 -0700',
},
}));
vi.mock('../../src/camera-manager/manager.js');
vi.mock('../../src/utils/ha');
vi.mock('../../src/localize/localize.js');
vi.mock('../../src/utils/ha/device-registry');
+4 -6
View File
@@ -1,12 +1,10 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CameraManager } from '../../src/camera-manager/manager.js';
import { downloadMedia, downloadURL } from '../../src/utils/download';
import { homeAssistantSignPath } from '../../src/utils/ha';
import { ViewMedia } from '../../src/view/media';
import { createCameraManager, createHASS } from '../test-utils';
vi.mock('../../src/camera-manager/manager.js');
vi.mock('../../src/utils/ha');
const media = new ViewMedia('clip', 'camera-1');
@@ -73,7 +71,7 @@ describe('downloadMedia', () => {
it('should throw error when no media', () => {
const cameraManager = createCameraManager();
mock<CameraManager>(cameraManager).getMediaDownloadPath.mockResolvedValue(null);
vi.mocked(cameraManager.getMediaDownloadPath).mockResolvedValue(null);
expect(downloadMedia(createHASS(), cameraManager, media)).rejects.toThrow(
/No media to download/,
@@ -84,7 +82,7 @@ describe('downloadMedia', () => {
vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
const cameraManager = createCameraManager();
mock<CameraManager>(cameraManager).getMediaDownloadPath.mockResolvedValue({
vi.mocked(cameraManager).getMediaDownloadPath.mockResolvedValue({
sign: true,
endpoint: 'foo',
});
@@ -98,7 +96,7 @@ describe('downloadMedia', () => {
it('should download media', async () => {
const cameraManager = createCameraManager();
mock<CameraManager>(cameraManager).getMediaDownloadPath.mockResolvedValue({
vi.mocked(cameraManager).getMediaDownloadPath.mockResolvedValue({
sign: true,
endpoint: 'foo',
});
@@ -111,7 +109,7 @@ describe('downloadMedia', () => {
it('should download media without signing', async () => {
const cameraManager = createCameraManager();
mock<CameraManager>(cameraManager).getMediaDownloadPath.mockResolvedValue({
vi.mocked(cameraManager).getMediaDownloadPath.mockResolvedValue({
sign: false,
endpoint: 'https://foo/',
});
+107 -33
View File
@@ -1,7 +1,7 @@
import add from 'date-fns/add';
import sub from 'date-fns/sub';
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import { CameraConfigs } from '../../src/camera-manager/types';
import { QueryType } from '../../src/camera-manager/types';
import {
changeViewToRecentEventsForCameraAndDependents,
changeViewToRecentRecordingForCameraAndDependents,
@@ -14,12 +14,11 @@ import { EventMediaQueries } from '../../src/view/media-queries';
import {
createCameraManager,
createPerformanceConfig,
createStore,
createView,
TestViewMedia,
} from '../test-utils';
vi.mock('../../src/camera-manager/manager.js');
const createElementListenForView = (): {
element: HTMLElement;
viewHandler: Mock<any, any>;
@@ -66,11 +65,10 @@ describe('changeViewToRecentEventsForCameraAndDependents', () => {
it('should do nothing without camera config for selected camera', async () => {
const elementHandler = createElementListenForView();
const cameraManager = createCameraManager({ configs: new Map() });
await changeViewToRecentEventsForCameraAndDependents(
elementHandler.element,
cameraManager,
createCameraManager(),
{},
createView(),
);
@@ -79,11 +77,10 @@ describe('changeViewToRecentEventsForCameraAndDependents', () => {
it('should do nothing without camera configs for all cameras', async () => {
const elementHandler = createElementListenForView();
const cameraManager = createCameraManager({ configs: new Map() });
await changeViewToRecentEventsForCameraAndDependents(
elementHandler.element,
cameraManager,
createCameraManager(),
{},
createView(),
{
@@ -113,6 +110,19 @@ describe('changeViewToRecentEventsForCameraAndDependents', () => {
it('should dispatch new view on success', async () => {
const elementHandler = createElementListenForView();
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(
createStore([
{
cameraID: 'camera',
},
]),
);
vi.mocked(cameraManager.generateDefaultEventQueries).mockReturnValue([
{
type: QueryType.Event,
cameraIDs: new Set(['camera']),
},
]);
const mediaArray = [new ViewMedia('clip', 'camera')];
vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray);
@@ -136,6 +146,19 @@ describe('changeViewToRecentEventsForCameraAndDependents', () => {
const elementHandler = createElementListenForView();
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(
createStore([
{
cameraID: 'camera',
},
]),
);
vi.mocked(cameraManager.generateDefaultEventQueries).mockReturnValue([
{
type: QueryType.Event,
cameraIDs: new Set(['camera']),
},
]);
vi.mocked(cameraManager.executeMediaQueries).mockRejectedValue(new Error());
await changeViewToRecentEventsForCameraAndDependents(
@@ -150,6 +173,13 @@ describe('changeViewToRecentEventsForCameraAndDependents', () => {
it('should respect media chunk size', async () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(
createStore([
{
cameraID: 'camera',
},
]),
);
await changeViewToRecentEventsForCameraAndDependents(
createElementListenForView().element,
@@ -178,6 +208,13 @@ describe('changeViewToRecentEventsForCameraAndDependents', () => {
['clips' as const, 'hasClip'],
])('%s', async (mediaType, queryParameter) => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(
createStore([
{
cameraID: 'camera',
},
]),
);
await changeViewToRecentEventsForCameraAndDependents(
createElementListenForView().element,
@@ -206,12 +243,9 @@ describe('executeMediaQueryForView', () => {
});
it('should not execute empty queries', async () => {
const cameraConfigs: CameraConfigs = new Map();
const cameraManager = createCameraManager({ configs: cameraConfigs });
expect(
await executeMediaQueryForView(
cameraManager,
createCameraManager(),
createView(),
new EventMediaQueries(),
),
@@ -219,24 +253,25 @@ describe('executeMediaQueryForView', () => {
});
it('should throw on failure', async () => {
const cameraConfigs: CameraConfigs = new Map();
const cameraManager = createCameraManager({ configs: cameraConfigs });
const cameraManager = createCameraManager();
vi.mocked(cameraManager.executeMediaQueries).mockRejectedValue(new Error());
await expect(
executeMediaQueryForView(
cameraManager,
createView(),
new EventMediaQueries(
cameraManager.generateDefaultEventQueries('camera') ?? undefined,
),
new EventMediaQueries([
{
type: QueryType.Event,
cameraIDs: new Set('camera'),
},
]),
),
).rejects.toThrowError();
});
it('should select time-based result', async () => {
const cameraConfigs: CameraConfigs = new Map();
const cameraManager = createCameraManager({ configs: cameraConfigs });
const cameraManager = createCameraManager();
const now = new Date();
const mediaArray = [
@@ -249,9 +284,12 @@ describe('executeMediaQueryForView', () => {
const view = await executeMediaQueryForView(
cameraManager,
createView(),
new EventMediaQueries(
cameraManager.generateDefaultEventQueries('camera') ?? undefined,
),
new EventMediaQueries([
{
type: QueryType.Event,
cameraIDs: new Set('camera'),
},
]),
{
select: 'time',
targetTime: add(now, { seconds: 30 }),
@@ -264,8 +302,7 @@ describe('executeMediaQueryForView', () => {
});
it('should select nothing when time-based selection does not match', async () => {
const cameraConfigs: CameraConfigs = new Map();
const cameraManager = createCameraManager({ configs: cameraConfigs });
const cameraManager = createCameraManager();
const now = new Date();
const mediaArray = [
@@ -279,9 +316,12 @@ describe('executeMediaQueryForView', () => {
const view = await executeMediaQueryForView(
cameraManager,
createView(),
new EventMediaQueries(
cameraManager.generateDefaultEventQueries('camera') ?? undefined,
),
new EventMediaQueries([
{
type: QueryType.Event,
cameraIDs: new Set('camera'),
},
]),
{
select: 'time',
targetTime: sub(now, { seconds: 30 }),
@@ -292,6 +332,24 @@ describe('executeMediaQueryForView', () => {
expect(view?.queryResults?.getSelectedIndex()).toBe(2);
expect(view?.queryResults?.getResults()).toBe(mediaArray);
});
it('should select nothing when query returns null', async () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(null);
expect(
await executeMediaQueryForView(
cameraManager,
createView(),
new EventMediaQueries([
{
type: QueryType.Event,
cameraIDs: new Set('camera'),
},
]),
),
).toBeNull();
});
});
// @vitest-environment jsdom
@@ -302,11 +360,10 @@ describe('changeViewToRecentRecordingForCameraAndDependents', () => {
it('should do nothing without camera config for selected camera', async () => {
const elementHandler = createElementListenForView();
const cameraManager = createCameraManager({ configs: new Map() });
await changeViewToRecentRecordingForCameraAndDependents(
elementHandler.element,
cameraManager,
createCameraManager(),
{},
createView(),
);
@@ -315,11 +372,10 @@ describe('changeViewToRecentRecordingForCameraAndDependents', () => {
it('should do nothing without camera configs for all cameras', async () => {
const elementHandler = createElementListenForView();
const cameraManager = createCameraManager({ configs: new Map() });
await changeViewToRecentRecordingForCameraAndDependents(
elementHandler.element,
cameraManager,
createCameraManager(),
{},
createView(),
{
@@ -346,9 +402,22 @@ describe('changeViewToRecentRecordingForCameraAndDependents', () => {
it('should dispatch new view on success', async () => {
const elementHandler = createElementListenForView();
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(
createStore([
{
cameraID: 'camera',
},
]),
);
const mediaArray = [new ViewMedia('recording', 'camera')];
vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray);
vi.mocked(cameraManager.generateDefaultRecordingQueries).mockReturnValue([
{
type: QueryType.Recording,
cameraIDs: new Set(['camera']),
},
]);
await changeViewToRecentRecordingForCameraAndDependents(
elementHandler.element,
@@ -366,6 +435,13 @@ describe('changeViewToRecentRecordingForCameraAndDependents', () => {
it('should respect media chunk size', async () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(
createStore([
{
cameraID: 'camera',
},
]),
);
await changeViewToRecentRecordingForCameraAndDependents(
createElementListenForView().element,
@@ -402,9 +478,7 @@ describe('executeMediaQueryForViewWithErrorDispatching', () => {
elementHandler.element,
cameraManager,
createView(),
new EventMediaQueries(
cameraManager.generateDefaultEventQueries('camera') ?? undefined,
),
new EventMediaQueries([{ type: QueryType.Event, cameraIDs: new Set(['camera']) }]),
);
expect(elementHandler.viewHandler).not.toBeCalled();
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { FrigateCardPTZConfig, frigateCardPTZSchema } from '../../src/config/types';
import { hasUsablePTZ } from '../../src/utils/ptz';
import { createCameraCapabilities } from '../test-utils';
const createPTZConfig = (
config?: Partial<FrigateCardPTZConfig>,
): FrigateCardPTZConfig => {
return frigateCardPTZSchema.parse(config ?? {});
};
describe('hasUsablePTZ', () => {
it('should return true with manual actions', () => {
expect(
hasUsablePTZ(
createCameraCapabilities(),
createPTZConfig({
actions_left: {},
}),
),
).toBeTruthy();
});
it('should return true with capabilities', () => {
expect(
hasUsablePTZ(
createCameraCapabilities({
ptz: {},
}),
createPTZConfig(),
),
).toBeTruthy();
});
it('should return false with manual actions or capabilities', () => {
expect(hasUsablePTZ(createCameraCapabilities(), createPTZConfig())).toBeFalsy();
});
});