import { HomeAssistant } from 'custom-card-helpers'; import add from 'date-fns/add'; import cloneDeep from 'lodash-es/cloneDeep'; import merge from 'lodash-es/merge.js'; import sum from 'lodash-es/sum'; import { CameraConfig, CamerasConfig } from '../config/types.js'; import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js'; import { localize } from '../localize/localize.js'; import { allPromises, arrayify, setify } from '../utils/basic.js'; import { getCameraID } from '../utils/camera.js'; import { CardCameraAPI } from '../card-controller/types.js'; import { log } from '../utils/debug.js'; import { EntityRegistryManager } from '../utils/ha/entity-registry/index.js'; import { ViewMedia } from '../view/media.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, CameraManagerCameraCapabilities, CameraManagerCameraMetadata, CameraManagerCapabilities, 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 './util.js'; 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; } } class QueryResultClassifier { public static isEventQueryResult( queryResults: QueryResults, ): queryResults is EventQueryResults { return queryResults.type === QueryResultsType.Event; } public static isRecordingQuery( queryResults: QueryResults, ): queryResults is RecordingQueryResults { return queryResults.type === QueryResultsType.Recording; } public static isRecordingSegmentsQuery( queryResults: QueryResults, ): queryResults is RecordingSegmentsQueryResults { return queryResults.type === QueryResultsType.RecordingSegments; } public static isMediaMetadataQuery( queryResults: QueryResults, ): queryResults is MediaMetadataQueryResults { return queryResults.type === QueryResultsType.MediaMetadata; } } export interface ExtendedMediaQueryResult { queries: T[]; results: ViewMedia[]; } interface InitializedCamera { inputConfig: CameraConfig; initializedConfig: CameraConfig; engine: CameraManagerEngine; } export class CameraManager { protected _api: CardCameraAPI; protected _engineFactory: CameraManagerEngineFactory; protected _store = new CameraManagerStore(); constructor(api: CardCameraAPI) { this._api = api; this._engineFactory = new CameraManagerEngineFactory( this._api.getEntityRegistryManager(), this._api.getResolvedMediaCache(), ); } public async initializeCamerasFromConfig(): Promise { const config = this._api.getConfigManager().getConfig(); const hass = this._api.getHASSManager().getHASS(); if (!config || !hass) { return; } this._store.reset(); // 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) => merge(cloneDeep(config?.cameras_global), camera), ); try { await this._initializeCameras(cameras); } catch (e: unknown) { this._api.getMessageManager().setErrorIfHigherPriority(e); } } protected async _getEnginesForCameras( camerasConfig: CamerasConfig, ): Promise> { const output: Map = new Map(); const engines: Map = new Map(); const hass = this._api.getHASSManager().getHASS(); 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)) : null; if (!engine || !engineType) { throw new CameraInitializationError( localize('error.no_camera_engine'), cameraConfig, ); } engines.set(engineType, engine); output.set(cameraConfig, engine); } return output; } protected async _initializeCamera( hass: HomeAssistant, engine: CameraManagerEngine, entityRegistryManager: EntityRegistryManager, inputCameraConfig: CameraConfig, ): Promise { const initializedConfig = await engine.initializeCamera( hass, entityRegistryManager, // Camera initialization may modify the configuration. Keep the original // for display in error messages to avoid user confusion. cloneDeep(inputCameraConfig), ); return { inputConfig: inputCameraConfig, initializedConfig: initializedConfig, engine: engine, }; } protected async _initializeCameras(camerasConfig: CamerasConfig): Promise { const initializationStartTime = new Date(); const hass = this._api.getHASSManager().getHASS(); 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 results = await allPromises( engineByConfig.entries(), async ([cameraConfig, engine]) => await this._initializeCamera( hass, engine, this._api.getEntityRegistryManager(), cameraConfig, ), ); // Do the additions based off the result-order, to ensure the map order is // preserved. results.forEach((result) => { const id = getCameraID(result.initializedConfig); if (!id) { throw new CameraInitializationError( localize('error.no_camera_id'), result.inputConfig, ); } if (this._store.hasCameraID(id)) { throw new CameraInitializationError( localize('error.duplicate_camera_id'), result.inputConfig, ); } this._store.addCamera(id, result.initializedConfig, result.engine); }); if (!this._store.getVisibleCameraCount()) { throw new CameraInitializationError(localize('error.no_visible_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, }); } 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) { return null; } return { ...(tags.size && { tags: tags }), ...(what.size && { what: what }), ...(where.size && { where: where }), ...(days.size && { days: days }), }; } 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; if (QueryClassifier.isEventQuery(partialQuery)) { queries = engine.generateDefaultEventQuery( this._store.getVisibleCameras(), cameraIDs, partialQuery, ); } else if (QueryClassifier.isRecordingQuery(partialQuery)) { queries = engine.generateDefaultRecordingQuery( this._store.getVisibleCameras(), cameraIDs, partialQuery, ); } else if (QueryClassifier.isRecordingSegmentsQuery(partialQuery)) { queries = engine.generateDefaultRecordingSegmentsQuery( this._store.getVisibleCameras(), cameraIDs, partialQuery, ); } for (const query of queries ?? []) { concreteQueries.push(query as PartialQueryConcreteType); } } return concreteQueries.length ? concreteQueries : null; } 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 }; if (direction === 'later') { const latestResult = getTimeFromResults('latest'); if (latestResult) { newChunkQuery.start = latestResult; } } else if (direction === 'earlier') { const earliestResult = getTimeFromResults('earliest'); if (earliestResult) { newChunkQuery.end = earliestResult; } } 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 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 cameraConfig = this._store.getCameraConfigForMedia(media); const engine = this._store.getEngineForMedia(media); const hass = this._api.getHASSManager().getHASS(); if ( !hass || !cameraConfig || !engine || !startTime || !endTime || target < startTime || target > endTime ) { return null; } return await engine.getMediaSeekTime(hass, this._store.getCameras(), media, target); } 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 => { if (!query) { return; } let engineResult: Map> | null = null; if (QueryClassifier.isEventQuery(query)) { engineResult = (await engine.getEvents( hass, this._store.getCameras(), query, engineOptions, )) as Map> | null; } else if (QueryClassifier.isRecordingQuery(query)) { engineResult = (await engine.getRecordings( hass, this._store.getCameras(), query, engineOptions, )) as Map> | null; } else if (QueryClassifier.isRecordingSegmentsQuery(query)) { engineResult = (await engine.getRecordingSegments( hass, this._store.getCameras(), query, engineOptions, )) as Map> | null; } else if (QueryClassifier.isMediaMetadataQuery(query)) { engineResult = (await engine.getMediaMetadata( hass, this._store.getCameras(), 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) => 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; if ( QueryClassifier.isEventQuery(query) && QueryResultClassifier.isEventQueryResult(result) ) { media = engine.generateMediaFromEvents( hass, this._store.getCameras(), query, result, ); } else if ( QueryClassifier.isRecordingQuery(query) && QueryResultClassifier.isRecordingQuery(result) ) { media = engine.generateMediaFromRecordings( hass, this._store.getCameras(), 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, ): CameraManagerCameraCapabilities | null { const cameraConfig = this._store.getCameraConfig(cameraID); const engine = this._store.getEngineForCameraID(cameraID); if (!cameraConfig || !engine) { return null; } return engine.getCameraCapabilities(cameraConfig); } public getAggregateCameraCapabilities( cameraIDs?: Set, ): CameraManagerCapabilities | null { const perCameraCapabilities = [...(cameraIDs ?? this._store.getCameraIDs())].map( (cameraID) => this.getCameraCapabilities(cameraID), ); return { canFavoriteEvents: perCameraCapabilities.some((cap) => cap?.canFavoriteEvents), canFavoriteRecordings: perCameraCapabilities.some( (cap) => cap?.canFavoriteRecordings, ), canSeek: perCameraCapabilities.some((cap) => cap?.canSeek), supportsClips: perCameraCapabilities.some((cap) => cap?.supportsClips), supportsRecordings: perCameraCapabilities.some((cap) => cap?.supportsRecordings), supportsSnapshots: perCameraCapabilities.some((cap) => cap?.supportsSnapshots), supportsTimeline: perCameraCapabilities.some((cap) => cap?.supportsTimeline), }; } }