Refactor card.ts substantially and test.

This commit is contained in:
Dermot Duffy
2023-10-02 16:30:05 -07:00
parent 4cf13b1645
commit bfdbe70d6c
92 changed files with 8404 additions and 2996 deletions
+1 -5
View File
@@ -1,6 +1,6 @@
import { HomeAssistant } from 'custom-card-helpers';
import { localize } from '../localize/localize';
import { CameraConfig, CardWideConfig } from '../types';
import { CameraConfig } from '../types';
import { BrowseMediaManager } from '../utils/ha/browse-media/browse-media-manager';
import { BrowseMedia } from '../utils/ha/browse-media/types';
import { EntityRegistryManager } from '../utils/ha/entity-registry';
@@ -15,15 +15,12 @@ import { getCameraEntityFromConfig } from './util';
export class CameraManagerEngineFactory {
protected _entityRegistryManager: EntityRegistryManager;
protected _resolvedMediaCache: ResolvedMediaCache;
protected _cardWideConfig: CardWideConfig;
constructor(
entityRegistryManager: EntityRegistryManager,
resolvedMediaCache: ResolvedMediaCache,
cardWideConfig: CardWideConfig,
) {
this._entityRegistryManager = entityRegistryManager;
this._cardWideConfig = cardWideConfig;
this._resolvedMediaCache = resolvedMediaCache;
}
@@ -37,7 +34,6 @@ export class CameraManagerEngineFactory {
case Engine.Frigate:
const { FrigateCameraManagerEngine } = await import('./frigate/engine-frigate');
cameraManagerEngine = new FrigateCameraManagerEngine(
this._cardWideConfig,
new RecordingSegmentsCache(),
new RequestCache(),
);
+44 -61
View File
@@ -1,24 +1,52 @@
import { HomeAssistant } from 'custom-card-helpers';
import add from 'date-fns/add';
import endOfHour from 'date-fns/endOfHour';
import format from 'date-fns/format';
import fromUnixTime from 'date-fns/fromUnixTime';
import startOfHour from 'date-fns/startOfHour';
import { CameraConfig, CardWideConfig, ExtendedHomeAssistant } from '../../types';
import isEqual from 'lodash-es/isEqual';
import orderBy from 'lodash-es/orderBy';
import throttle from 'lodash-es/throttle';
import uniq from 'lodash-es/uniq';
import uniqWith from 'lodash-es/uniqWith';
import { localize } from '../../localize/localize';
import { CameraConfig, ExtendedHomeAssistant } from '../../types';
import {
allPromises,
formatDate,
prettifyTitle,
runWhenIdleIfSupported,
} from '../../utils/basic';
import { getEntityTitle } from '../../utils/ha';
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
import { Entity } from '../../utils/ha/entity-registry/types';
import { ViewMedia } from '../../view/media';
import { ViewMediaClassifier } from '../../view/media-classifier';
import { RecordingSegmentsCache, RequestCache } from '../cache';
import {
CameraManagerEngine,
CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT,
CameraManagerEngine,
} from '../engine';
import { CameraInitializationError } from '../error';
import { GenericCameraManagerEngine } from '../generic/engine-generic';
import { DateRange } from '../range';
import {
CameraManagerCameraMetadata,
CameraConfigs,
CameraEndpoint,
CameraEndpoints,
CameraEndpointsContext,
CameraManagerCameraCapabilities,
CameraManagerCameraMetadata,
CameraManagerMediaCapabilities,
DataQuery,
Engine,
EngineOptions,
EventQuery,
EventQueryResults,
EventQueryResultsMap,
MediaMetadataQuery,
MediaMetadataQueryResults,
MediaMetadataQueryResultsMap,
PartialEventQuery,
PartialRecordingQuery,
PartialRecordingSegmentsQuery,
@@ -32,56 +60,26 @@ import {
RecordingSegment,
RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap,
CameraEndpointsContext,
CameraConfigs,
CameraEndpoints,
CameraEndpoint,
MediaMetadataQuery,
MediaMetadataQueryResults,
MediaMetadataQueryResultsMap,
EngineOptions,
} from '../types';
import { getCameraEntityFromConfig } from '../util';
import frigateLogo from './assets/frigate-logo-dark.svg';
import { FrigateViewMediaFactory } from './media';
import { FrigateViewMediaClassifier } from './media-classifier';
import {
FrigateEventQueryResults,
FrigateRecordingQueryResults,
FrigateRecordingSegmentsQueryResults,
FrigateRecording,
} from './types';
import {
getEvents,
getEventSummary,
getRecordingSegments,
getRecordingsSummary,
NativeFrigateEventQuery,
NativeFrigateRecordingSegmentsQuery,
getEventSummary,
getEvents,
getRecordingSegments,
getRecordingsSummary,
retainEvent,
} from './requests';
import isEqual from 'lodash-es/isEqual';
import orderBy from 'lodash-es/orderBy';
import throttle from 'lodash-es/throttle';
import uniqWith from 'lodash-es/uniqWith';
import {
allPromises,
formatDate,
prettifyTitle,
runWhenIdleIfSupported,
} from '../../utils/basic';
import fromUnixTime from 'date-fns/fromUnixTime';
import sum from 'lodash-es/sum';
import { FrigateViewMediaClassifier } from './media-classifier';
import { ViewMediaClassifier } from '../../view/media-classifier';
import { FrigateViewMediaFactory } from './media';
import { log } from '../../utils/debug';
import { getEntityTitle } from '../../utils/ha';
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
import { Entity } from '../../utils/ha/entity-registry/types';
import { CameraInitializationError } from '../error';
import { localize } from '../../localize/localize';
import uniq from 'lodash-es/uniq';
import format from 'date-fns/format';
import { GenericCameraManagerEngine } from '../generic/engine-generic';
import frigateLogo from './assets/frigate-logo-dark.svg';
import { getCameraEntityFromConfig } from '../util';
FrigateEventQueryResults,
FrigateRecording,
FrigateRecordingQueryResults,
FrigateRecordingSegmentsQueryResults,
} from './types';
const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
@@ -120,7 +118,6 @@ export class FrigateCameraManagerEngine
{
protected _recordingSegmentsCache: RecordingSegmentsCache;
protected _requestCache: RequestCache;
protected _cardWideConfig: CardWideConfig;
// Garbage collect segments at most once an hour.
protected _throttledSegmentGarbageCollector = throttle(
@@ -130,12 +127,10 @@ export class FrigateCameraManagerEngine
);
constructor(
cardWideConfig: CardWideConfig,
recordingSegmentsCache: RecordingSegmentsCache,
requestCache: RequestCache,
) {
super();
this._cardWideConfig = cardWideConfig;
this._recordingSegmentsCache = recordingSegmentsCache;
this._requestCache = requestCache;
}
@@ -989,12 +984,6 @@ export class FrigateCameraManagerEngine
type: QueryType.Recording,
};
const countSegments = () =>
sum(
cameraIDs.map((cameraID) => this._recordingSegmentsCache.getSize(cameraID) ?? 0),
);
const segmentsStart = countSegments();
// Performance: _recordingSegments is potentially very large (e.g. 10K - 1M
// items) and each item must be examined, so care required here to stick to
// nothing worse than O(n) performance.
@@ -1029,12 +1018,6 @@ export class FrigateCameraManagerEngine
},
);
}
log(
this._cardWideConfig,
'Frigate Card recording segment garbage collection: ' +
`Released ${segmentsStart - countSegments()} segment(s)`,
);
}
/**
+115 -97
View File
@@ -1,23 +1,30 @@
import { HomeAssistant } from 'custom-card-helpers';
import {
CameraConfig,
CamerasConfig,
CardWideConfig,
ExtendedHomeAssistant,
} from '../types.js';
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 { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js';
import { localize } from '../localize/localize.js';
import { CameraConfig, CamerasConfig } from '../types.js';
import { allPromises, arrayify, setify } from '../utils/basic.js';
import { getCameraID } from '../utils/camera.js';
import { CardCameraAPI } from '../utils/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 {
CameraManagerCameraCapabilities,
CameraEndpoint, CameraEndpoints, CameraEndpointsContext, CameraManagerCameraCapabilities,
CameraManagerCameraMetadata,
CameraManagerCapabilities,
CameraManagerMediaCapabilities,
CameraEndpointsContext,
DataQuery,
EventQuery,
CameraManagerMediaCapabilities, DataQuery, Engine, EngineOptions, EventQuery,
EventQueryResults,
EventQueryResultsMap,
MediaMetadata,
MediaQuery,
MediaMetadata, MediaMetadataQuery,
MediaMetadataQueryResults, MediaQuery,
PartialDataQuery,
PartialEventQuery,
PartialQueryConcreteType,
@@ -33,27 +40,8 @@ import {
RecordingSegmentsQuery,
RecordingSegmentsQueryResults,
RecordingSegmentsQueryResultsMap,
ResultsMap,
CameraEndpoints,
Engine,
MediaMetadataQuery,
MediaMetadataQueryResults,
EngineOptions,
CameraEndpoint,
ResultsMap
} from './types.js';
import { CameraManagerEngineFactory } from './engine-factory.js';
import { ViewMedia } from '../view/media.js';
import { CameraManagerEngine } from './engine.js';
import sum from 'lodash-es/sum';
import add from 'date-fns/add';
import { log } from '../utils/debug.js';
import { EntityRegistryManager } from '../utils/ha/entity-registry/index.js';
import { getCameraID } from '../utils/camera.js';
import { localize } from '../localize/localize.js';
import { CameraInitializationError } from './error.js';
import { CameraManagerReadOnlyConfigStore, CameraManagerStore } from './store.js';
import cloneDeep from 'lodash-es/cloneDeep';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js';
import { sortMedia } from './util.js';
class QueryClassifier {
@@ -112,25 +100,51 @@ interface InitializedCamera {
}
export class CameraManager {
protected _api: CardCameraAPI;
protected _engineFactory: CameraManagerEngineFactory;
protected _cardWideConfig?: CardWideConfig;
protected _store: CameraManagerStore;
protected _store = new CameraManagerStore();
constructor(
engineFactory: CameraManagerEngineFactory,
cardWideConfig?: CardWideConfig,
) {
this._engineFactory = engineFactory;
this._cardWideConfig = cardWideConfig;
this._store = new CameraManagerStore();
constructor(api: CardCameraAPI) {
this._api = api;
this._engineFactory = new CameraManagerEngineFactory(
this._api.getEntityRegistryManager(),
this._api.getResolvedMediaCache(),
);
}
public async initializeCamerasFromConfig(): Promise<void> {
const config = this._api.getConfigManager().getConfig();
const hass = this._api.getHASSManager().getHASS();
if (!config || !hass) {
return;
}
// 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(
hass: HomeAssistant,
camerasConfig: CamerasConfig,
): Promise<Map<CameraConfig, CameraManagerEngine>> {
const output: Map<CameraConfig, CameraManagerEngine> = new Map();
const engines: Map<Engine, CameraManagerEngine> = new Map();
const hass = this._api.getHASSManager().getHASS();
if (!hass) {
return output;
}
const getEngineTypes = async (configs: CameraConfig[]) => {
return await allPromises(configs, (config) =>
@@ -142,7 +156,7 @@ export class CameraManager {
for (const [index, cameraConfig] of camerasConfig.entries()) {
const engineType = engineTypes[index];
const engine = engineType
? engines.get(engineType) ?? await this._engineFactory.createEngine(engineType)
? engines.get(engineType) ?? (await this._engineFactory.createEngine(engineType))
: null;
if (!engine || !engineType) {
throw new CameraInitializationError(
@@ -177,12 +191,13 @@ export class CameraManager {
};
}
public async initializeCameras(
hass: HomeAssistant,
entityRegistryManager: EntityRegistryManager,
camerasConfig: CamerasConfig,
): Promise<void> {
protected async _initializeCameras(camerasConfig: CamerasConfig): Promise<void> {
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;
@@ -195,18 +210,23 @@ export class CameraManager {
// ... 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 entityRegistryManager.fetchEntityList(hass);
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(hass, camerasConfig);
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, entityRegistryManager, cameraConfig),
await this._initializeCamera(
hass,
engine,
this._api.getEntityRegistryManager(),
cameraConfig,
),
);
// Do the additions based off the result-order, to ensure the map order is
@@ -236,7 +256,7 @@ export class CameraManager {
}
log(
this._cardWideConfig,
this._api.getConfigManager().getCardWideConfig(),
'Frigate Card CameraManager initialized (Cameras: ',
this._store.getCameras(),
`, Duration: ${
@@ -284,7 +304,7 @@ export class CameraManager {
});
}
public async getMediaMetadata(hass: HomeAssistant): Promise<MediaMetadata | 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();
@@ -295,7 +315,7 @@ export class CameraManager {
cameraIDs: this._store.getCameraIDs(),
};
const results = await this._handleQuery(hass, query);
const results = await this._handleQuery(query);
for (const result of results?.values() ?? []) {
if (result.metadata.tags) {
@@ -365,47 +385,46 @@ export class CameraManager {
}
public async getEvents(
hass: HomeAssistant,
query: EventQuery | EventQuery[],
engineOptions?: EngineOptions,
): Promise<EventQueryResultsMap> {
return await this._handleQuery(hass, query, engineOptions);
return await this._handleQuery(query, engineOptions);
}
public async getRecordings(
hass: HomeAssistant,
query: RecordingQuery | RecordingQuery[],
engineOptions?: EngineOptions,
): Promise<RecordingQueryResultsMap> {
return await this._handleQuery(hass, query, engineOptions);
return await this._handleQuery(query, engineOptions);
}
public async getRecordingSegments(
hass: HomeAssistant,
query: RecordingSegmentsQuery | RecordingSegmentsQuery[],
engineOptions?: EngineOptions,
): Promise<RecordingSegmentsQueryResultsMap> {
return await this._handleQuery(hass, query, engineOptions);
return await this._handleQuery(query, engineOptions);
}
public async executeMediaQueries<T extends MediaQuery>(
hass: HomeAssistant,
queries: T[],
engineOptions?: EngineOptions,
): Promise<ViewMedia[] | null> {
return this._convertQueryResultsToMedia(
hass,
await this._handleQuery(hass, queries, engineOptions),
await this._handleQuery(queries, engineOptions),
);
}
public async extendMediaQueries<T extends MediaQuery>(
hass: HomeAssistant,
queries: T[],
results: ViewMedia[],
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) {
@@ -423,8 +442,8 @@ export class CameraManager {
};
const chunkSize =
this._cardWideConfig?.performance?.features.media_chunk_size ??
MEDIA_CHUNK_SIZE_DEFAULT;
this._api.getConfigManager().getCardWideConfig()?.performance?.features
.media_chunk_size ?? MEDIA_CHUNK_SIZE_DEFAULT;
// The queries associated with the chunk to fetch.
const newChunkQueries: T[] = [];
@@ -456,8 +475,7 @@ export class CameraManager {
}
const newChunkMedia = this._convertQueryResultsToMedia(
hass,
await this._handleQuery(hass, newChunkQueries, engineOptions),
await this._handleQuery(newChunkQueries, engineOptions),
);
if (!newChunkMedia.length) {
@@ -478,14 +496,12 @@ export class CameraManager {
};
}
public async getMediaDownloadPath(
hass: ExtendedHomeAssistant,
media: ViewMedia,
): Promise<CameraEndpoint | null> {
public async getMediaDownloadPath(media: ViewMedia): Promise<CameraEndpoint | null> {
const cameraConfig = this._store.getCameraConfigForMedia(media);
const engine = this._store.getEngineForMedia(media);
const hass = this._api.getHASSManager().getHASS();
if (!cameraConfig || !engine) {
if (!cameraConfig || !engine || !hass) {
return null;
}
return await engine.getMediaDownloadPath(hass, cameraConfig, media);
@@ -499,15 +515,12 @@ export class CameraManager {
return engine.getMediaCapabilities(media);
}
public async favoriteMedia(
hass: HomeAssistant,
media: ViewMedia,
favorite: boolean,
): Promise<void> {
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) {
if (!cameraConfig || !engine || !hass) {
return;
}
@@ -515,7 +528,7 @@ export class CameraManager {
await engine.favoriteMedia(hass, cameraConfig, media, favorite);
log(
this._cardWideConfig,
this._api.getConfigManager().getCardWideConfig(),
'Frigate Card CameraManager favorite request (',
`Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`,
'Media:',
@@ -550,16 +563,15 @@ export class CameraManager {
return true;
}
public async getMediaSeekTime(
hass: HomeAssistant,
media: ViewMedia,
target: Date,
): Promise<number | null> {
public async getMediaSeekTime(media: ViewMedia, target: Date): Promise<number | null> {
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 ||
@@ -574,13 +586,17 @@ export class CameraManager {
}
protected async _handleQuery<QT extends DataQuery>(
hass: HomeAssistant,
query: QT | QT[],
engineOptions?: EngineOptions,
): Promise<Map<QT, QueryReturnType<QT>>> {
const _queries = 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,
@@ -643,7 +659,7 @@ export class CameraManager {
);
log(
this._cardWideConfig,
this._api.getConfigManager().getCardWideConfig(),
'Frigate Card CameraManager request [Input queries:',
_queries.length,
', Cached output queries:',
@@ -662,10 +678,15 @@ export class CameraManager {
}
protected _convertQueryResultsToMedia<QT extends DataQuery>(
hass: HomeAssistant,
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);
@@ -712,13 +733,12 @@ export class CameraManager {
return engine.getCameraEndpoints(cameraConfig, context);
}
public getCameraMetadata(
hass: HomeAssistant,
cameraID: string,
): CameraManagerCameraMetadata | null {
public getCameraMetadata(cameraID: string): CameraManagerCameraMetadata | null {
const cameraConfig = this._store.getCameraConfig(cameraID);
const engine = this._store.getEngineForCameraID(cameraID);
if (!cameraConfig || !engine) {
const hass = this._api.getHASSManager().getHASS();
if (!cameraConfig || !engine || !hass) {
return null;
}
return engine.getCameraMetadata(hass, cameraConfig);
@@ -747,9 +767,7 @@ export class CameraManager {
canFavoriteRecordings: perCameraCapabilities.some(
(cap) => cap?.canFavoriteRecordings,
),
canSeek: perCameraCapabilities.some(
(cap) => cap?.canSeek,
),
canSeek: perCameraCapabilities.some((cap) => cap?.canSeek),
supportsClips: perCameraCapabilities.some((cap) => cap?.supportsClips),
supportsRecordings: perCameraCapabilities.some((cap) => cap?.supportsRecordings),