From 2b00d951767adf2dc74d62858831453b52480fcf Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 29 Apr 2023 15:09:50 -0700 Subject: [PATCH] Fix incorrect engine detection. --- src/camera-manager/engine-factory.ts | 13 +- src/camera-manager/frigate/engine-frigate.ts | 6 +- src/camera-manager/util.ts | 5 + tests/camera-manager/engine-factory.test.ts | 58 ++++++- tests/camera-manager/utils.test.ts | 164 +++++++++++++++++++ 5 files changed, 239 insertions(+), 7 deletions(-) create mode 100644 tests/camera-manager/utils.test.ts diff --git a/src/camera-manager/engine-factory.ts b/src/camera-manager/engine-factory.ts index dbacd8ca..7aab5ed1 100644 --- a/src/camera-manager/engine-factory.ts +++ b/src/camera-manager/engine-factory.ts @@ -10,6 +10,7 @@ import { MemoryRequestCache, RecordingSegmentsCache, RequestCache } from './cach import { CameraManagerEngine } from './engine'; import { CameraInitializationError } from './error'; import { Engine } from './types'; +import { getCameraEntityFromConfig } from './util'; export class CameraManagerEngineFactory { protected _entityRegistryManager: EntityRegistryManager; @@ -63,16 +64,22 @@ export class CameraManagerEngineFactory { engine = Engine.Frigate; } else if (cameraConfig.engine === 'motioneye') { engine = Engine.MotionEye; + } else if (cameraConfig.engine === 'generic') { + engine = Engine.Generic; } else if (cameraConfig.engine === 'auto') { - const cameraEntity = cameraConfig.camera_entity; + const cameraEntity = getCameraEntityFromConfig(cameraConfig); if (cameraEntity) { let entity: Entity | null; try { entity = await this._entityRegistryManager.getEntity(hass, cameraEntity); } catch (e) { - // Throw a slightly friendlier exception (as a typo in the entity is - // likely to be a common failure mode). + // If the camera is not in the registry, but is in the HA states it is + // assumed to be a generic camera. + if (hass.states[cameraEntity]) { + return Engine.Generic; + } + // Otherwise, it's probably a typo so throw an exception. throw new CameraInitializationError( localize('error.no_camera_entity'), cameraConfig, diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index 53212a41..8917c99a 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -81,6 +81,7 @@ import uniq from 'lodash-es/uniq'; import format from 'date-fns/format'; import { GenericCameraManagerEngine } from '../generic/engine-generic'; import frigateLogo from './assets/frigate-logo-dark.svg'; +import { getCameraEntityFromConfig } from '../util'; const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60; const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60; @@ -153,12 +154,13 @@ export class FrigateCameraManagerEngine cameraConfig.triggers.motion || cameraConfig.triggers.occupancy; let entity: Entity | null = null; + const cameraEntity = getCameraEntityFromConfig(cameraConfig); // Entity information is required if the Frigate camera name is missing, or // if the entity requires automatic resolution of motion/occupancy sensors. - if (cameraConfig.camera_entity && (!hasCameraName || hasAutoTriggers)) { + if (cameraEntity && (!hasCameraName || hasAutoTriggers)) { try { - entity = await entityRegistryManager.getEntity(hass, cameraConfig.camera_entity); + entity = await entityRegistryManager.getEntity(hass, cameraEntity); } catch (e) { throw new CameraInitializationError( localize('error.no_camera_entity'), diff --git a/src/camera-manager/util.ts b/src/camera-manager/util.ts index e5e56812..c6678b60 100644 --- a/src/camera-manager/util.ts +++ b/src/camera-manager/util.ts @@ -7,6 +7,7 @@ import { DateRange } from './range'; import orderBy from 'lodash-es/orderBy'; import uniqBy from 'lodash-es/uniqBy'; import { ViewMedia } from '../view/media'; +import { CameraConfig } from '../types'; export const convertRangeToCacheFriendlyTimes = ( range: DateRange, @@ -53,3 +54,7 @@ export const sortMedia = (mediaArray: ViewMedia[]): ViewMedia[] => { 'asc', ); }; + +export const getCameraEntityFromConfig = (cameraConfig: CameraConfig): string | null => { + return cameraConfig.camera_entity ?? cameraConfig.webrtc_card?.entity ?? null; +}; diff --git a/tests/camera-manager/engine-factory.test.ts b/tests/camera-manager/engine-factory.test.ts index 24da9446..0768422d 100644 --- a/tests/camera-manager/engine-factory.test.ts +++ b/tests/camera-manager/engine-factory.test.ts @@ -11,6 +11,7 @@ import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media'; import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic'; import { FrigateCameraManagerEngine } from '../../src/camera-manager/frigate/engine-frigate'; import { MotionEyeCameraManagerEngine } from '../../src/camera-manager/motioneye/engine-motioneye'; +import { HassEntities } from 'home-assistant-js-websocket'; vi.mock('../../src/utils/ha/entity-registry'); vi.mock('../../src/utils/ha/entity-registry/cache'); @@ -31,8 +32,12 @@ const createCameraConfig = (config: Partial): CameraConfig => { return cameraConfigSchema.parse(config); }; -const createHASS = (): HomeAssistant => { - return mock(); +const createHASS = (states?: HassEntities): HomeAssistant => { + const hass = mock(); + if (states) { + hass.states = states; + } + return hass; }; const createEntity = (entity: Partial): Entity => { @@ -62,6 +67,12 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => { Engine.MotionEye, ); }); + it('should get generic engine from config', async () => { + const config = createCameraConfig({ engine: 'generic' }); + expect(await createFactory().getEngineForCamera(createHASS(), config)).toBe( + Engine.Generic, + ); + }); it('should get frigate engine from auto config', async () => { const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); const entityRegistryManager = new EntityRegistryManager(new EntityCache()); @@ -120,6 +131,49 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => { entityRegistryManager.getEntity = vi.fn().mockRejectedValue(new Error()); + await expect( + createFactory({ + entityRegistryManager: entityRegistryManager, + }).getEngineForCamera(createHASS(), config), + ).rejects.toThrow(); + }); + it('should treat entity not in registry but with state as generic', async () => { + const config = createCameraConfig({ + engine: 'auto', + webrtc_card: { entity: 'camera.foo' }, + }); + const entityRegistryManager = new EntityRegistryManager(new EntityCache()); + + entityRegistryManager.getEntity = vi.fn().mockRejectedValue(new Error()); + + expect( + await createFactory({ + entityRegistryManager: entityRegistryManager, + }).getEngineForCamera( + createHASS({ + 'camera.foo': { + entity_id: 'camera.foo', + state: 'streaming', + last_changed: 'bar', + last_updated: 'baz', + attributes: {}, + context: { + id: 'context', + user_id: null, + parent_id: null, + }, + }, + }), + config, + ), + ).toBe(Engine.Generic); + }); + it('should get engine from webrtc-card configuration', async () => { + const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); + const entityRegistryManager = new EntityRegistryManager(new EntityCache()); + + entityRegistryManager.getEntity = vi.fn().mockRejectedValue(new Error()); + await expect( createFactory({ entityRegistryManager: entityRegistryManager, diff --git a/tests/camera-manager/utils.test.ts b/tests/camera-manager/utils.test.ts new file mode 100644 index 00000000..96ba0731 --- /dev/null +++ b/tests/camera-manager/utils.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + capEndDate, + convertRangeToCacheFriendlyTimes, + getCameraEntityFromConfig, + sortMedia, +} from '../../src/camera-manager/util.js'; +import { ViewMedia, ViewMediaType } from '../../src/view/media.js'; +import { CameraConfig, cameraConfigSchema } from '../../src/types.js'; + +describe('convertRangeToCacheFriendlyTimes', () => { + it('should return cache friendly within hour range', () => { + expect( + convertRangeToCacheFriendlyTimes({ + start: new Date('2023-04-29T14:01:02'), + end: new Date('2023-04-29T14:11:03'), + }), + ).toEqual({ + start: new Date('2023-04-29T14:00:00'), + end: new Date('2023-04-29T14:59:59.999'), + }); + }); + + it('should return cache friendly within day range', () => { + expect( + convertRangeToCacheFriendlyTimes({ + start: new Date('2023-04-29T14:01:02'), + end: new Date('2023-04-29T15:11:03'), + }), + ).toEqual({ + start: new Date('2023-04-29T00:00:00'), + end: new Date('2023-04-29T23:59:59.999'), + }); + }); + + it('should cap end date', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2023-04-29T14:25')); + expect( + convertRangeToCacheFriendlyTimes( + { + start: new Date('2023-04-29T14:01:02'), + end: new Date('2023-04-29T14:11:03'), + }, + { endCap: true }, + ), + ).toEqual({ + start: new Date('2023-04-29T14:00:00'), + end: new Date('2023-04-29T14:25:59.999'), + }); + vi.useRealTimers(); + }); +}); + +describe('capEndDate', () => { + it('should cap end date', () => { + const fakeNow = new Date('2023-04-29T14:25'); + vi.useFakeTimers(); + vi.setSystemTime(fakeNow); + + expect(capEndDate(new Date('2023-04-29T15:02'))).toEqual(fakeNow); + + vi.useRealTimers(); + }); + + it('should not cap end date', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2023-04-29T14:25')); + + const testDate = new Date('2023-04-29T14:24'); + expect(capEndDate(testDate)).toEqual(testDate); + + vi.useRealTimers(); + }); +}); + +// ViewMedia itself has no native way to set startTime and ID that aren't linked +// to an engine. +class TestViewMedia extends ViewMedia { + protected _ID: string | null; + protected _startTime: Date; + + constructor( + ID: string | null, + startTime: Date, + mediaType: ViewMediaType, + cameraID: string, + ) { + super(mediaType, cameraID); + this._ID = ID; + this._startTime = startTime; + } + public getID(): string | null { + return this._ID; + } + public getStartTime(): Date | null { + return this._startTime; + } +} + +describe('sortMedia', () => { + const media_1 = new TestViewMedia( + 'id-1', + new Date('2023-04-29T14:25'), + 'clip', + 'camera-1', + ); + const media_2 = new TestViewMedia( + 'id-2', + new Date('2023-04-29T14:26'), + 'clip', + 'camera-1', + ); + const media_3_dup_id = new TestViewMedia( + 'id-2', + new Date('2023-04-29T14:26'), + 'clip', + 'camera-1', + ); + const media_4_no_id = new TestViewMedia( + null, + new Date('2023-04-29T14:27'), + 'clip', + 'camera-1', + ); + + it('should sort sorted media', () => { + const media = [media_1, media_2]; + expect(sortMedia(media)).toEqual(media); + }); + it('should sort unsorted media', () => { + expect(sortMedia([media_2, media_1])).toEqual([media_1, media_2]); + }); + it('should remove duplicate id', () => { + expect(sortMedia([media_1, media_2, media_3_dup_id])).toEqual([media_1, media_2]); + }); + it('should remove de-duplicate by object if no id', () => { + expect(sortMedia([media_1, media_2, media_4_no_id, media_4_no_id])).toEqual([ + media_1, + media_2, + media_4_no_id, + ]); + }); +}); + +describe('getCameraEntityFromConfig', () => { + const createCameraConfig = (config: Partial): CameraConfig => { + return cameraConfigSchema.parse(config); + }; + + it('should get camera_entity', () => { + expect(getCameraEntityFromConfig(createCameraConfig({ camera_entity: 'foo' }))).toBe( + 'foo', + ); + }); + it('should get camera_entity from webrtc_card config', () => { + expect( + getCameraEntityFromConfig(createCameraConfig({ webrtc_card: { entity: 'bar' } })), + ).toBe('bar'); + }); + it('should get no camera_entity', () => { + expect(getCameraEntityFromConfig(createCameraConfig({}))).toBeNull(); + }); +});