import { add } from 'date-fns'; import cloneDeep from 'lodash-es/cloneDeep'; import sum from 'lodash-es/sum'; import PQueue from 'p-queue'; import { CardCameraAPI } from '../card-controller/types.js'; import { PTZAction } from '../config/ptz.js'; import { ActionPhase, CameraConfig, CamerasConfig } from '../config/types.js'; import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js'; import { localize } from '../localize/localize.js'; import { allPromises, arrayify, isTruthy, recursivelyMergeObjectsNotArrays, setify, } from '../utils/basic.js'; import { getCameraID } from '../utils/camera.js'; import { log } from '../utils/debug.js'; import { getConfiguredPTZAction } from './utils/ptz.js'; import { ViewMedia } from '../view/media.js'; import { Capabilities } from './capabilities.js'; import { CameraManagerEngineFactory } from './engine-factory.js'; import { CameraManagerEngine } from './engine.js'; import { CameraInitializationError } from './error.js'; import { CameraManagerReadOnlyConfigStore, CameraManagerStore } from './store.js'; import { CameraEndpoint, CameraEndpoints, CameraEndpointsContext, CameraManagerCameraMetadata, CameraManagerMediaCapabilities, DataQuery, Engine, EngineOptions, EventQuery, EventQueryResults, EventQueryResultsMap, MediaMetadata, MediaMetadataQuery, MediaMetadataQueryResults, MediaQuery, PartialDataQuery, PartialEventQuery, PartialQueryConcreteType, PartialRecordingQuery, PartialRecordingSegmentsQuery, QueryResults, QueryResultsType, QueryReturnType, QueryType, RecordingQuery, RecordingQueryResults, RecordingQueryResultsMap, RecordingSegmentsQuery, RecordingSegmentsQueryResults, RecordingSegmentsQueryResultsMap, ResultsMap, } from './types.js'; import { sortMedia } from './utils/sort-media.js'; export class QueryClassifier { public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery { return query.type === QueryType.Event; } public static isRecordingQuery( query: DataQuery | PartialDataQuery, ): query is RecordingQuery { return query.type === QueryType.Recording; } public static isRecordingSegmentsQuery( query: DataQuery | PartialDataQuery, ): query is RecordingSegmentsQuery { return query.type === QueryType.RecordingSegments; } public static isMediaMetadataQuery( query: DataQuery | PartialDataQuery, ): query is MediaMetadataQuery { return query.type === QueryType.MediaMetadata; } } export class QueryResultClassifier { public static isEventQueryResult( queryResults: QueryResults, ): queryResults is EventQueryResults { return queryResults.type === QueryResultsType.Event; } public static isRecordingQueryResult( queryResults: QueryResults, ): queryResults is RecordingQueryResults { return queryResults.type === QueryResultsType.Recording; } public static isRecordingSegmentsQueryResult( queryResults: QueryResults, ): queryResults is RecordingSegmentsQueryResults { return queryResults.type === QueryResultsType.RecordingSegments; } public static isMediaMetadataQueryResult( queryResults: QueryResults, ): queryResults is MediaMetadataQueryResults { return queryResults.type === QueryResultsType.MediaMetadata; } } export interface ExtendedMediaQueryResult { queries: T[]; results: ViewMedia[]; } export class CameraManager { protected _api: CardCameraAPI; protected _engineFactory: CameraManagerEngineFactory; protected _store: CameraManagerStore; protected _requestLimit = new PQueue(); constructor( api: CardCameraAPI, options?: { store?: CameraManagerStore; factory?: CameraManagerEngineFactory; }, ) { this._api = api; this._engineFactory = options?.factory ?? new CameraManagerEngineFactory(this._api.getEntityRegistryManager()); this._store = options?.store ?? new CameraManagerStore(); } public async initializeCamerasFromConfig(): Promise { const config = this._api.getConfigManager().getConfig(); const hass = this._api.getHASSManager().getHASS(); if (!config || !hass) { return false; } this._requestLimit.concurrency = config.performance.features.max_simultaneous_engine_requests ?? Infinity; // For each camera merge the config (which has no defaults) into the camera // global config (which does have defaults). The merging must happen in this // order, to ensure that the defaults in the cameras global config do not // override the values specified in the per-camera config. const cameras = config.cameras.map((camera) => recursivelyMergeObjectsNotArrays({}, cloneDeep(config?.cameras_global), camera), ); try { await this._initializeCameras(cameras); } catch (e: unknown) { this._api .getMessageManager() .setErrorIfHigherPriority(e, localize('error.camera_initialization')); return false; } return true; } public async reset(): Promise { await this._store.reset(); } protected async _getEnginesForCameras( camerasConfig: CamerasConfig, ): Promise> { const output: Map = new Map(); const engines: Map = new Map(); const hass = this._api.getHASSManager().getHASS(); /* istanbul ignore if: the if path cannot be reached -- @preserve */ if (!hass) { return output; } const getEngineTypes = async (configs: CameraConfig[]) => { return await allPromises(configs, (config) => this._engineFactory.getEngineForCamera(hass, config), ); }; const engineTypes = await getEngineTypes(camerasConfig); for (const [index, cameraConfig] of camerasConfig.entries()) { const engineType = engineTypes[index]; const engine = engineType ? engines.get(engineType) ?? (await this._engineFactory.createEngine(engineType, { eventCallback: (ev) => this._api.getTriggersManager().handleCameraEvent(ev), stateWatcher: this._api.getHASSManager().getStateWatcher(), resolvedMediaCache: this._api.getResolvedMediaCache(), })) : null; if (!engine || !engineType) { throw new CameraInitializationError( localize('error.no_camera_engine'), // Camera initialization may modify the configuration. Keep the // original config unchanged. cloneDeep(cameraConfig), ); } engines.set(engineType, engine); output.set(cameraConfig, engine); } return output; } protected async _initializeCameras(camerasConfig: CamerasConfig): Promise { const initializationStartTime = new Date(); const hass = this._api.getHASSManager().getHASS(); /* istanbul ignore if: the if path cannot be reached -- @preserve */ if (!hass) { return; } const hasAutoTriggers = (config: CameraConfig): boolean => { return config.triggers.motion || config.triggers.occupancy; }; if ( // If any camera requires automatic trigger detection ... camerasConfig.some((config) => hasAutoTriggers(config)) ) { // ... then we need to populate the entity cache by fetching all entities // from Home Assistant. Do this once upfront, to avoid each camera doing // it. await this._api.getEntityRegistryManager().fetchEntityList(hass); } // Engines are created sequentially, to avoid duplicate creation of the same // engine. See: https://github.com/dermotduffy/frigate-hass-card/issues/941 const engineByConfig = await this._getEnginesForCameras(camerasConfig); // Configuration is initialized in parallel. const cameras = await allPromises( engineByConfig.entries(), async ([cameraConfig, engine]) => await engine.createCamera(hass, cameraConfig), ); const destroyCameras = async () => { cameras.forEach((camera) => camera.destroy()); }; const cameraIDs: Set = new Set(); // Do the additions based off the result-order, to ensure the map order is // preserved. for (const camera of cameras) { const cameraID = getCameraID(camera.getConfig()); if (!cameraID) { await destroyCameras(); throw new CameraInitializationError( localize('error.no_camera_id'), camera.getConfig(), ); } if (cameraIDs.has(cameraID)) { await destroyCameras(); throw new CameraInitializationError( localize('error.duplicate_camera_id'), camera.getConfig(), ); } // Always ensure the actual ID used in the card is in the configuration itself. camera.setID(cameraID); cameraIDs.add(cameraID); } await this._store.setCameras(cameras); log( this._api.getConfigManager().getCardWideConfig(), 'Frigate Card CameraManager initialized (Cameras: ', this._store.getCameras(), `, Duration: ${ (new Date().getTime() - initializationStartTime.getTime()) / 1000 }s,`, ')', ); } public isInitialized(): boolean { return this._store.getCameraCount() > 0; } public getStore(): CameraManagerReadOnlyConfigStore { return this._store; } public generateDefaultEventQueries( cameraIDs: string | Set, partialQuery?: PartialEventQuery, ): EventQuery[] | null { return this._generateDefaultQueries(cameraIDs, { type: QueryType.Event, ...partialQuery, }); } public generateDefaultRecordingQueries( cameraIDs: string | Set, partialQuery?: PartialRecordingQuery, ): RecordingQuery[] | null { return this._generateDefaultQueries(cameraIDs, { type: QueryType.Recording, ...partialQuery, }); } public generateDefaultRecordingSegmentsQueries( cameraIDs: string | Set, partialQuery?: PartialRecordingSegmentsQuery, ): RecordingSegmentsQuery[] | null { return this._generateDefaultQueries(cameraIDs, { type: QueryType.RecordingSegments, ...partialQuery, }); } protected _generateDefaultQueries( cameraIDs: string | Set, partialQuery: PQT, ): PartialQueryConcreteType[] | null { const concreteQueries: PartialQueryConcreteType[] = []; const _cameraIDs = setify(cameraIDs); const engines = this._store.getEnginesForCameraIDs(_cameraIDs); if (!engines) { return null; } for (const [engine, cameraIDs] of engines) { let queries: DataQuery[] | null = null; /* istanbul ignore else: the else path cannot be reached -- @preserve */ if (QueryClassifier.isEventQuery(partialQuery)) { queries = engine.generateDefaultEventQuery(this._store, cameraIDs, partialQuery); } else if (QueryClassifier.isRecordingQuery(partialQuery)) { queries = engine.generateDefaultRecordingQuery( this._store, cameraIDs, partialQuery, ); } else if (QueryClassifier.isRecordingSegmentsQuery(partialQuery)) { queries = engine.generateDefaultRecordingSegmentsQuery( this._store, cameraIDs, partialQuery, ); } for (const query of queries ?? []) { concreteQueries.push(query as PartialQueryConcreteType); } } return concreteQueries.length ? concreteQueries : null; } public async getMediaMetadata(): Promise { const tags: Set = new Set(); const what: Set = new Set(); const where: Set = new Set(); const days: Set = new Set(); const query: MediaMetadataQuery = { type: QueryType.MediaMetadata, cameraIDs: this._store.getCameraIDs(), }; const results = await this._handleQuery(query); for (const result of results.values()) { if (result.metadata.tags) { result.metadata.tags.forEach(tags.add, tags); } if (result.metadata.what) { result.metadata.what.forEach(what.add, what); } if (result.metadata.where) { result.metadata.where.forEach(where.add, where); } if (result.metadata.days) { result.metadata.days.forEach(days.add, days); } } if (!what.size && !where.size && !days.size && !tags.size) { return null; } return { ...(tags.size && { tags: tags }), ...(what.size && { what: what }), ...(where.size && { where: where }), ...(days.size && { days: days }), }; } public async getEvents( query: EventQuery | EventQuery[], engineOptions?: EngineOptions, ): Promise { return await this._handleQuery(query, engineOptions); } public async getRecordings( query: RecordingQuery | RecordingQuery[], engineOptions?: EngineOptions, ): Promise { return await this._handleQuery(query, engineOptions); } public async getRecordingSegments( query: RecordingSegmentsQuery | RecordingSegmentsQuery[], engineOptions?: EngineOptions, ): Promise { return await this._handleQuery(query, engineOptions); } public async executeMediaQueries( queries: T[], engineOptions?: EngineOptions, ): Promise { return this._convertQueryResultsToMedia( await this._handleQuery(queries, engineOptions), ); } public async extendMediaQueries( queries: T[], results: ViewMedia[], direction: 'earlier' | 'later', engineOptions?: EngineOptions, ): Promise | null> { const hass = this._api.getHASSManager().getHASS(); if (!hass) { return null; } const getTimeFromResults = (want: 'earliest' | 'latest'): Date | null => { let output: Date | null = null; for (const result of results) { const startTime = result.getStartTime(); if ( startTime && (!output || (want === 'earliest' && startTime < output) || (want === 'latest' && startTime > output)) ) { output = startTime; } } return output; }; const chunkSize = this._api.getConfigManager().getCardWideConfig()?.performance?.features .media_chunk_size ?? MEDIA_CHUNK_SIZE_DEFAULT; // The queries associated with the chunk to fetch. const newChunkQueries: T[] = []; // The re-constituted combined query. const extendedQueries: T[] = []; for (const query of queries) { const newChunkQuery = { ...query }; /* istanbul ignore else: the else path cannot be reached -- @preserve */ if (direction === 'later') { const latestResult = getTimeFromResults('latest'); if (latestResult) { newChunkQuery.start = latestResult; delete newChunkQuery.end; } } else if (direction === 'earlier') { const earliestResult = getTimeFromResults('earliest'); if (earliestResult) { newChunkQuery.end = earliestResult; delete newChunkQuery.start; } } newChunkQuery.limit = chunkSize; extendedQueries.push({ ...query, limit: (query.limit ?? 0) + chunkSize, }); newChunkQueries.push(newChunkQuery); } const newChunkMedia = this._convertQueryResultsToMedia( await this._handleQuery(newChunkQueries, engineOptions), ); if (!newChunkMedia.length) { return null; } const outputMedia = sortMedia(results.concat(newChunkMedia)); // If the media did not _ACTUALLY_ get longer, there is no new media despite // the increased limit, so just return null. if (outputMedia.length === results.length) { return null; } return { queries: extendedQueries, results: outputMedia, }; } public async getMediaDownloadPath(media: ViewMedia): Promise { const cameraConfig = this._store.getCameraConfigForMedia(media); const engine = this._store.getEngineForMedia(media); const hass = this._api.getHASSManager().getHASS(); if (!cameraConfig || !engine || !hass) { return null; } return await engine.getMediaDownloadPath(hass, cameraConfig, media); } public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null { const engine = this._store.getEngineForMedia(media); if (!engine) { return null; } return engine.getMediaCapabilities(media); } public async favoriteMedia(media: ViewMedia, favorite: boolean): Promise { const cameraConfig = this._store.getCameraConfigForMedia(media); const engine = this._store.getEngineForMedia(media); const hass = this._api.getHASSManager().getHASS(); if (!cameraConfig || !engine || !hass) { return; } const queryStartTime = new Date(); await this._requestLimit.add(() => engine.favoriteMedia(hass, cameraConfig, media, favorite), ); log( this._api.getConfigManager().getCardWideConfig(), 'Frigate Card CameraManager favorite request (', `Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`, 'Media:', media.getID(), ', Favorite:', favorite, ')', ); } public areMediaQueriesResultsFresh( queries: T[], resultsTimestamp: Date, ): boolean { const now = new Date(); for (const query of queries) { const engines = this._store.getEnginesForCameraIDs(query.cameraIDs); for (const [engine, cameraIDs] of engines ?? []) { const maxAgeSeconds = engine.getQueryResultMaxAge({ ...query, cameraIDs: cameraIDs, }); if ( maxAgeSeconds !== null && add(resultsTimestamp, { seconds: maxAgeSeconds }) < now ) { return false; } } } return true; } public async getMediaSeekTime(media: ViewMedia, target: Date): Promise { const startTime = media.getStartTime(); const endTime = media.getEndTime(); const engine = this._store.getEngineForMedia(media); const hass = this._api.getHASSManager().getHASS(); if ( !hass || !engine || !startTime || !endTime || target < startTime || target > endTime ) { return null; } return ( (await this._requestLimit.add(() => engine.getMediaSeekTime(hass, this._store, media, target), )) ?? null ); } protected async _handleQuery( query: QT | QT[], engineOptions?: EngineOptions, ): Promise>> { const _queries = arrayify(query); const results = new Map>(); const queryStartTime = new Date(); const hass = this._api.getHASSManager().getHASS(); if (!hass) { return results; } const processEngineQuery = async ( engine: CameraManagerEngine, query: QT, ): Promise => { let engineResult: Map> | null = null; /* istanbul ignore else: the else path cannot be reached -- @preserve */ if (QueryClassifier.isEventQuery(query)) { engineResult = (await engine.getEvents( hass, this._store, query, engineOptions, )) as Map> | null; } else if (QueryClassifier.isRecordingQuery(query)) { engineResult = (await engine.getRecordings( hass, this._store, query, engineOptions, )) as Map> | null; } else if (QueryClassifier.isRecordingSegmentsQuery(query)) { engineResult = (await engine.getRecordingSegments( hass, this._store, query, engineOptions, )) as Map> | null; } else if (QueryClassifier.isMediaMetadataQuery(query)) { engineResult = (await engine.getMediaMetadata( hass, this._store, query, engineOptions, )) as Map> | null; } engineResult?.forEach((value, key) => results.set(key, value)); }; const processQuery = async (query: QT): Promise => { const engines = this._store.getEnginesForCameraIDs(query.cameraIDs); if (!engines) { return; } await Promise.all( Array.from(engines.keys()).map((engine) => this._requestLimit.add(() => processEngineQuery(engine, { ...query, cameraIDs: engines.get(engine) }), ), ), ); }; await Promise.all(_queries.map((query) => processQuery(query))); const cachedOutputQueries = sum( Array.from(results.values()).map((result) => Number(result.cached ?? 0)), ); log( this._api.getConfigManager().getCardWideConfig(), 'Frigate Card CameraManager request [Input queries:', _queries.length, ', Cached output queries:', cachedOutputQueries, ', Total output queries:', results.size, ', Duration:', `${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`, ', Queries:', _queries, ', Results:', results, ']', ); return results; } protected _convertQueryResultsToMedia( results: ResultsMap, ): ViewMedia[] { const mediaArray: ViewMedia[] = []; const hass = this._api.getHASSManager().getHASS(); if (!hass) { return mediaArray; } for (const [query, result] of results.entries()) { const engine = this._store.getEngineOfType(result.engine); if (engine) { let media: ViewMedia[] | null = null; /* istanbul ignore else: the else path cannot be reached -- @preserve */ if ( QueryClassifier.isEventQuery(query) && QueryResultClassifier.isEventQueryResult(result) ) { media = engine.generateMediaFromEvents(hass, this._store, query, result); } else if ( QueryClassifier.isRecordingQuery(query) && QueryResultClassifier.isRecordingQueryResult(result) ) { media = engine.generateMediaFromRecordings(hass, this._store, query, result); } if (media) { mediaArray.push(...media); } } } return sortMedia(mediaArray); } public getCameraEndpoints( cameraID: string, context?: CameraEndpointsContext, ): CameraEndpoints | null { const cameraConfig = this._store.getCameraConfig(cameraID); const engine = this._store.getEngineForCameraID(cameraID); if (!cameraConfig || !engine) { return null; } return engine.getCameraEndpoints(cameraConfig, context); } public getCameraMetadata(cameraID: string): CameraManagerCameraMetadata | null { const cameraConfig = this._store.getCameraConfig(cameraID); const engine = this._store.getEngineForCameraID(cameraID); const hass = this._api.getHASSManager().getHASS(); if (!cameraConfig || !engine || !hass) { return null; } return engine.getCameraMetadata(hass, cameraConfig); } public getCameraCapabilities(cameraID: string): Capabilities | null { return this._store.getCamera(cameraID)?.getCapabilities() ?? null; } public getAggregateCameraCapabilities(cameraIDs?: Set): Capabilities { const cameras = [...(cameraIDs ?? this._store.getCameraIDs())] .map((cameraID) => this._store.getCamera(cameraID)) .filter(isTruthy); return new Capabilities({ live: cameras.some((camera) => camera.getCapabilities()?.has('live')), clips: cameras.some((camera) => camera.getCapabilities()?.has('clips')), recordings: cameras.some((camera) => camera.getCapabilities()?.has('recordings')), snapshots: cameras.some((camera) => camera.getCapabilities()?.has('snapshots')), 'favorite-events': cameras.some((camera) => camera.getCapabilities()?.has('favorite-events'), ), 'favorite-recordings': cameras.some((camera) => camera.getCapabilities()?.has('favorite-recordings'), ), seek: cameras.some((camera) => camera.getCapabilities()?.has('seek')), menu: cameras.some((camera) => camera.getCapabilities()?.has('menu')), }); } public async executePTZAction( cameraID: string, action: PTZAction, options?: { phase?: ActionPhase; preset?: string; }, ): Promise { const cameraConfig = this._store.getCameraConfig(cameraID); if (!cameraConfig) { return; } const configuredAction = getConfiguredPTZAction(cameraConfig, action, options); if (configuredAction) { return await this._api.getActionsManager().executeActions(configuredAction); } const hass = this._api.getHASSManager().getHASS(); const engine = this._store.getEngineForCameraID(cameraID); if (!engine || !hass) { return; } return await this._requestLimit.add(() => engine.executePTZAction(hass, cameraConfig, action, options), ); } }