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/util.test.ts b/tests/camera-manager/frigate/util.test.ts new file mode 100644 index 00000000..b47557b5 --- /dev/null +++ b/tests/camera-manager/frigate/util.test.ts @@ -0,0 +1,154 @@ +import add from 'date-fns/add'; +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', () => { + const start = new Date('2023-05-06T10:43:00'); + const end = new Date('2023-05-06T10:44:12'); + afterEach(() => { + vi.useRealTimers(); + }); + it('should get finished event title', () => { + expect( + getEventTitle( + createFrigateEvent({ + start_time: start.getTime() / 1000, + end_time: end.getTime() / 1000, + top_score: 0.841796875, + label: 'person', + }), + ), + ).toBe('2023-05-06 10:43 [72s, Person 84%]'); + }); + it('should get in-progress event title', () => { + vi.useFakeTimers(); + vi.setSystemTime(add(start, { seconds: 60 })); + + expect( + getEventTitle( + createFrigateEvent({ + start_time: start.getTime() / 1000, + end_time: null, + top_score: 0.841796875, + label: 'person', + }), + ), + ).toBe('2023-05-06 10:43 [60s, Person 84%]'); + }); + it('should get scoreless event title', () => { + expect( + getEventTitle( + createFrigateEvent({ + start_time: start.getTime() / 1000, + end_time: end.getTime() / 1000, + top_score: null, + label: 'person', + }), + ), + ).toBe('2023-05-06 10:43 [72s, 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:00Z'), + endTime: new Date('2023-04-29T14:59:59Z'), + }), + ), + ).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 + // 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:00Z'), + endTime: new Date('2023-04-29T14:59:59Z'), + }), + ), + ).toBe('//1682776800000/1682780399000'); + }); +}); 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, + }; +};