944 lines
29 KiB
TypeScript
944 lines
29 KiB
TypeScript
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<T extends MediaQuery> {
|
|
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<void> {
|
|
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<void> {
|
|
await this._store.reset();
|
|
}
|
|
|
|
private async _getEnginesForCameras(
|
|
camerasConfig: CameraConfig[],
|
|
): Promise<Map<CameraConfig, CameraManagerEngine>> {
|
|
const output: Map<CameraConfig, CameraManagerEngine> = new Map();
|
|
const engines: Map<Engine, CameraManagerEngine> = 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<void> {
|
|
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<string> = 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<string>,
|
|
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<string>,
|
|
partialQuery?: PartialRecordingQuery,
|
|
): RecordingQuery[] | null {
|
|
return this._generateDefaultQueries(cameraIDs, {
|
|
type: QueryType.Recording,
|
|
...partialQuery,
|
|
});
|
|
}
|
|
|
|
public generateDefaultRecordingSegmentsQueries(
|
|
cameraIDs: string | Set<string>,
|
|
partialQuery?: PartialRecordingSegmentsQuery,
|
|
): RecordingSegmentsQuery[] | null {
|
|
return this._generateDefaultQueries(cameraIDs, {
|
|
type: QueryType.RecordingSegments,
|
|
...partialQuery,
|
|
});
|
|
}
|
|
|
|
public generateDefaultReviewQueries(
|
|
cameraIDs: string | Set<string>,
|
|
partialQuery?: PartialReviewQuery,
|
|
): ReviewQuery[] | null {
|
|
return this._generateDefaultQueries(cameraIDs, {
|
|
type: QueryType.Review,
|
|
...partialQuery,
|
|
});
|
|
}
|
|
|
|
private _generateDefaultQueries<PQT extends PartialCameraQuery>(
|
|
cameraIDs: string | Set<string>,
|
|
partialQuery: PQT,
|
|
): PartialQueryConcreteType<PQT>[] | null {
|
|
const concreteQueries: PartialQueryConcreteType<PQT>[] = [];
|
|
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<PQT>);
|
|
}
|
|
}
|
|
return concreteQueries.length ? concreteQueries : null;
|
|
}
|
|
|
|
public async getMediaMetadata(): Promise<MediaMetadata | null> {
|
|
const tags: Set<string> = new Set();
|
|
const what: Set<string> = new Set();
|
|
const where: Set<string> = new Set();
|
|
const days: Set<string> = 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<EventQueryResultsMap> {
|
|
return await this._handleQuery(query, engineOptions);
|
|
}
|
|
|
|
public async getRecordings(
|
|
query: RecordingQuery | RecordingQuery[],
|
|
engineOptions?: EngineOptions,
|
|
): Promise<RecordingQueryResultsMap> {
|
|
return await this._handleQuery(query, engineOptions);
|
|
}
|
|
|
|
public async getRecordingSegments(
|
|
query: RecordingSegmentsQuery | RecordingSegmentsQuery[],
|
|
engineOptions?: EngineOptions,
|
|
): Promise<RecordingSegmentsQueryResultsMap> {
|
|
return await this._handleQuery(query, engineOptions);
|
|
}
|
|
|
|
public async executeMediaQueries<T extends MediaQuery>(
|
|
queries: T[],
|
|
engineOptions?: EngineOptions,
|
|
): Promise<ViewMedia[] | null> {
|
|
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<T extends CameraQuery>(queries: T[]): T[] {
|
|
if (queries.length <= 1) {
|
|
return queries;
|
|
}
|
|
|
|
type CameraLessQuery = Omit<T, 'cameraIDs'>;
|
|
|
|
// Compare queries ignoring the camera parameter.
|
|
const groups = new EqualityMap<CameraLessQuery, T>();
|
|
|
|
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<T extends MediaQuery>(
|
|
queries: T[],
|
|
results: ViewItem[],
|
|
direction: 'earlier' | 'later',
|
|
engineOptions?: EngineOptions,
|
|
): Promise<ExtendedMediaQueryResult<T> | 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<Endpoint | null> {
|
|
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<void> {
|
|
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<void> {
|
|
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<T extends MediaQuery>(
|
|
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<number | null> {
|
|
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<QT extends CameraQuery>(
|
|
query: QT | QT[],
|
|
engineOptions?: EngineOptions,
|
|
): Promise<Map<QT, QueryReturnType<QT>>> {
|
|
const _queries = this._mergeCompatibleQueries(arrayify(query));
|
|
const results = new Map<QT, QueryReturnType<QT>>();
|
|
const queryStartTime = new Date();
|
|
const hass = this._api.getHASSManager().getHASS();
|
|
|
|
if (!hass) {
|
|
return results;
|
|
}
|
|
|
|
const processEngineQuery = async (
|
|
engine: CameraManagerEngine,
|
|
query: QT,
|
|
): Promise<void> => {
|
|
let engineResult: Map<QT, QueryReturnType<QT>> | 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<QT, QueryReturnType<QT>> | null;
|
|
} else if (CameraQueryClassifier.isRecordingQuery(query)) {
|
|
engineResult = (await engine.getRecordings(
|
|
hass,
|
|
this._store,
|
|
query,
|
|
engineOptions,
|
|
)) as Map<QT, QueryReturnType<QT>> | null;
|
|
} else if (CameraQueryClassifier.isRecordingSegmentsQuery(query)) {
|
|
engineResult = (await engine.getRecordingSegments(
|
|
hass,
|
|
this._store,
|
|
query,
|
|
engineOptions,
|
|
)) as Map<QT, QueryReturnType<QT>> | null;
|
|
} else if (CameraQueryClassifier.isMediaMetadataQuery(query)) {
|
|
engineResult = (await engine.getMediaMetadata(
|
|
hass,
|
|
this._store,
|
|
query,
|
|
engineOptions,
|
|
)) as Map<QT, QueryReturnType<QT>> | null;
|
|
} else if (CameraQueryClassifier.isReviewQuery(query)) {
|
|
engineResult = (await engine.getReviews(
|
|
hass,
|
|
this._store,
|
|
query,
|
|
engineOptions,
|
|
)) as Map<QT, QueryReturnType<QT>> | null;
|
|
}
|
|
|
|
engineResult?.forEach((value, key) => results.set(key, value));
|
|
};
|
|
|
|
const processQuery = async (query: QT): Promise<void> => {
|
|
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<QT extends CameraQuery>(
|
|
results: ResultsMap<QT>,
|
|
): 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<string>): 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<void> {
|
|
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,
|
|
}),
|
|
);
|
|
}
|
|
}
|