import { add } from 'date-fns'; import { cloneDeep, omit, sum } from 'lodash-es'; import PQueue from 'p-queue'; import { EqualityMap } from '../cache/equality-map.js'; import { CardCameraAPI } from '../card-controller/types.js'; import { sortItems } from '../card-controller/view/sort.js'; import { PTZAction, PTZActionPhase, PTZPanTiltAction, } from '../config/schema/actions/custom/ptz.js'; import { CameraConfig, Rotation } from '../config/schema/cameras.js'; import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js'; import { Endpoint } from '../types.js'; import { allPromises, arrayify, isTruthy, recursivelyMergeObjectsNotArrays, setify, } from '../utils/basic.js'; import { getCameraID } from '../utils/camera.js'; import { log } from '../utils/debug.js'; import { ViewItemClassifier } from '../view/item-classifier.js'; import { ViewItem, ViewMedia } from '../view/item.js'; import { ViewItemCapabilities } from '../view/types.js'; import { Capabilities } from './capabilities.js'; import { CameraManagerEngineFactory } from './engine-factory.js'; import { CameraManagerEngine } from './engine.js'; import { CameraDuplicateIDError, CameraNoEngineError, CameraNoIDError, } from './error.js'; import { CameraManagerReadOnlyConfigStore, CameraManagerStore } from './store.js'; import { CameraEndpoints, CameraEndpointsContext, CameraManagerCameraMetadata, CameraQuery, DefaultQueryParameters, Engine, EngineOptions, EventQuery, EventQueryResults, EventQueryResultsMap, MediaMetadata, MediaMetadataQuery, MediaMetadataQueryResults, MediaQuery, PartialCameraQuery, PartialEventQuery, PartialQueryConcreteType, PartialRecordingQuery, PartialRecordingSegmentsQuery, PartialReviewQuery, QueryResults, QueryResultsType, QueryReturnType, QueryType, RecordingQuery, RecordingQueryResults, RecordingQueryResultsMap, RecordingSegmentsQuery, RecordingSegmentsQueryResults, RecordingSegmentsQueryResultsMap, ResultsMap, ReviewQuery, ReviewQueryResults, } from './types.js'; export class CameraQueryClassifier { public static isEventQuery( query: CameraQuery | PartialCameraQuery, ): query is EventQuery { return query.type === QueryType.Event; } public static isRecordingQuery( query: CameraQuery | PartialCameraQuery, ): query is RecordingQuery { return query.type === QueryType.Recording; } public static isRecordingSegmentsQuery( query: CameraQuery | PartialCameraQuery, ): query is RecordingSegmentsQuery { return query.type === QueryType.RecordingSegments; } public static isMediaMetadataQuery( query: CameraQuery | PartialCameraQuery, ): query is MediaMetadataQuery { return query.type === QueryType.MediaMetadata; } public static isReviewQuery( query: CameraQuery | PartialCameraQuery, ): query is ReviewQuery { return query.type === QueryType.Review; } } export class QueryResultClassifier { public static isEventQueryResult( queryResults?: QueryResults | null, ): queryResults is EventQueryResults { return queryResults?.type === QueryResultsType.Event; } public static isRecordingQueryResult( queryResults?: QueryResults | null, ): queryResults is RecordingQueryResults { return queryResults?.type === QueryResultsType.Recording; } public static isRecordingSegmentsQueryResult( queryResults?: QueryResults | null, ): queryResults is RecordingSegmentsQueryResults { return queryResults?.type === QueryResultsType.RecordingSegments; } public static isMediaMetadataQueryResult( queryResults?: QueryResults | null, ): queryResults is MediaMetadataQueryResults { return queryResults?.type === QueryResultsType.MediaMetadata; } public static isReviewQueryResult( queryResults?: QueryResults | null, ): queryResults is ReviewQueryResults { return queryResults?.type === QueryResultsType.Review; } } interface ExtendedMediaQueryResult { queries: T[]; results: ViewItem[]; } export class CameraManager { private _api: CardCameraAPI; private _engineFactory: CameraManagerEngineFactory; private _store: CameraManagerStore; private _requestLimit = new PQueue(); constructor( api: CardCameraAPI, options?: { store?: CameraManagerStore; factory?: CameraManagerEngineFactory; }, ) { this._api = api; this._engineFactory = options?.factory ?? new CameraManagerEngineFactory( this._api.getEntityRegistryManager(), this._api.getDeviceRegistryManager(), ); 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; } 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(config?.cameras_global, camera), ); await this._initializeCameras(cameras); } public async destroy(): Promise { await this._store.reset(); } private async _getEnginesForCameras( camerasConfig: CameraConfig[], ): 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(), eventWatcher: this._api.getHASSManager().getEventWatcher(), resolvedMediaCache: this._api.getResolvedMediaCache(), })) : null; if (!engine || !engineType) { // Camera initialization may modify the configuration. Keep the // original config unchanged. throw new CameraNoEngineError(cloneDeep(cameraConfig)); } engines.set(engineType, engine); output.set(cameraConfig, engine); } return output; } private async _initializeCameras(camerasConfig: CameraConfig[]): 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 || config.triggers.doorbell ); }; 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. Attempt to do this once upfront, to avoid each // camera doing needing to fetch entity state. await this._api.getEntityRegistryManager().fetchEntityList(hass); } // Engines are created sequentially, to avoid duplicate creation of the same // engine. See: https://github.com/dermotduffy/advanced-camera-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 CameraNoIDError(camera.getConfig()); } if (cameraIDs.has(cameraID)) { await destroyCameras(); throw new CameraDuplicateIDError(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(), 'Advanced Camera 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 getDefaultQueryParameters( cameraID: string, queryType: QueryType, ): DefaultQueryParameters { const camera = this._store.getCamera(cameraID); if (!camera) { return {}; } return camera.getEngine().getDefaultQueryParameters(camera, queryType); } 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 generateDefaultReviewQueries( cameraIDs: string | Set, partialQuery?: PartialReviewQuery, ): ReviewQuery[] | null { return this._generateDefaultQueries(cameraIDs, { type: QueryType.Review, ...partialQuery, }); } private _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: CameraQuery[] | null = null; /* istanbul ignore else: the else path cannot be reached -- @preserve */ if (CameraQueryClassifier.isEventQuery(partialQuery)) { queries = engine.generateDefaultEventQuery(this._store, cameraIDs, partialQuery); } else if (CameraQueryClassifier.isRecordingQuery(partialQuery)) { queries = engine.generateDefaultRecordingQuery( this._store, cameraIDs, partialQuery, ); } else if (CameraQueryClassifier.isRecordingSegmentsQuery(partialQuery)) { queries = engine.generateDefaultRecordingSegmentsQuery( this._store, cameraIDs, partialQuery, ); } else if (CameraQueryClassifier.isReviewQuery(partialQuery)) { queries = engine.generateDefaultReviewQuery( 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), ); } /** * Merge compatible queries by combining cameraIDs for queries with identical * properties (other than cameraIDs). This preserves multi-camera batching for * engines like Frigate that support querying multiple cameras at once. */ private _mergeCompatibleQueries(queries: T[]): T[] { if (queries.length <= 1) { return queries; } type CameraLessQuery = Omit; // Compare queries ignoring the camera parameter. const groups = new EqualityMap(); for (const query of queries) { const key: CameraLessQuery = omit(query, 'cameraIDs'); const existing = groups.get(key); if (existing) { // Merge cameraIDs into the existing query for (const id of query.cameraIDs) { existing.cameraIDs.add(id); } } else { // Clone with a new Set to avoid mutating the original groups.set(key, { ...query, cameraIDs: new Set(query.cameraIDs) }); } } return Array.from(groups.values()); } public async extendMediaQueries( queries: T[], results: ViewItem[], 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) { if (!ViewItemClassifier.isMedia(result)) { continue; } 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 = sortItems(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): ViewItemCapabilities | 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(), 'Advanced Camera Card CameraManager favorite request (', `Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`, 'Media:', media.getID(), ', Favorite:', favorite, ')', ); } public async reviewMedia(media: ViewMedia, reviewed: 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.reviewMedia(hass, cameraConfig, media, reviewed), ); log( this._api.getConfigManager().getCardWideConfig(), 'Advanced Camera Card CameraManager review media request (', `Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`, 'Media:', media.getID(), ', Reviewed:', reviewed, ')', ); } public areMediaQueriesResultsFresh( resultsTimestamp: Date, queries: T[] | null, ): boolean { if (!queries) { return false; } 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 ); } private async _handleQuery( query: QT | QT[], engineOptions?: EngineOptions, ): Promise>> { const _queries = this._mergeCompatibleQueries(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 (CameraQueryClassifier.isEventQuery(query)) { engineResult = (await engine.getEvents( hass, this._store, query, engineOptions, )) as Map> | null; } else if (CameraQueryClassifier.isRecordingQuery(query)) { engineResult = (await engine.getRecordings( hass, this._store, query, engineOptions, )) as Map> | null; } else if (CameraQueryClassifier.isRecordingSegmentsQuery(query)) { engineResult = (await engine.getRecordingSegments( hass, this._store, query, engineOptions, )) as Map> | null; } else if (CameraQueryClassifier.isMediaMetadataQuery(query)) { engineResult = (await engine.getMediaMetadata( hass, this._store, query, engineOptions, )) as Map> | null; } else if (CameraQueryClassifier.isReviewQuery(query)) { engineResult = (await engine.getReviews( 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(), 'Advanced Camera 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, ', Options:', engineOptions ?? {}, ']', ); return results; } private _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 ( CameraQueryClassifier.isEventQuery(query) && QueryResultClassifier.isEventQueryResult(result) ) { media = engine.generateMediaFromEvents(hass, this._store, query, result); } else if ( CameraQueryClassifier.isRecordingQuery(query) && QueryResultClassifier.isRecordingQueryResult(result) ) { media = engine.generateMediaFromRecordings(hass, this._store, query, result); } else if ( CameraQueryClassifier.isReviewQuery(query) && QueryResultClassifier.isReviewQueryResult(result) ) { media = engine.generateMediaFromReviews(hass, this._store, query, result); } if (media) { mediaArray.push(...media); } } } return sortItems(mediaArray); } public getCameraEndpoints( cameraID: string, context?: CameraEndpointsContext, ): CameraEndpoints | null { return this._store.getCamera(cameraID)?.getEndpoints(context) ?? null; } 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')), }); } /** * Rotate a PTZ action based on camera rotation setting. * When camera view is rotated, PTZ controls should logically rotate too. * For example: with 90° rotation, pressing "left" should send "down" to camera. */ private _rotatePTZAction(action: PTZAction, rotation?: Rotation): PTZAction { if ( !rotation || action === 'preset' || action === 'zoom_in' || action === 'zoom_out' ) { return action; } // Directions in clockwise order for rotation calculation. const CLOCKWISE: PTZPanTiltAction[] = ['up', 'right', 'down', 'left']; const index = CLOCKWISE.indexOf(action); // Each 90° rotation shifts the direction index counter-clockwise. const shift = (4 - rotation / 90) % 4; return CLOCKWISE[(index + shift) % 4]; } public async executePTZAction( cameraID: string, action: PTZAction, options?: { phase?: PTZActionPhase; preset?: string; }, ): Promise { const camera = this._store.getCamera(cameraID); if (!camera) { return; } const rotatedAction = this._rotatePTZAction( action, camera.getConfig().dimensions?.rotation, ); const hass = this._api.getHASSManager().getHASS(); await this._requestLimit.add(() => camera.executePTZAction(this._api.getActionsManager(), rotatedAction, { ...options, hass: hass ?? undefined, }), ); } }