From cb6a73c3f82ad1a75465b4f0888a5a49baf068ac Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 6 May 2023 11:46:30 -0700 Subject: [PATCH 1/2] Fix Frigate event schema break (top_score === null) --- src/camera-manager/frigate/types.ts | 2 +- src/camera-manager/frigate/util.ts | 8 +- tests/camera-manager/frigate/utils.test.ts | 154 +++++++++++++++++++++ tests/test-utils.ts | 29 ++++ 4 files changed, 188 insertions(+), 5 deletions(-) create mode 100644 tests/camera-manager/frigate/utils.test.ts diff --git a/src/camera-manager/frigate/types.ts b/src/camera-manager/frigate/types.ts index ef18041c..d9ef207d 100644 --- a/src/camera-manager/frigate/types.ts +++ b/src/camera-manager/frigate/types.ts @@ -21,7 +21,7 @@ const eventSchema = z.object({ label: z.string(), sub_label: z.string().nullable(), start_time: z.number(), - top_score: z.number(), + top_score: z.number().nullable(), zones: z.string().array(), retain_indefinitely: z.boolean().optional(), }); diff --git a/src/camera-manager/frigate/util.ts b/src/camera-manager/frigate/util.ts index 0be47429..24c8f532 100644 --- a/src/camera-manager/frigate/util.ts +++ b/src/camera-manager/frigate/util.ts @@ -14,11 +14,11 @@ export const getEventTitle = (event: FrigateEvent): string => { ? event.end_time - event.start_time : Date.now() / 1000 - event.start_time, ); + const score = event.top_score !== null ? ` ${Math.round(event.top_score * 100)}%` : ''; + return `${formatDateAndTime( utcToZonedTime(event.start_time * 1000, localTimezone), - )} [${durationSeconds}s, ${prettifyTitle(event.label)} ${Math.round( - event.top_score * 100, - )}%]`; + )} [${durationSeconds}s, ${prettifyTitle(event.label)}${score}]`; }; export const getRecordingTitle = ( @@ -93,5 +93,5 @@ export const getRecordingID = ( // can be dedup'd from this id. return `${cameraConfig.frigate?.client_id ?? ''}/${ cameraConfig.frigate.camera_name ?? '' - }/${recording.startTime.getTime()}/${recording.endTime.getTime()}}`; + }/${recording.startTime.getTime()}/${recording.endTime.getTime()}`; }; diff --git a/tests/camera-manager/frigate/utils.test.ts b/tests/camera-manager/frigate/utils.test.ts new file mode 100644 index 00000000..39c27095 --- /dev/null +++ b/tests/camera-manager/frigate/utils.test.ts @@ -0,0 +1,154 @@ +import sub from 'date-fns/sub'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { + getEventMediaContentID, + getEventThumbnailURL, + getEventTitle, + getRecordingID, + getRecordingMediaContentID, + getRecordingTitle, +} from '../../../src/camera-manager/frigate/util'; +import { CameraConfig } from '../../../src/types'; +import { + createCameraConfig, + createFrigateEvent, + createFrigateRecording, +} from '../../test-utils'; + +describe('getEventTitle', () => { + afterEach(() => { + vi.useRealTimers(); + }); + it('should get finished event title', () => { + expect( + getEventTitle( + createFrigateEvent({ + start_time: 1683395000, + end_time: 1683397124, + top_score: 0.841796875, + label: 'person', + }), + ), + ).toBe('2023-05-06 10:43 [2124s, Person 84%]'); + }); + it('should get in-progress event title', () => { + const baseTime = new Date('2023-04-29T14:25'); + vi.useFakeTimers(); + vi.setSystemTime(baseTime); + const startTime = sub(baseTime, { seconds: 60 }); + + expect( + getEventTitle( + createFrigateEvent({ + start_time: startTime.getTime() / 1000, + end_time: null, + top_score: 0.841796875, + label: 'person', + }), + ), + ).toBe('2023-04-29 14:24 [60s, Person 84%]'); + }); + it('should get scoreless event title', () => { + expect( + getEventTitle( + createFrigateEvent({ + start_time: 1683395000, + end_time: 1683397124, + top_score: null, + label: 'person', + }), + ), + ).toBe('2023-05-06 10:43 [2124s, Person]'); + }); +}); + +describe('getRecordingTitle', () => { + it('should get recording title', () => { + expect( + getRecordingTitle( + 'Kitchen', + createFrigateRecording({ + startTime: new Date('2023-04-29T14:00:00'), + }), + ), + ).toBe('Kitchen 2023-04-29 14:00'); + }); +}); + +describe('getEventThumbnailURL', () => { + it('should get thumbnail URL', () => { + expect( + getEventThumbnailURL( + 'clientid', + createFrigateEvent({ + id: '1683396875.643998-hmzrh5', + }), + ), + ).toBe('/api/frigate/clientid/thumbnail/1683396875.643998-hmzrh5'); + }); +}); + +describe('getEventMediaContentID', () => { + it('should get event content ID', () => { + expect( + getEventMediaContentID( + 'clientid', + 'kitchen', + createFrigateEvent({ + id: '1683396875.643998-hmzrh5', + }), + 'clips', + ), + ).toBe( + 'media-source://frigate/clientid/event/clips/kitchen/1683396875.643998-hmzrh5', + ); + }); +}); + +describe('getRecordingMediaContentID', () => { + it('should get recording content ID', () => { + expect( + getRecordingMediaContentID( + 'clientid', + 'kitchen', + createFrigateRecording({ + startTime: new Date('2023-04-29T14:00:00'), + }), + ), + ).toBe('media-source://frigate/clientid/recordings/kitchen/2023-04-29/14'); + }); +}); + +describe('getRecordingID', () => { + it('should get recording ID', () => { + expect( + getRecordingID( + createCameraConfig({ + frigate: { + client_id: 'unique_client_id', + camera_name: 'kitchen', + }, + }), + createFrigateRecording({ + startTime: new Date('2023-04-29T14:00:00'), + endTime: new Date('2023-04-29T14:59:59'), + }), + ), + ).toBe('unique_client_id/kitchen/1682802000000/1682805599000'); + }); + it('should get recording ID without client_id or camera_name', () => { + // Note: This path is defended against in the code but should not happen in + // practice as this would be a malformed (not-zod-parsed) camera config. + const cameraConfig = mock(); + expect( + getRecordingID( + cameraConfig, + createFrigateRecording({ + startTime: new Date('2023-04-29T14:00:00'), + endTime: new Date('2023-04-29T14:59:59'), + }), + ), + ).toBe('//1682802000000/1682805599000'); + }); +}); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 7e14ebdc..dcc71536 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -1,6 +1,7 @@ import { HomeAssistant } from 'custom-card-helpers'; import { HassEntities, HassEntity } from 'home-assistant-js-websocket'; import { mock } from 'vitest-mock-extended'; +import { FrigateEvent, FrigateRecording } from '../src/camera-manager/frigate/types'; import { CameraConfig, FrigateCardCondition, @@ -60,3 +61,31 @@ export const createStateEntity = (entity?: Partial): HassEntity => { }, }; }; + +export const createFrigateEvent = (event?: Partial) => { + return { + camera: 'camera', + end_time: 1683397124, + false_positive: false, + has_clip: true, + has_snapshot: true, + id: '1683396875.643998-hmzrh5', + label: 'person', + sub_label: null, + start_time: 1683395000, + top_score: 0.841796875, + zones: [], + retain_indefinitely: false, + ...event, + }; +}; + +export const createFrigateRecording = (recording?: Partial) => { + return { + cameraID: 'cameraID', + startTime: new Date('2023-04-29T14:00:00'), + endTime: new Date('2023-04-29T14:59:59'), + events: 42, + ...recording, + }; +}; From 4cd1fdad7222de93ce7afe3983a2a20f928d1e0d Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 6 May 2023 12:21:50 -0700 Subject: [PATCH 2/2] Fix timezone issues. --- .../frigate/{utils.test.ts => util.test.ts} | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) rename tests/camera-manager/frigate/{utils.test.ts => util.test.ts} (79%) diff --git a/tests/camera-manager/frigate/utils.test.ts b/tests/camera-manager/frigate/util.test.ts similarity index 79% rename from tests/camera-manager/frigate/utils.test.ts rename to tests/camera-manager/frigate/util.test.ts index 39c27095..b47557b5 100644 --- a/tests/camera-manager/frigate/utils.test.ts +++ b/tests/camera-manager/frigate/util.test.ts @@ -1,4 +1,4 @@ -import sub from 'date-fns/sub'; +import add from 'date-fns/add'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { @@ -17,6 +17,8 @@ import { } from '../../test-utils'; describe('getEventTitle', () => { + const start = new Date('2023-05-06T10:43:00'); + const end = new Date('2023-05-06T10:44:12'); afterEach(() => { vi.useRealTimers(); }); @@ -24,42 +26,40 @@ describe('getEventTitle', () => { expect( getEventTitle( createFrigateEvent({ - start_time: 1683395000, - end_time: 1683397124, + start_time: start.getTime() / 1000, + end_time: end.getTime() / 1000, top_score: 0.841796875, label: 'person', }), ), - ).toBe('2023-05-06 10:43 [2124s, Person 84%]'); + ).toBe('2023-05-06 10:43 [72s, Person 84%]'); }); it('should get in-progress event title', () => { - const baseTime = new Date('2023-04-29T14:25'); vi.useFakeTimers(); - vi.setSystemTime(baseTime); - const startTime = sub(baseTime, { seconds: 60 }); + vi.setSystemTime(add(start, { seconds: 60 })); expect( getEventTitle( createFrigateEvent({ - start_time: startTime.getTime() / 1000, + start_time: start.getTime() / 1000, end_time: null, top_score: 0.841796875, label: 'person', }), ), - ).toBe('2023-04-29 14:24 [60s, Person 84%]'); + ).toBe('2023-05-06 10:43 [60s, Person 84%]'); }); it('should get scoreless event title', () => { expect( getEventTitle( createFrigateEvent({ - start_time: 1683395000, - end_time: 1683397124, + start_time: start.getTime() / 1000, + end_time: end.getTime() / 1000, top_score: null, label: 'person', }), ), - ).toBe('2023-05-06 10:43 [2124s, Person]'); + ).toBe('2023-05-06 10:43 [72s, Person]'); }); }); @@ -131,11 +131,11 @@ describe('getRecordingID', () => { }, }), createFrigateRecording({ - startTime: new Date('2023-04-29T14:00:00'), - endTime: new Date('2023-04-29T14:59:59'), + startTime: new Date('2023-04-29T14:00:00Z'), + endTime: new Date('2023-04-29T14:59:59Z'), }), ), - ).toBe('unique_client_id/kitchen/1682802000000/1682805599000'); + ).toBe('unique_client_id/kitchen/1682776800000/1682780399000'); }); it('should get recording ID without client_id or camera_name', () => { // Note: This path is defended against in the code but should not happen in @@ -145,10 +145,10 @@ describe('getRecordingID', () => { getRecordingID( cameraConfig, createFrigateRecording({ - startTime: new Date('2023-04-29T14:00:00'), - endTime: new Date('2023-04-29T14:59:59'), + startTime: new Date('2023-04-29T14:00:00Z'), + endTime: new Date('2023-04-29T14:59:59Z'), }), ), - ).toBe('//1682802000000/1682805599000'); + ).toBe('//1682776800000/1682780399000'); }); });