diff --git a/src/action-handler-directive.ts b/src/action-handler-directive.ts index 170c3e41..fdfd0472 100644 --- a/src/action-handler-directive.ts +++ b/src/action-handler-directive.ts @@ -180,6 +180,29 @@ export const actionHandler = directive( }, ); +export interface ActionEventTarget extends EventTarget { + addEventListener( + event: '@action', + listener: (this: ActionEventTarget, ev: CustomEvent) => void, + options?: AddEventListenerOptions | boolean, + ): void; + addEventListener( + type: string, + callback: EventListenerOrEventListenerObject, + options?: AddEventListenerOptions | boolean, + ): void; + removeEventListener( + event: '@action', + listener: (this: ActionEventTarget, ev: CustomEvent) => void, + options?: boolean | EventListenerOptions, + ): void; + removeEventListener( + type: string, + callback: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ): void; +} + declare global { interface HTMLElementTagNameMap { 'action-handler-frigate-card': ActionHandler; diff --git a/src/automations.ts b/src/automations.ts deleted file mode 100644 index 643b610d..00000000 --- a/src/automations.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { HomeAssistant } from 'custom-card-helpers'; -import { AutomationActions, FrigateCardError } from './types.js'; -import { ConditionController } from './conditions.js'; -import { Automation, Automations } from './types.js'; -import { frigateCardHandleAction } from './utils/action.js'; -import { localize } from './localize/localize.js'; - -const MAX_NESTED_AUTOMATION_EXECUTIONS = 10; - -export class AutomationsControllerError extends FrigateCardError {} - -export class AutomationsController { - protected _automations: Automations; - protected _priorEvaluations: Map = new Map(); - - // A counter to avoid infinite loops, increases every time actions are run, - // decreases every time actions are complete. - protected _nestedAutomationExecutions = 0; - - constructor(automations: Automations) { - this._automations = automations; - } - - public execute( - element: HTMLElement, - hass: HomeAssistant, - conditionController: ConditionController, - ): void { - const actionsToRun: AutomationActions[] = []; - for (const automation of this._automations ?? []) { - const shouldExecute = conditionController.evaluateCondition(automation.conditions); - const actions = shouldExecute ? automation.actions : automation.actions_not; - const priorEvaluation = this._priorEvaluations.get(automation); - this._priorEvaluations.set(automation, shouldExecute); - if (shouldExecute !== priorEvaluation && actions) { - actionsToRun.push(actions); - } - } - - ++this._nestedAutomationExecutions; - if (this._nestedAutomationExecutions > MAX_NESTED_AUTOMATION_EXECUTIONS) { - throw new AutomationsControllerError(localize('error.too_many_automations')); - } - - actionsToRun.forEach((actions) => { - frigateCardHandleAction(element, hass, {}, actions); - }); - --this._nestedAutomationExecutions; - } -} diff --git a/src/camera-manager/browse-media/engine-browse-media.ts b/src/camera-manager/browse-media/engine-browse-media.ts index 751c00b8..55c3fbb8 100644 --- a/src/camera-manager/browse-media/engine-browse-media.ts +++ b/src/camera-manager/browse-media/engine-browse-media.ts @@ -1,22 +1,8 @@ import { HomeAssistant } from 'custom-card-helpers'; -import { CameraConfig, ExtendedHomeAssistant } from '../../types'; -import { ViewMedia } from '../../view/media'; -import { - CameraManagerMediaCapabilities, - DataQuery, - EventQuery, - PartialEventQuery, - CameraConfigs, - CameraManagerCameraCapabilities, - QueryType, - CameraEndpoint, -} from '../types'; -import { EntityRegistryManager } from '../../utils/ha/entity-registry'; -import { CameraManagerEngine } from '../engine'; -import { GenericCameraManagerEngine } from '../generic/engine-generic'; -import { CameraInitializationError } from '../error'; +import { CameraConfig } from '../../config/types'; import { localize } from '../../localize/localize'; -import { Entity } from '../../utils/ha/entity-registry/types'; +import { ExtendedHomeAssistant } from '../../types'; +import { canonicalizeHAURL } from '../../utils/ha'; import { BrowseMediaManager } from '../../utils/ha/browse-media/browse-media-manager'; import { BROWSE_MEDIA_CACHE_SECONDS, @@ -24,12 +10,27 @@ import { MEDIA_CLASS_VIDEO, RichBrowseMedia, } from '../../utils/ha/browse-media/types'; -import { BrowseMediaMetadata } from './types'; -import { rangesOverlap } from '../range'; +import { EntityRegistryManager } from '../../utils/ha/entity-registry'; +import { Entity } from '../../utils/ha/entity-registry/types'; import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media'; -import { canonicalizeHAURL } from '../../utils/ha'; +import { ViewMedia } from '../../view/media'; import { RequestCache } from '../cache'; +import { CameraManagerEngine } from '../engine'; +import { CameraInitializationError } from '../error'; +import { GenericCameraManagerEngine } from '../generic/engine-generic'; +import { rangesOverlap } from '../range'; +import { + CameraConfigs, + CameraEndpoint, + CameraManagerCameraCapabilities, + CameraManagerMediaCapabilities, + DataQuery, + EventQuery, + PartialEventQuery, + QueryType, +} from '../types'; import { BrowseMediaViewMediaFactory } from './media'; +import { BrowseMediaMetadata } from './types'; /** * A utility method to determine if a browse media object matches against a diff --git a/src/camera-manager/engine-factory.ts b/src/camera-manager/engine-factory.ts index 61dd5901..87b0e471 100644 --- a/src/camera-manager/engine-factory.ts +++ b/src/camera-manager/engine-factory.ts @@ -1,6 +1,6 @@ import { HomeAssistant } from 'custom-card-helpers'; +import { CameraConfig } from '../config/types'; import { localize } from '../localize/localize'; -import { CameraConfig, CardWideConfig } 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,20 +15,17 @@ 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; } - public async createEngine(engine: Engine): Promise { - let cameraManagerEngine: CameraManagerEngine | null = null; + public async createEngine(engine: Engine): Promise { + let cameraManagerEngine: CameraManagerEngine; switch (engine) { case Engine.Generic: const { GenericCameraManagerEngine } = await import('./generic/engine-generic'); @@ -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(), ); diff --git a/src/camera-manager/engine.ts b/src/camera-manager/engine.ts index 518113c8..89313507 100644 --- a/src/camera-manager/engine.ts +++ b/src/camera-manager/engine.ts @@ -1,11 +1,23 @@ import { HomeAssistant } from 'custom-card-helpers'; -import { CameraConfig, ExtendedHomeAssistant } from '../types'; +import { CameraConfig } from '../config/types'; +import { ExtendedHomeAssistant } from '../types'; import { EntityRegistryManager } from '../utils/ha/entity-registry'; import { ViewMedia } from '../view/media'; import { + CameraConfigs, + CameraEndpoint, + CameraEndpoints, + CameraEndpointsContext, + CameraManagerCameraCapabilities, + CameraManagerCameraMetadata, + CameraManagerMediaCapabilities, DataQuery, + Engine, + EngineOptions, EventQuery, EventQueryResultsMap, + MediaMetadataQuery, + MediaMetadataQueryResultsMap, PartialEventQuery, PartialRecordingQuery, PartialRecordingSegmentsQuery, @@ -14,17 +26,6 @@ import { RecordingQueryResultsMap, RecordingSegmentsQuery, RecordingSegmentsQueryResultsMap, - CameraManagerCameraCapabilities, - CameraManagerMediaCapabilities, - CameraManagerCameraMetadata, - CameraEndpointsContext, - CameraConfigs, - Engine, - CameraEndpoints, - MediaMetadataQuery, - MediaMetadataQueryResultsMap, - EngineOptions, - CameraEndpoint, } from './types'; export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index e4c43492..6301cd93 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -1,24 +1,53 @@ 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 { CameraConfig } from '../../config/types'; +import { localize } from '../../localize/localize'; +import { 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 +61,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 +119,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 +128,10 @@ export class FrigateCameraManagerEngine ); constructor( - cardWideConfig: CardWideConfig, recordingSegmentsCache: RecordingSegmentsCache, requestCache: RequestCache, ) { super(); - this._cardWideConfig = cardWideConfig; this._recordingSegmentsCache = recordingSegmentsCache; this._requestCache = requestCache; } @@ -989,12 +985,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 +1019,6 @@ export class FrigateCameraManagerEngine }, ); } - - log( - this._cardWideConfig, - 'Frigate Card recording segment garbage collection: ' + - `Released ${segmentsStart - countSegments()} segment(s)`, - ); } /** diff --git a/src/camera-manager/frigate/media.ts b/src/camera-manager/frigate/media.ts index f096f40f..2a4c54d9 100644 --- a/src/camera-manager/frigate/media.ts +++ b/src/camera-manager/frigate/media.ts @@ -1,12 +1,12 @@ import fromUnixTime from 'date-fns/fromUnixTime'; import isEqual from 'lodash-es/isEqual'; -import { CameraConfig } from '../../types'; +import { CameraConfig } from '../../config/types'; import { - ViewMedia, EventViewMedia, RecordingViewMedia, - ViewMediaType, VideoContentType, + ViewMedia, + ViewMediaType, } from '../../view/media'; import { FrigateEvent, FrigateRecording } from './types'; import { diff --git a/src/camera-manager/frigate/util.ts b/src/camera-manager/frigate/util.ts index 24c8f532..0f5952a3 100644 --- a/src/camera-manager/frigate/util.ts +++ b/src/camera-manager/frigate/util.ts @@ -1,5 +1,6 @@ import utcToZonedTime from 'date-fns-tz/utcToZonedTime'; -import { CameraConfig, ClipsOrSnapshots } from '../../types'; +import { CameraConfig } from '../../config/types'; +import { ClipsOrSnapshots } from '../../types'; import { formatDateAndTime, prettifyTitle } from '../../utils/basic'; import { FrigateEvent, FrigateRecording } from './types'; diff --git a/src/camera-manager/generic/engine-generic.ts b/src/camera-manager/generic/engine-generic.ts index 9e9207ba..dff7966c 100644 --- a/src/camera-manager/generic/engine-generic.ts +++ b/src/camera-manager/generic/engine-generic.ts @@ -1,35 +1,36 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { HomeAssistant } from 'custom-card-helpers'; -import { CameraConfig, ExtendedHomeAssistant } from '../../types'; +import { CameraConfig } from '../../config/types'; +import { ExtendedHomeAssistant } from '../../types'; +import { getEntityIcon, getEntityTitle } from '../../utils/ha'; +import { EntityRegistryManager } from '../../utils/ha/entity-registry'; import { ViewMedia } from '../../view/media'; +import { CameraManagerEngine } from '../engine'; import { + CameraConfigs, + CameraEndpoint, + CameraEndpoints, + CameraEndpointsContext, + CameraManagerCameraCapabilities, CameraManagerCameraMetadata, CameraManagerMediaCapabilities, DataQuery, + Engine, + EngineOptions, EventQuery, EventQueryResultsMap, + MediaMetadataQuery, + MediaMetadataQueryResultsMap, PartialEventQuery, PartialRecordingQuery, PartialRecordingSegmentsQuery, + QueryReturnType, + RecordingQuery, RecordingQueryResultsMap, RecordingSegmentsQuery, RecordingSegmentsQueryResultsMap, - CameraEndpointsContext, - CameraConfigs, - RecordingQuery, - QueryReturnType, - CameraManagerCameraCapabilities, - Engine, - CameraEndpoints, - MediaMetadataQuery, - MediaMetadataQueryResultsMap, - EngineOptions, - CameraEndpoint, } from '../types'; -import { getEntityIcon, getEntityTitle } from '../../utils/ha'; -import { EntityRegistryManager } from '../../utils/ha/entity-registry'; -import { CameraManagerEngine } from '../engine'; export class GenericCameraManagerEngine implements CameraManagerEngine { public getEngineType(): Engine { diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index b686082f..3a6f3719 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -1,22 +1,38 @@ 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 { 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, - CameraEndpointsContext, DataQuery, + Engine, + EngineOptions, EventQuery, EventQueryResults, EventQueryResultsMap, MediaMetadata, + MediaMetadataQuery, + MediaMetadataQueryResults, MediaQuery, PartialDataQuery, PartialEventQuery, @@ -34,26 +50,7 @@ import { RecordingSegmentsQueryResults, RecordingSegmentsQueryResultsMap, ResultsMap, - CameraEndpoints, - Engine, - MediaMetadataQuery, - MediaMetadataQueryResults, - EngineOptions, - CameraEndpoint, } 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 +109,53 @@ 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 { + 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( - hass: HomeAssistant, 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) => @@ -142,7 +167,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 +202,13 @@ export class CameraManager { }; } - public async initializeCameras( - hass: HomeAssistant, - entityRegistryManager: EntityRegistryManager, - camerasConfig: CamerasConfig, - ): Promise { + 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; @@ -195,18 +221,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 +267,7 @@ export class CameraManager { } log( - this._cardWideConfig, + this._api.getConfigManager().getCardWideConfig(), 'Frigate Card CameraManager initialized (Cameras: ', this._store.getCameras(), `, Duration: ${ @@ -284,7 +315,7 @@ export class CameraManager { }); } - public async getMediaMetadata(hass: HomeAssistant): Promise { + public async getMediaMetadata(): Promise { const tags: Set = new Set(); const what: Set = new Set(); const where: Set = new Set(); @@ -295,7 +326,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 +396,46 @@ export class CameraManager { } public async getEvents( - hass: HomeAssistant, query: EventQuery | EventQuery[], engineOptions?: EngineOptions, ): Promise { - return await this._handleQuery(hass, query, engineOptions); + return await this._handleQuery(query, engineOptions); } public async getRecordings( - hass: HomeAssistant, query: RecordingQuery | RecordingQuery[], engineOptions?: EngineOptions, ): Promise { - return await this._handleQuery(hass, query, engineOptions); + return await this._handleQuery(query, engineOptions); } public async getRecordingSegments( - hass: HomeAssistant, query: RecordingSegmentsQuery | RecordingSegmentsQuery[], engineOptions?: EngineOptions, ): Promise { - return await this._handleQuery(hass, query, engineOptions); + return await this._handleQuery(query, engineOptions); } public async executeMediaQueries( - hass: HomeAssistant, queries: T[], engineOptions?: EngineOptions, ): Promise { return this._convertQueryResultsToMedia( - hass, - await this._handleQuery(hass, queries, engineOptions), + await this._handleQuery(queries, engineOptions), ); } public async extendMediaQueries( - hass: HomeAssistant, 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) { @@ -423,8 +453,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 +486,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 +507,12 @@ export class CameraManager { }; } - public async getMediaDownloadPath( - hass: ExtendedHomeAssistant, - media: ViewMedia, - ): Promise { + 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) { + if (!cameraConfig || !engine || !hass) { return null; } return await engine.getMediaDownloadPath(hass, cameraConfig, media); @@ -499,15 +526,12 @@ export class CameraManager { return engine.getMediaCapabilities(media); } - public async favoriteMedia( - hass: HomeAssistant, - media: ViewMedia, - favorite: boolean, - ): Promise { + 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) { + if (!cameraConfig || !engine || !hass) { return; } @@ -515,7 +539,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 +574,15 @@ export class CameraManager { return true; } - public async getMediaSeekTime( - hass: HomeAssistant, - media: ViewMedia, - target: Date, - ): Promise { + 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 || @@ -574,13 +597,17 @@ export class CameraManager { } protected async _handleQuery( - hass: HomeAssistant, 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, @@ -643,7 +670,7 @@ export class CameraManager { ); log( - this._cardWideConfig, + this._api.getConfigManager().getCardWideConfig(), 'Frigate Card CameraManager request [Input queries:', _queries.length, ', Cached output queries:', @@ -662,10 +689,15 @@ export class CameraManager { } protected _convertQueryResultsToMedia( - hass: HomeAssistant, 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); @@ -712,13 +744,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 +778,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), diff --git a/src/camera-manager/motioneye/engine-motioneye.ts b/src/camera-manager/motioneye/engine-motioneye.ts index badabd70..fbb2e5f8 100644 --- a/src/camera-manager/motioneye/engine-motioneye.ts +++ b/src/camera-manager/motioneye/engine-motioneye.ts @@ -1,6 +1,30 @@ import { HomeAssistant } from 'custom-card-helpers'; -import { CameraConfig } from '../../types'; +import add from 'date-fns/add'; +import endOfDay from 'date-fns/endOfDay'; +import parse from 'date-fns/parse'; +import startOfDay from 'date-fns/startOfDay'; +import orderBy from 'lodash-es/orderBy'; +import { CameraConfig } from '../../config/types'; +import { allPromises, formatDate, isValidDate } from '../../utils/basic'; +import { + BrowseMediaStep, + BrowseMediaTarget, +} from '../../utils/ha/browse-media/browse-media-manager'; +import { + BROWSE_MEDIA_CACHE_SECONDS, + BrowseMedia, + MEDIA_CLASS_IMAGE, + MEDIA_CLASS_VIDEO, + RichBrowseMedia, +} from '../../utils/ha/browse-media/types'; import { ViewMedia } from '../../view/media'; +import { + BrowseMediaCameraManagerEngine, + getViewMediaFromBrowseMediaArray, + isMediaWithinDates, +} from '../browse-media/engine-browse-media'; +import { BrowseMediaMetadata } from '../browse-media/types'; +import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine'; import { CameraConfigs, CameraEndpoint, @@ -19,32 +43,8 @@ import { QueryResultsType, QueryReturnType, } from '../types'; -import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine'; -import { - BrowseMediaStep, - BrowseMediaTarget, -} from '../../utils/ha/browse-media/browse-media-manager'; -import { allPromises, formatDate, isValidDate } from '../../utils/basic'; -import endOfDay from 'date-fns/endOfDay'; -import { - BROWSE_MEDIA_CACHE_SECONDS, - BrowseMedia, - MEDIA_CLASS_IMAGE, - MEDIA_CLASS_VIDEO, - RichBrowseMedia, -} from '../../utils/ha/browse-media/types'; -import parse from 'date-fns/parse'; -import { MotionEyeEventQueryResults } from './types'; -import orderBy from 'lodash-es/orderBy'; -import startOfDay from 'date-fns/startOfDay'; -import add from 'date-fns/add'; -import { - BrowseMediaCameraManagerEngine, - getViewMediaFromBrowseMediaArray, - isMediaWithinDates, -} from '../browse-media/engine-browse-media'; -import { BrowseMediaMetadata } from '../browse-media/types'; import motioneyeLogo from './assets/motioneye-logo.svg'; +import { MotionEyeEventQueryResults } from './types'; class MotionEyeQueryResultsClassifier { public static isMotionEyeEventQueryResults( diff --git a/src/camera-manager/store.ts b/src/camera-manager/store.ts index 7d929cb4..b5ec864e 100644 --- a/src/camera-manager/store.ts +++ b/src/camera-manager/store.ts @@ -1,4 +1,4 @@ -import { CameraConfig } from '../types'; +import { CameraConfig } from '../config/types'; import { ViewMedia } from '../view/media'; import { CameraManagerEngine } from './engine'; import { CameraConfigs, Engine } from './types'; @@ -41,6 +41,13 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore { this._enginesByType.set(engine.getEngineType(), engine); } + public reset(): void { + this._allConfigs.clear(); + this._visibleConfigs.clear(); + this._enginesByCamera.clear(); + this._enginesByType.clear(); + } + public getCameraConfig(cameraID: string): CameraConfig | null { return this._allConfigs.get(cameraID) ?? null; } @@ -74,11 +81,7 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore { } public getCameraConfigForMedia(media: ViewMedia): CameraConfig | null { - const cameraID = media.getCameraID(); - if (!cameraID) { - return null; - } - return this.getCameraConfig(cameraID); + return this.getCameraConfig(media.getCameraID()); } public getEngineOfType(engine: Engine): CameraManagerEngine | null { @@ -108,11 +111,7 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore { } public getEngineForMedia(media: ViewMedia): CameraManagerEngine | null { - const cameraID = media.getCameraID(); - if (!cameraID) { - return null; - } - return this.getEngineForCameraID(cameraID); + return this.getEngineForCameraID(media.getCameraID()); } public getAllEngines(): CameraManagerEngine[] { diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts index 19b508cc..a9c6906e 100644 --- a/src/camera-manager/types.ts +++ b/src/camera-manager/types.ts @@ -1,4 +1,4 @@ -import { CameraConfig, FrigateCardView } from '../types'; +import { CameraConfig, FrigateCardView } from '../config/types'; import { ViewMedia } from '../view/media'; // ==== diff --git a/src/camera-manager/util.ts b/src/camera-manager/util.ts index c6678b60..d8a5effc 100644 --- a/src/camera-manager/util.ts +++ b/src/camera-manager/util.ts @@ -1,13 +1,13 @@ -import startOfHour from 'date-fns/startOfHour'; -import endOfHour from 'date-fns/endOfHour'; -import startOfDay from 'date-fns/startOfDay'; import endOfDay from 'date-fns/endOfDay'; +import endOfHour from 'date-fns/endOfHour'; import endOfMinute from 'date-fns/endOfMinute'; -import { DateRange } from './range'; +import startOfDay from 'date-fns/startOfDay'; +import startOfHour from 'date-fns/startOfHour'; import orderBy from 'lodash-es/orderBy'; import uniqBy from 'lodash-es/uniqBy'; +import { CameraConfig } from '../config/types'; import { ViewMedia } from '../view/media'; -import { CameraConfig } from '../types'; +import { DateRange } from './range'; export const convertRangeToCacheFriendlyTimes = ( range: DateRange, diff --git a/src/card-controller/actions-manager.ts b/src/card-controller/actions-manager.ts new file mode 100644 index 00000000..37d50b5a --- /dev/null +++ b/src/card-controller/actions-manager.ts @@ -0,0 +1,223 @@ +import { + Actions, + ActionsConfig, + FrigateCardCustomAction, + FRIGATE_CARD_VIEW_DEFAULT, +} from '../config/types.js'; +import { + convertActionToFrigateCardCustomAction, + frigateCardHandleActionConfig, + getActionConfigGivenAction, +} from '../utils/action.js'; +import { getStreamCameraID } from '../utils/substream.js'; +import { CardActionsManagerAPI } from './types.js'; + +export class ActionsManager { + protected _api: CardActionsManagerAPI; + + constructor(api: CardActionsManagerAPI) { + this._api = api; + } + + /** + * Merge card-wide and view-specific actions. + * @returns A combined set of action. + */ + public getMergedActions(): ActionsConfig { + const view = this._api.getViewManager().getView(); + if (this._api.getMessageManager().hasMessage()) { + return {}; + } + + const config = this._api.getConfigManager().getConfig(); + let specificActions: Actions | undefined = undefined; + if (view?.is('live')) { + specificActions = config?.live.actions; + } else if (view?.isGalleryView()) { + specificActions = config?.media_gallery?.actions; + } else if (view?.isViewerView()) { + specificActions = config?.media_viewer.actions; + } else if (view?.is('image')) { + specificActions = config?.image?.actions; + } else { + return {}; + } + return { ...config?.view.actions, ...specificActions }; + } + + /** + * Handle an human interaction called on an element (e.g. 'tap'). + */ + public handleInteraction(interaction: string): void { + const hass = this._api.getHASSManager().getHASS(); + const config = this.getMergedActions(); + const actionConfig = getActionConfigGivenAction(interaction, config); + if ( + hass && + config && + interaction && + // Don't call frigateCardHandleActionConfig() unless there is explicitly an + // action defined (as it uses a default that is unhelpful for views that + // have default tap/click actions). + actionConfig + ) { + frigateCardHandleActionConfig( + this._api.getCardElementManager().getElement(), + hass, + config, + interaction, + actionConfig, + ); + } + } + + public handleActionEvent = (ev: Event): void => { + if (!('detail' in ev)) { + // The event may not actually be a CustomEvent object, but may still have a + // detail field. See: + // https://github.com/custom-cards/custom-card-helpers/blob/master/src/fire-event.ts#L70 + return; + } + + const frigateCardAction = convertActionToFrigateCardCustomAction(ev.detail); + if (frigateCardAction) { + this.executeAction(frigateCardAction); + } + }; + + /** + * Execute a card action. + * @param frigateCardAction + * @returns `true` if an action is executed. + */ + public async executeAction(frigateCardAction: FrigateCardCustomAction): Promise { + const config = this._api.getConfigManager().getConfig(); + const mediaLoadedInfoManager = this._api.getMediaLoadedInfoManager(); + + if ( + // Command not intended for this card (e.g. query string command). + frigateCardAction.card_id && + config?.card_id !== frigateCardAction.card_id + ) { + return; + } + + // Note: This function needs to process (view-related) commands even when + // _view has not yet been initialized (since it may be used to set a view + // via the querystring). + const view = this._api.getViewManager().getView(); + + const action = frigateCardAction.frigate_card_action; + + switch (action) { + case 'default': + this._api.getViewManager().setViewDefault(); + break; + case 'clip': + case 'clips': + case 'image': + case 'live': + case 'recording': + case 'recordings': + case 'snapshot': + case 'snapshots': + case 'timeline': + this._api.getViewManager().setViewByParameters({ + viewName: action, + cameraID: view?.camera, + }); + break; + case 'download': + await this._api.getDownloadManager().downloadViewerMedia(); + break; + case 'camera_ui': + this._api.getCameraURLManager().openURL(); + break; + case 'expand': + this._api.getExpandManager().toggleExpanded(); + break; + case 'fullscreen': + this._api.getFullscreenManager().toggleFullscreen(); + break; + case 'menu_toggle': + // This is a rare code path: this would only be used if someone has a + // menu toggle action configured outside of the menu itself (e.g. + // picture elements). + this._api.getCardElementManager().toggleMenu(); + break; + case 'camera_select': + const selectCameraID = frigateCardAction.camera; + if (view) { + const viewOnCameraSelect = config?.view.camera_select ?? 'current'; + const targetViewName = + viewOnCameraSelect === 'current' ? view.view : viewOnCameraSelect; + const verifiedViewName = this._api + .getViewManager() + .isViewSupportedByCamera(selectCameraID, targetViewName) + ? targetViewName + : FRIGATE_CARD_VIEW_DEFAULT; + this._api.getViewManager().setViewByParameters({ + viewName: verifiedViewName, + cameraID: selectCameraID, + }); + } + break; + case 'live_substream_select': { + this._api.getViewManager().setViewWithSubstream(frigateCardAction.camera); + break; + } + case 'live_substream_off': { + this._api.getViewManager().setViewWithoutSubstream(); + break; + } + case 'live_substream_on': { + this._api.getViewManager().setViewWithSubstream(); + break; + } + case 'media_player': + const mediaPlayer = frigateCardAction.media_player; + const mediaPlayerController = this._api.getMediaPlayerManager(); + const media = view?.queryResults?.getSelectedResult() ?? null; + + if (frigateCardAction.media_player_action === 'stop') { + await mediaPlayerController.stop(mediaPlayer); + } else if (view?.is('live')) { + await mediaPlayerController.playLive(mediaPlayer, getStreamCameraID(view)); + } else if (view?.isViewerView() && media) { + await mediaPlayerController.playMedia(mediaPlayer, media); + } + break; + case 'diagnostics': + this._api.getViewManager().setViewByParameters({ viewName: 'diagnostics' }); + break; + case 'microphone_mute': + this._api.getMicrophoneManager().mute(); + break; + case 'microphone_unmute': + await this._api.getMicrophoneManager().unmute(); + break; + case 'mute': + await mediaLoadedInfoManager.get()?.player?.mute(); + break; + case 'unmute': + await mediaLoadedInfoManager.get()?.player?.unmute(); + break; + case 'play': + await mediaLoadedInfoManager.get()?.player?.play(); + break; + case 'pause': + await mediaLoadedInfoManager.get()?.player?.pause(); + break; + case 'screenshot': + await this._api.getDownloadManager().downloadScreenshot(); + break; + case 'display_mode_select': + this._api + .getViewManager() + .setViewWithNewDisplayMode(frigateCardAction.display_mode); + break; + default: + console.warn(`Frigate card received unknown card action: ${action}`); + } + } +} diff --git a/src/card-controller/auto-update-manager.ts b/src/card-controller/auto-update-manager.ts new file mode 100644 index 00000000..96462c97 --- /dev/null +++ b/src/card-controller/auto-update-manager.ts @@ -0,0 +1,43 @@ +import { Timer } from '../utils/timer'; +import { CardAutoRefreshAPI } from './types'; + +export class AutoUpdateManager { + protected _timer = new Timer(); + protected _api: CardAutoRefreshAPI; + + constructor(api: CardAutoRefreshAPI) { + this._api = api; + } + + /** + * Set the update timer to trigger an update refresh every + * `view.update_seconds`. + */ + public startDefaultViewTimer(): void { + this._timer.stop(); + const updateSeconds = this._api.getConfigManager().getConfig() + ?.view.update_seconds; + if (updateSeconds) { + this._timer.start(updateSeconds, () => { + if (this._isAutomatedUpdateAllowed()) { + this._api.getViewManager().setViewDefault(); + } else { + // Not allowed to update this time around, but try again at the next + // interval. + this.startDefaultViewTimer(); + } + }); + } + } + + protected _isAutomatedUpdateAllowed(): boolean { + const triggers = this._api.getTriggersManager(); + const config = this._api.getConfigManager().getConfig(); + const interactionManager = this._api.getInteractionManager(); + + return ( + !triggers.isTriggered() && + (config?.view.update_force || !interactionManager.hasInteraction()) + ); + } +} diff --git a/src/card-controller/automations-manager.ts b/src/card-controller/automations-manager.ts new file mode 100644 index 00000000..cede9ed1 --- /dev/null +++ b/src/card-controller/automations-manager.ts @@ -0,0 +1,69 @@ +import { Automation, AutomationActions, Automations } from '../config/types.js'; +import { localize } from '../localize/localize.js'; +import { frigateCardHandleAction } from '../utils/action.js'; +import { CardAutomationsAPI } from './types.js'; + +const MAX_NESTED_AUTOMATION_EXECUTIONS = 10; + +export class AutomationsManager { + protected _api: CardAutomationsAPI; + + protected _automations: Automations; + protected _priorEvaluations: Map = new Map(); + + // A counter to avoid infinite loops, increases every time actions are run, + // decreases every time actions are complete. + protected _nestedAutomationExecutions = 0; + + constructor(api: CardAutomationsAPI) { + this._api = api; + } + + public setAutomationsFromConfig() { + this._automations = this._api + .getConfigManager() + .getNonOverriddenConfig()?.automations; + } + + public execute(): void { + const hass = this._api.getHASSManager().getHASS(); + + // Never execute automations if there's an error (as our automation loop + // avoidance -- which shows as an error -- would not work!). + if (!hass || this._api.getMessageManager().hasErrorMessage()) { + return; + } + + const actionsToRun: AutomationActions[] = []; + for (const automation of this._automations ?? []) { + const shouldExecute = this._api + .getConditionsManager() + .evaluateCondition(automation.conditions); + const actions = shouldExecute ? automation.actions : automation.actions_not; + const priorEvaluation = this._priorEvaluations.get(automation); + this._priorEvaluations.set(automation, shouldExecute); + if (shouldExecute !== priorEvaluation && actions) { + actionsToRun.push(actions); + } + } + + ++this._nestedAutomationExecutions; + if (this._nestedAutomationExecutions > MAX_NESTED_AUTOMATION_EXECUTIONS) { + this._api.getMessageManager().setMessageIfHigherPriority({ + type: 'error', + message: localize('error.too_many_automations'), + }); + return; + } + + actionsToRun.forEach((actions) => { + frigateCardHandleAction( + this._api.getCardElementManager().getElement(), + hass, + {}, + actions, + ); + }); + --this._nestedAutomationExecutions; + } +} diff --git a/src/card-controller/camera-url-manager.ts b/src/card-controller/camera-url-manager.ts new file mode 100644 index 00000000..58d9e782 --- /dev/null +++ b/src/card-controller/camera-url-manager.ts @@ -0,0 +1,32 @@ +import { CardCameraURLAPI } from './types'; + +export class CameraURLManager { + protected _api: CardCameraURLAPI; + + constructor(api: CardCameraURLAPI) { + this._api = api; + } + + public openURL(): void { + const url = this.getCameraURL(); + if (url) { + window.open(url); + } + } + + public hasCameraURL(): boolean { + return !!this.getCameraURL(); + } + + public getCameraURL(): string | null { + const view = this._api.getViewManager().getView(); + const media = view?.queryResults?.getSelectedResult() ?? null; + const endpoints = view?.camera + ? this._api.getCameraManager().getCameraEndpoints(view.camera, { + view: view.view, + ...(media && { media: media }), + }) ?? null + : null; + return endpoints?.ui?.endpoint ?? null; + } +} diff --git a/src/card-controller/card-element-manager.ts b/src/card-controller/card-element-manager.ts new file mode 100644 index 00000000..1df6edef --- /dev/null +++ b/src/card-controller/card-element-manager.ts @@ -0,0 +1,117 @@ +import { LitElement, ReactiveControllerHost } from 'lit'; +import { ActionEventTarget } from '../action-handler-directive'; +import { setOrRemoveAttribute } from '../utils/basic'; +import { isCardInPanel } from '../utils/ha'; +import { CardElementAPI } from './types'; + +export type ScrollCallback = () => void; +export type MenuToggleCallback = () => void; + +export type CardHTMLElement = LitElement & ReactiveControllerHost & ActionEventTarget; + +export class CardElementManager { + protected _api: CardElementAPI; + + protected _element: CardHTMLElement; + protected _scrollCallback: ScrollCallback; + protected _menuToggleCallback: MenuToggleCallback; + + constructor( + api: CardElementAPI, + element: CardHTMLElement, + scrollCallback: ScrollCallback, + menuToggleCallback: MenuToggleCallback, + ) { + this._api = api; + + this._element = element; + this._scrollCallback = scrollCallback; + this._menuToggleCallback = menuToggleCallback; + } + + public getElement(): HTMLElement { + return this._element; + } + + public scrollReset(): void { + this._scrollCallback(); + } + + public toggleMenu(): void { + this._menuToggleCallback(); + } + + public update(): void { + this._element.requestUpdate(); + } + + public hasUpdated(): boolean { + return this._element.hasUpdated; + } + + public getCardHeight(): number { + return this._element.getBoundingClientRect().height; + } + + public elementConnected(): void { + // Whether or not the card is in panel mode on the dashboard. + setOrRemoveAttribute(this._element, isCardInPanel(this._element), 'panel'); + + this._api.getFullscreenManager().connect(); + + this._element.addEventListener( + 'mousemove', + this._api.getInteractionManager().reportInteraction, + ); + this._element.addEventListener( + 'll-custom', + this._api.getActionsManager().handleActionEvent, + ); + this._element.addEventListener( + '@action', + this._api.getInteractionManager().reportInteraction, + ); + + // Listen for HA `navigate` actions. + // See: https://github.com/home-assistant/frontend/blob/273992c8e9c3062c6e49481b6d7d688a07067232/src/common/navigate.ts#L43 + window.addEventListener( + 'location-changed', + this._api.getQueryStringManager().executeAll, + ); + + // Listen for history state changes (i.e. user using the browser + // back/forward controls). + window.addEventListener('popstate', this._api.getQueryStringManager().executeAll); + + // Manually call the location change handler as the card will be + // disconnected/reconnected when dashboard 'tab' changes happen within HA. + this._api.getQueryStringManager().executeAll(); + } + + public elementDisconnected(): void { + setOrRemoveAttribute(this._element, false, 'panel'); + + // When the dashboard 'tab' is changed, the media is effectively unloaded. + this._api.getMediaLoadedInfoManager().clear(); + this._api.getFullscreenManager().disconnect(); + + this._element.removeEventListener( + 'mousemove', + this._api.getInteractionManager().reportInteraction, + ); + this._element.removeEventListener( + 'll-custom', + this._api.getActionsManager().handleActionEvent, + ); + this._element.removeEventListener( + '@action', + this._api.getInteractionManager().reportInteraction, + ); + + window.removeEventListener( + 'location-changed', + this._api.getQueryStringManager().executeAll, + ); + window.removeEventListener('popstate', this._api.getQueryStringManager().executeAll); + } +} diff --git a/src/conditions.ts b/src/card-controller/conditions-manager.ts similarity index 80% rename from src/conditions.ts rename to src/card-controller/conditions-manager.ts index 174f4c72..deba399f 100644 --- a/src/conditions.ts +++ b/src/card-controller/conditions-manager.ts @@ -1,14 +1,14 @@ import { HassEntities } from 'home-assistant-js-websocket'; import merge from 'lodash-es/merge'; -import { copyConfig } from './config-mgmt'; +import { copyConfig } from '../config-mgmt'; import { FrigateCardCondition, - FrigateCardConfig, frigateConditionalSchema, OverrideConfigurationKey, RawFrigateCardConfig, ViewDisplayMode -} from './types'; +} from '../config/types'; +import { CardConditionAPI } from './types'; interface ConditionState { view?: string; @@ -69,7 +69,7 @@ type RawOverrides = { }[]; export function getOverriddenConfig( - controller: Readonly, + manager: Readonly, config: Readonly, configOverrides?: Readonly, stateOverrides?: Partial, @@ -78,7 +78,7 @@ export function getOverriddenConfig( let overridden = false; if (configOverrides) { for (const override of configOverrides) { - if (controller.evaluateCondition(override.conditions, stateOverrides)) { + if (manager.evaluateCondition(override.conditions, stateOverrides)) { merge(output, override.overrides); overridden = true; } @@ -103,19 +103,23 @@ export function getOverridesByKey( ); } -// A tiny wrapper interface to allow the same controller to be passed around +// A tiny wrapper interface to allow the same manager to be passed around // immutably within objects that will not be equal (===). Every state change // generates a new epoch. This is used for Lit rendering to ensure changes to -// condition state are recognized as changes even though the controller is the +// condition state are recognized as changes even though the manager is the // same. -export interface ConditionControllerEpoch { - controller: Readonly; +export interface ConditionsManagerEpoch { + manager: Readonly; } -export class ConditionController { +export type ConditionsManagerListener = () => void; + +export class ConditionsManager { + protected _api: CardConditionAPI; + protected _state: ConditionState = {}; - protected _epoch: ConditionControllerEpoch = this._createEpoch(); - protected _stateListeners: (() => void)[] = []; + protected _epoch: ConditionsManagerEpoch = this._createEpoch(); + protected _listeners: ConditionsManagerListener[]; // Whether or not to include HA state in ConditionState. Doing so increases // CPU usage as HA state is pumped out very fast, so this is only enabled if @@ -124,29 +128,59 @@ export class ConditionController { protected _mediaQueries: MediaQueryList[] = []; protected _mediaQueryTrigger = () => this._triggerChange(); - constructor(config?: FrigateCardConfig) { - if (config) { - this._initConditions(config); - } + constructor(api: CardConditionAPI, listener?: ConditionsManagerListener) { + this._api = api; + this._listeners = [ + () => this._api.getConfigManager().computeOverrideConfig(), + () => this._api.getAutomationsManager().execute(), + ...(listener ? [listener] : []) + ]; } - public addStateListener(callback: () => void): void { - this._stateListeners.push(callback); - } - - public removeStateListener(callback: () => void): void { - this._stateListeners = this._stateListeners.filter( - (listener) => listener != callback, - ); - } - - public destroy(): void { + public removeConditions(): void { this._mediaQueries.forEach((mql) => mql.removeEventListener('change', this._mediaQueryTrigger), ); this._mediaQueries = []; } + public setConditionsFromConfig(): void { + this.removeConditions(); + + const getAllConditions = (): FrigateCardCondition[] => { + const config = this._api.getConfigManager().getConfig(); + const conditions: FrigateCardCondition[] = []; + config?.overrides?.forEach((override) => conditions.push(override.conditions)); + + // Element conditions can be arbitrarily nested underneath conditionals and + // custom elements that this card may not known. Here we recursively parse + // down the elements tree, parsing as we go to find valid conditions. + const getElementsConditions = (data: unknown): void => { + const parseResult = frigateConditionalSchema.safeParse(data); + if (parseResult.success) { + conditions.push(parseResult.data.conditions); + parseResult.data.elements?.forEach(getElementsConditions); + } else if (data && typeof data === 'object') { + Object.keys(data).forEach((key) => getElementsConditions(data[key])); + } + }; + config?.elements?.forEach(getElementsConditions); + return conditions; + }; + + const conditions = getAllConditions(); + this._hasHAStateConditions = conditions.some( + (condition) => !!condition.state?.length, + ); + conditions.forEach((condition) => { + if (condition.media_query) { + const mql = window.matchMedia(condition.media_query); + mql.addEventListener('change', this._mediaQueryTrigger); + this._mediaQueries.push(mql); + } + }); + } + public setState(state: Partial): void { this._state = { ...this._state, @@ -155,11 +189,11 @@ export class ConditionController { this._triggerChange(); } - get hasHAStateConditions(): boolean { + public hasHAStateConditions(): boolean { return this._hasHAStateConditions; } - public getEpoch(): ConditionControllerEpoch { + public getEpoch(): ConditionsManagerEpoch { return this._epoch; } @@ -212,46 +246,12 @@ export class ConditionController { return result; } - protected _createEpoch(): ConditionControllerEpoch { - return { controller: this }; + protected _createEpoch(): ConditionsManagerEpoch { + return { manager: this }; } protected _triggerChange(): void { this._epoch = this._createEpoch(); - this._stateListeners.forEach((listener) => listener()); - } - - protected _initConditions(config: FrigateCardConfig): void { - const getAllConditions = (config: FrigateCardConfig): FrigateCardCondition[] => { - const conditions: FrigateCardCondition[] = []; - config.overrides?.forEach((override) => conditions.push(override.conditions)); - - // Element conditions can be arbitrarily nested underneath conditionals and - // custom elements that this card may not known. Here we recursively parse - // down the elements tree, parsing as we go to find valid conditions. - const getElementsConditions = (data: unknown): void => { - const parseResult = frigateConditionalSchema.safeParse(data); - if (parseResult.success) { - conditions.push(parseResult.data.conditions); - parseResult.data.elements?.forEach(getElementsConditions); - } else if (data && typeof data === 'object') { - Object.keys(data).forEach((key) => getElementsConditions(data[key])); - } - }; - config.elements?.forEach(getElementsConditions); - return conditions; - }; - - const conditions = getAllConditions(config); - this._hasHAStateConditions = conditions.some( - (condition) => !!condition.state?.length, - ); - conditions.forEach((condition) => { - if (condition.media_query) { - const mql = window.matchMedia(condition.media_query); - mql.addEventListener('change', this._mediaQueryTrigger); - this._mediaQueries.push(mql); - } - }); + this._listeners.forEach((listener) => listener()); } } diff --git a/src/card-controller/config-manager.ts b/src/card-controller/config-manager.ts new file mode 100644 index 00000000..e597ad3a --- /dev/null +++ b/src/card-controller/config-manager.ts @@ -0,0 +1,147 @@ +import isEqual from 'lodash-es/isEqual'; +import { isConfigUpgradeable } from '../config-mgmt'; +import { + CardWideConfig, + FrigateCardConfig, + frigateCardConfigSchema, + RawFrigateCardConfig +} from '../config/types'; +import { localize } from '../localize/localize'; +import { setLowPerformanceProfile } from '../performance.js'; +import { getParseErrorPaths } from '../utils/zod.js'; +import { getOverriddenConfig } from './conditions-manager'; +import { InitializationAspect } from './initialization-manager'; +import { CardConfigAPI } from './types'; + +export class ConfigManager { + protected _api: CardConfigAPI; + + // The main base configuration object. For most usecases use getConfig() to + // get the correct configuration (which will return overrides as appropriate). + // This variable must be called `_config` or `config` to be compatible with + // card-mod. + protected _config: FrigateCardConfig | null = null; + protected _overriddenConfig: FrigateCardConfig | null = null; + protected _rawConfig: RawFrigateCardConfig | null = null; + protected _cardWideConfig: CardWideConfig | null = null; + + constructor(api: CardConfigAPI) { + this._api = api; + } + + public hasConfig(): boolean { + return !!this.getConfig(); + } + + public getConfig(): FrigateCardConfig | null { + return this._overriddenConfig ?? this._config; + } + + public getCardWideConfig(): CardWideConfig | null { + return this._cardWideConfig; + } + + public getNonOverriddenConfig(): FrigateCardConfig | null { + return this._config; + } + + public getRawConfig(): RawFrigateCardConfig | null { + return this._rawConfig; + } + + public setConfig(inputConfig?: RawFrigateCardConfig): void { + if (!inputConfig) { + throw new Error(localize('error.invalid_configuration')); + } + + const parseResult = frigateCardConfigSchema.safeParse(inputConfig); + if (!parseResult.success) { + const configUpgradeable = isConfigUpgradeable(inputConfig); + const hint = getParseErrorPaths(parseResult.error); + let upgradeMessage = ''; + if (configUpgradeable) { + upgradeMessage = `${localize('error.upgrade_available')}. `; + } + throw new Error( + upgradeMessage + + `${localize('error.invalid_configuration')}: ` + + (hint && hint.size + ? JSON.stringify([...hint], null, ' ') + : localize('error.invalid_configuration_no_hint')), + ); + } + const config = + parseResult.data.performance.profile !== 'low' + ? parseResult.data + : setLowPerformanceProfile(inputConfig, parseResult.data); + + this._rawConfig = inputConfig; + if (isEqual(this._config, config)) { + return; + } + + this._config = config; + this._cardWideConfig = { + performance: config.performance, + debug: config.debug, + }; + + this._api.getConditionsManager().setConditionsFromConfig(); + this._api.getConditionsManager().setState({ + view: undefined, + displayMode: undefined, + camera: undefined, + }); + this._api.getMediaLoadedInfoManager().clear(); + this._api.getViewManager().reset(); + this._api.getMessageManager().reset(); + this._api.getAutomationsManager().setAutomationsFromConfig(); + this._api.getStyleManager().setPerformance(); + this._api.getCardElementManager().update(); + + this.computeOverrideConfig(); + } + + public computeOverrideConfig(): void { + const conditionsManager = this._api.getConditionsManager(); + if (!this._config) { + return; + } + const overriddenConfig = getOverriddenConfig( + conditionsManager, + this._config, + this._config.overrides, + ) as FrigateCardConfig; + + // Save on Lit re-rendering costs by only updating the configuration if it + // actually changes. + if (isEqual(overriddenConfig, this._overriddenConfig)) { + return; + } + + const previousConfig = this._overriddenConfig; + this._overriddenConfig = overriddenConfig; + + this._api.getStyleManager().setMinMaxHeight(); + + if ( + previousConfig && + (!isEqual(previousConfig?.cameras, this._overriddenConfig?.cameras) || + !isEqual(previousConfig?.cameras_global, this._overriddenConfig?.cameras_global)) + ) { + this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS); + } + + if ( + previousConfig && + previousConfig?.live.microphone.always_connected !== + this._overriddenConfig?.live.microphone.always_connected + ) { + this._api + .getInitializationManager() + .uninitialize(InitializationAspect.MICROPHONE_CONNECT); + } + + this._api.getCardElementManager().update(); + } +} diff --git a/src/card-controller/controller.ts b/src/card-controller/controller.ts new file mode 100644 index 00000000..50a0ca47 --- /dev/null +++ b/src/card-controller/controller.ts @@ -0,0 +1,259 @@ +import { LovelaceCardEditor } from 'custom-card-helpers'; +import { ReactiveController } from 'lit'; +import { CameraManager } from '../camera-manager/manager'; +import { FrigateCardConfig } from '../config/types'; +import { EntityRegistryManager } from '../utils/ha/entity-registry'; +import { EntityCache } from '../utils/ha/entity-registry/cache'; +import { ResolvedMediaCache } from '../utils/ha/resolved-media'; +import { ActionsManager } from './actions-manager'; +import { AutoUpdateManager } from './auto-update-manager'; +import { AutomationsManager } from './automations-manager'; +import { CameraURLManager } from './camera-url-manager'; +import { + CardElementManager, + CardHTMLElement, + MenuToggleCallback, + ScrollCallback, +} from './card-element-manager'; +import { ConditionsManager, ConditionsManagerListener } from './conditions-manager'; +import { ConfigManager } from './config-manager'; +import { DownloadManager } from './download-manager'; +import { ExpandManager } from './expand-manager'; +import { FullscreenManager } from './fullscreen-manager'; +import { HASSManager } from './hass-manager'; +import { InitializationManager } from './initialization-manager'; +import { InteractionManager } from './interaction-manager'; +import { MediaLoadedInfoManager } from './media-info-manager'; +import { MediaPlayerManager } from './media-player-manager'; +import { MessageManager } from './message-manager'; +import { MicrophoneManager } from './microphone-manager'; +import { QueryStringManager } from './query-string-manager'; +import { StyleManager } from './style-manager'; +import { TriggersManager } from './triggers-manager'; +import { + CardActionsManagerAPI, + CardAutoRefreshAPI, + CardAutomationsAPI, + CardCameraAPI, + CardCameraURLAPI, + CardConditionAPI, + CardConfigAPI, + CardDownloadAPI, + CardElementAPI, + CardExpandAPI, + CardFullscreenAPI, + CardHASSAPI, + CardInitializerAPI, + CardInteractionAPI, + CardMediaLoadedAPI, + CardMediaPlayerAPI, + CardMessageAPI, + CardMicrophoneAPI, + CardQueryStringAPI, + CardStyleAPI, + CardTriggersAPI, + CardViewAPI, +} from './types'; +import { ViewManager } from './view-manager'; + +export class CardController + implements + CardActionsManagerAPI, + CardAutomationsAPI, + CardAutoRefreshAPI, + CardCameraAPI, + CardCameraURLAPI, + CardConditionAPI, + CardConfigAPI, + CardDownloadAPI, + CardElementAPI, + CardExpandAPI, + CardFullscreenAPI, + CardHASSAPI, + CardInitializerAPI, + CardInteractionAPI, + CardMediaLoadedAPI, + CardMediaPlayerAPI, + CardMessageAPI, + CardMicrophoneAPI, + CardQueryStringAPI, + CardStyleAPI, + CardTriggersAPI, + CardViewAPI, + ReactiveController +{ + // These properties may be used in the construction of 'managers' (and should + // be created first). + protected _entityRegistryManager = new EntityRegistryManager(new EntityCache()); + protected _resolvedMediaCache = new ResolvedMediaCache(); + + protected _actionsManager = new ActionsManager(this); + protected _automationsManager = new AutomationsManager(this); + protected _autoUpdateManager = new AutoUpdateManager(this); + protected _cameraManager = new CameraManager(this); + protected _cameraURLManager = new CameraURLManager(this); + protected _cardElementManager: CardElementManager; + protected _conditionsManager: ConditionsManager; + protected _configManager = new ConfigManager(this); + protected _downloadManager = new DownloadManager(this); + protected _expandManager = new ExpandManager(this); + protected _fullscreenManager = new FullscreenManager(this); + protected _hassManager = new HASSManager(this); + protected _initializationManager = new InitializationManager(this); + protected _interactionManager = new InteractionManager(this); + protected _mediaLoadedInfoManager = new MediaLoadedInfoManager(this); + protected _mediaPlayerManager = new MediaPlayerManager(this); + protected _messageManager = new MessageManager(this); + protected _microphoneManager = new MicrophoneManager(this); + protected _queryStringManager = new QueryStringManager(this); + protected _styleManager = new StyleManager(this); + protected _triggersManager = new TriggersManager(this); + protected _viewManager = new ViewManager(this); + + constructor( + host: CardHTMLElement, + scrollCallback: ScrollCallback, + menuToggleCallback: MenuToggleCallback, + conditionListener: ConditionsManagerListener, + ) { + host.addController(this); + + this._conditionsManager = new ConditionsManager(this, conditionListener); + this._cardElementManager = new CardElementManager( + this, + host, + scrollCallback, + menuToggleCallback, + ); + } + + // ************************************************************************* + // Accessors + // ************************************************************************* + + public getActionsManager(): ActionsManager { + return this._actionsManager; + } + + public getAutomationsManager(): AutomationsManager { + return this._automationsManager; + } + + public getAutoUpdateManager(): AutoUpdateManager { + return this._autoUpdateManager; + } + + public getCameraManager(): CameraManager { + return this._cameraManager; + } + + public getCameraURLManager(): CameraURLManager { + return this._cameraURLManager; + } + + public getCardElementManager(): CardElementManager { + return this._cardElementManager; + } + + public getConditionsManager(): ConditionsManager { + return this._conditionsManager; + } + + public static async getConfigElement(): Promise { + await import('../editor.js'); + return document.createElement('frigate-card-editor'); + } + + public getConfigManager(): ConfigManager { + return this._configManager; + } + public getDownloadManager(): DownloadManager { + return this._downloadManager; + } + + public getEntityRegistryManager(): EntityRegistryManager { + return this._entityRegistryManager; + } + + public getExpandManager(): ExpandManager { + return this._expandManager; + } + + public getFullscreenManager(): FullscreenManager { + return this._fullscreenManager; + } + + public getHASSManager(): HASSManager { + return this._hassManager; + } + + public getInitializationManager(): InitializationManager { + return this._initializationManager; + } + + public getInteractionManager(): InteractionManager { + return this._interactionManager; + } + + public getMediaLoadedInfoManager(): MediaLoadedInfoManager { + return this._mediaLoadedInfoManager; + } + + public getMediaPlayerManager(): MediaPlayerManager { + return this._mediaPlayerManager; + } + + public getMessageManager(): MessageManager { + return this._messageManager; + } + + public getMicrophoneManager(): MicrophoneManager { + return this._microphoneManager; + } + + public getQueryStringManager(): QueryStringManager { + return this._queryStringManager; + } + + public getResolvedMediaCache(): ResolvedMediaCache { + return this._resolvedMediaCache; + } + + public static getStubConfig(entities: string[]): FrigateCardConfig { + const cameraEntity = entities.find((element) => element.startsWith('camera.')); + return { + cameras: [ + { + camera_entity: cameraEntity ?? 'camera.demo', + }, + ], + // Need to use 'as unknown' to convince Typescript that this really isn't a + // mistake, despite the miniscule size of the configuration vs the full type + // description. + } as unknown as FrigateCardConfig; + } + + public getStyleManager(): StyleManager { + return this._styleManager; + } + + public getTriggersManager(): TriggersManager { + return this._triggersManager; + } + + public getViewManager(): ViewManager { + return this._viewManager; + } + + // ************************************************************************* + // Handlers + // ************************************************************************* + + public hostConnected(): void { + this.getCardElementManager().elementConnected(); + } + + public hostDisconnected(): void { + this.getCardElementManager().elementDisconnected(); + } +} diff --git a/src/card-controller/download-manager.ts b/src/card-controller/download-manager.ts new file mode 100644 index 00000000..f3dc5274 --- /dev/null +++ b/src/card-controller/download-manager.ts @@ -0,0 +1,41 @@ +import { downloadMedia, downloadURL } from '../utils/download'; +import { generateScreenshotTitle } from '../utils/screenshot'; +import { CardDownloadAPI } from './types'; + +export class DownloadManager { + protected _api: CardDownloadAPI; + + constructor(api: CardDownloadAPI) { + this._api = api; + } + + public async downloadViewerMedia(): Promise { + const media = this._api + .getViewManager() + .getView() + ?.queryResults?.getSelectedResult(); + const hass = this._api.getHASSManager().getHASS(); + + if (!media || !hass) { + return false; + } + + try { + await downloadMedia(hass, this._api.getCameraManager(), media); + } catch (error: unknown) { + this._api.getMessageManager().setErrorIfHigherPriority(error); + return false; + } + return true; + } + + public async downloadScreenshot(): Promise { + const url = await this._api + .getMediaLoadedInfoManager() + .get() + ?.player?.getScreenshotURL(); + if (url) { + downloadURL(url, generateScreenshotTitle(this._api.getViewManager().getView())); + } + } +} diff --git a/src/card-controller/expand-manager.ts b/src/card-controller/expand-manager.ts new file mode 100644 index 00000000..9516a9f0 --- /dev/null +++ b/src/card-controller/expand-manager.ts @@ -0,0 +1,31 @@ +import { CardExpandAPI } from './types'; + +export class ExpandManager { + protected _expanded = false; + protected _api: CardExpandAPI; + + constructor(api: CardExpandAPI) { + this._api = api; + } + + public isExpanded(): boolean { + return this._expanded; + } + + public toggleExpanded(): void { + this.setExpanded(!this._expanded); + } + + public setExpanded(expanded: boolean): void { + if (expanded && this._api.getFullscreenManager().isInFullscreen()) { + // Fullscreen and expanded mode are mutually exclusive. + this._api.getFullscreenManager().stopFullscreen(); + } + + this._expanded = expanded; + this._api.getConditionsManager()?.setState({ + expand: expanded, + }); + this._api.getCardElementManager().update(); + } +} diff --git a/src/card-controller/fullscreen-manager.ts b/src/card-controller/fullscreen-manager.ts new file mode 100644 index 00000000..418bf72f --- /dev/null +++ b/src/card-controller/fullscreen-manager.ts @@ -0,0 +1,47 @@ +import screenfull from 'screenfull'; +import { CardFullscreenAPI } from './types'; + +export class FullscreenManager { + protected _api: CardFullscreenAPI; + + constructor(api: CardFullscreenAPI) { + this._api = api; + } + + public connect(): void { + if (screenfull.isEnabled) { + screenfull.on('change', this._fullscreenHandler); + } + } + + public disconnect(): void { + if (screenfull.isEnabled) { + screenfull.off('change', this._fullscreenHandler); + } + } + + public isInFullscreen(): boolean { + return screenfull.isEnabled && screenfull.isFullscreen; + } + + public toggleFullscreen(): void { + screenfull.toggle(this._api.getCardElementManager().getElement()); + } + + public stopFullscreen(): void { + screenfull.exit(); + } + + protected _fullscreenHandler = (): void => { + this._api.getExpandManager().setExpanded(false); + + this._api.getConditionsManager()?.setState({ + fullscreen: this.isInFullscreen(), + }); + + // Re-render after a change to fullscreen mode to take advantage of + // the expanded screen real-estate (vs staying in aspect-ratio locked + // modes). + this._api.getCardElementManager().update(); + }; +} diff --git a/src/card-controller/hass-manager.ts b/src/card-controller/hass-manager.ts new file mode 100644 index 00000000..b449be06 --- /dev/null +++ b/src/card-controller/hass-manager.ts @@ -0,0 +1,89 @@ +import { CameraConfig } from '../config/types'; +import { localize } from '../localize/localize'; +import { ExtendedHomeAssistant } from '../types'; +import { hasHAConnectionStateChanged, isHassDifferent } from '../utils/ha'; +import { CardHASSAPI } from './types'; + +export class HASSManager { + protected _hass: ExtendedHomeAssistant | null = null; + protected _api: CardHASSAPI; + + constructor(api: CardHASSAPI) { + this._api = api; + } + + public getHASS(): ExtendedHomeAssistant | null { + return this._hass; + } + + public setHASS(hass: ExtendedHomeAssistant): void { + const getSelectedCameraConfig = (): CameraConfig | null => { + const view = this._api.getViewManager().getView(); + const cameraManager = this._api.getCameraManager(); + + return view && cameraManager + ? cameraManager?.getStore().getCameraConfig(view.camera) ?? null + : null; + }; + + const oldHass = this._hass; + this._hass = hass; + + const selectedCamera = getSelectedCameraConfig(); + + if (hasHAConnectionStateChanged(oldHass, hass)) { + if (!this._hass?.connected) { + this._api.getMessageManager().setMessageIfHigherPriority({ + message: localize('error.reconnecting'), + icon: 'mdi:lan-disconnect', + type: 'connection', + dotdotdot: true, + }); + } else { + this._api.getViewManager().setViewDefault(); + } + } else if ( + // Home Assistant pumps a lot of updates through. Re-rendering the card is + // necessary at times (e.g. to update the 'clip' view as new clips + // arrive), but also is a jarring experience for the user (e.g. if they + // are browsing the mini-gallery). Do not allow re-rendering from a Home + // Assistant update if there's been recent interaction (e.g. clicks on the + // card) or if there is media active playing. + this._isAutomatedViewUpdateAllowed() && + isHassDifferent(this._hass, oldHass, [ + ...(this._api.getConfigManager().getConfig()?.view.update_entities ?? []), + ...(selectedCamera?.triggers.entities ?? []), + ]) + ) { + // If entities being monitored have changed then reset the view to the + // default. + this._api.getViewManager().setViewDefault(); + } else if ( + isHassDifferent(this._hass, oldHass, [ + ...(this._api.getConfigManager().getConfig()?.view.render_entities ?? []), + + // Refresh the card if media player state changes: + // https://github.com/dermotduffy/frigate-hass-card/issues/881 + ...this._api.getMediaPlayerManager().getMediaPlayers(), + ]) + ) { + this._api.getCardElementManager().update(); + } + + this._api.getTriggersManager().updateTriggeredCameras(oldHass); + + if (this._api.getConditionsManager().hasHAStateConditions()) { + this._api.getConditionsManager().setState({ state: this._hass.states }); + } + + // Dark mode may depend on HASS. + this._api.getStyleManager().setLightOrDarkMode(); + } + + protected _isAutomatedViewUpdateAllowed(): boolean { + return ( + this._api.getConfigManager().getConfig()?.view.update_force || + !this._api.getInteractionManager().hasInteraction() + ); + } +} diff --git a/src/card-controller/initialization-manager.ts b/src/card-controller/initialization-manager.ts new file mode 100644 index 00000000..c6fa07da --- /dev/null +++ b/src/card-controller/initialization-manager.ts @@ -0,0 +1,134 @@ +import { loadLanguages } from '../localize/localize'; +import { sideLoadHomeAssistantElements } from '../utils/ha'; +import { Initializer } from '../utils/initializer/initializer'; +import { CardInitializerAPI } from './types'; + +export enum InitializationAspect { + LANGUAGES = 'languages', + SIDE_LOAD_ELEMENTS = 'side-load-elements', + MEDIA_PLAYERS = 'media-players', + CAMERAS = 'cameras', + MICROPHONE_CONNECT = 'microphone-connect', +} + +export class InitializationManager { + protected _api: CardInitializerAPI; + protected _initializer; + + constructor(api: CardInitializerAPI, initializer?: Initializer) { + this._api = api; + this._initializer = initializer ?? new Initializer(); + } + + public isInitializedMandatory(): boolean { + return this._initializer.isInitializedMultiple([ + InitializationAspect.LANGUAGES, + InitializationAspect.SIDE_LOAD_ELEMENTS, + InitializationAspect.CAMERAS, + ]); + } + + /** + * Initialize the hard requirements for rendering anything. + * @returns `true` if card rendering can continue. + */ + public async initializeMandatory(): Promise { + const hass = this._api.getHASSManager().getHASS(); + if (!hass) { + return false; + } + + if ( + !(await this._initializer.initializeMultipleIfNecessary({ + // Caution: Ensure nothing in this set of initializers requires + // config or languages since they will not yet have been initialized. + [InitializationAspect.LANGUAGES]: async () => await loadLanguages(hass), + [InitializationAspect.SIDE_LOAD_ELEMENTS]: async () => + await sideLoadHomeAssistantElements(), + })) + ) { + return false; + } + + if (!this._api.getConfigManager().hasConfig()) { + return false; + } + + if ( + !(await this._initializer.initializeIfNecessary( + InitializationAspect.CAMERAS, + async () => await this._api.getCameraManager().initializeCamerasFromConfig(), + )) + ) { + return false; + } + + if (!this._api.getMessageManager().hasMessage()) { + // Set a view on initial load. However, if the query string contains a + // view related action, we don't set any view here and allow that content + // to be triggered by the firstUpdated() call that runs query string + // actions. To do otherwise may cause a race condition between the default + // view and the querystring view, see: + // https://github.com/dermotduffy/frigate-hass-card/issues/1200 + const hasViewRelatedActions = this._api + .getQueryStringManager() + .hasViewRelatedActions(); + if (hasViewRelatedActions) { + this._api.getQueryStringManager().executeViewRelated(); + } else { + this._api.getViewManager().setViewDefault(); + } + } + + this._api.getCardElementManager().update(); + return true; + } + + /** + * Initialize aspects of the card that can load in the 'background'. + * @returns `true` if card rendering can continue. + */ + public async initializeBackgroundIfNecessary(): Promise { + const hass = this._api.getHASSManager().getHASS(); + const config = this._api.getConfigManager().getConfig(); + + if (!hass || !config) { + return false; + } + + if ( + this._initializer.isInitializedMultiple([ + ...(config.menu.buttons.media_player.enabled + ? [InitializationAspect.MEDIA_PLAYERS] + : []), + ...(config.live.microphone.always_connected + ? [InitializationAspect.MICROPHONE_CONNECT] + : []), + ]) + ) { + return true; + } + + if ( + !(await this._initializer.initializeMultipleIfNecessary({ + ...(config.menu.buttons.media_player.enabled && { + [InitializationAspect.MEDIA_PLAYERS]: async () => + await this._api.getMediaPlayerManager().initialize(), + }), + ...(config.live.microphone.always_connected && { + [InitializationAspect.MICROPHONE_CONNECT]: async () => + await this._api.getMicrophoneManager().connect(), + }), + })) + ) { + return false; + } + + this._api.getCardElementManager().update(); + return true; + } + + public uninitialize(aspect: InitializationAspect) { + return this._initializer.uninitialize(aspect); + } +} diff --git a/src/card-controller/interaction-manager.ts b/src/card-controller/interaction-manager.ts new file mode 100644 index 00000000..24a473b8 --- /dev/null +++ b/src/card-controller/interaction-manager.ts @@ -0,0 +1,50 @@ +import throttle from 'lodash-es/throttle'; +import { Timer } from '../utils/timer'; +import { CardInteractionAPI } from './types'; + +export class InteractionManager { + protected _timer = new Timer(); + protected _api: CardInteractionAPI; + + constructor(api: CardInteractionAPI) { + this._api = api; + } + + // The mouse handler may be called continually, throttle it to at most once + // per second for performance reasons. + public reportInteraction = throttle(() => { + this._reportInteraction(); + }, 1 * 1000); + + public hasInteraction(): boolean { + return this._timer.isRunning(); + } + + /** + * Start the user interaction ('screensaver') timer to reset the view to + * default `view.timeout_seconds` after user interaction. + */ + protected _reportInteraction(): void { + this._timer.stop(); + + // Interactions reset the trigger state. + this._api.getTriggersManager().untrigger(); + + const timeoutSeconds = this._api.getConfigManager().getConfig() + ?.view.timeout_seconds; + + if (timeoutSeconds) { + this._timer.start(timeoutSeconds, () => { + if (this._isAutomatedUpdateAllowed()) { + this._api.getViewManager().setViewDefault(); + this._api.getStyleManager().setLightOrDarkMode(); + } + }); + } + this._api.getStyleManager().setLightOrDarkMode(); + } + + protected _isAutomatedUpdateAllowed(): boolean { + return !this._api.getTriggersManager().isTriggered(); + } +} diff --git a/src/card-controller/media-info-manager.ts b/src/card-controller/media-info-manager.ts new file mode 100644 index 00000000..88dde87a --- /dev/null +++ b/src/card-controller/media-info-manager.ts @@ -0,0 +1,52 @@ +import { MediaLoadedInfo } from '../types'; +import { log } from '../utils/debug'; +import { isValidMediaLoadedInfo } from '../utils/media-info'; +import { CardMediaLoadedAPI } from './types'; + +export class MediaLoadedInfoManager { + protected _api: CardMediaLoadedAPI; + protected _current: MediaLoadedInfo | null = null; + protected _lastKnown: MediaLoadedInfo | null = null; + + constructor(api: CardMediaLoadedAPI) { + this._api = api; + } + + public set(mediaInfo: MediaLoadedInfo): void { + if (!isValidMediaLoadedInfo(mediaInfo)) { + return; + } + + log( + this._api.getConfigManager().getCardWideConfig(), + `Frigate Card media load: `, + mediaInfo, + ); + + this._current = mediaInfo; + this._lastKnown = mediaInfo; + + this._api.getConditionsManager().setState({ media_loaded: true }); + + // Fresh media information may change how the card is rendered. + this._api.getStyleManager().setExpandedMode(); + this._api.getCardElementManager().update(); + } + + public get(): MediaLoadedInfo | null { + return this._current; + } + + public getLastKnown(): MediaLoadedInfo | null { + return this._lastKnown; + } + + public clear(): void { + this._current = null; + this._api.getConditionsManager().setState({ media_loaded: false }); + } + + public has(): boolean { + return !!this._current; + } +} diff --git a/src/card-controller/media-player-manager.ts b/src/card-controller/media-player-manager.ts new file mode 100644 index 00000000..34dabdec --- /dev/null +++ b/src/card-controller/media-player-manager.ts @@ -0,0 +1,125 @@ +import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA } from '../const'; +import { ViewMedia } from '../view/media'; +import { ViewMediaClassifier } from '../view/media-classifier'; +import { errorToConsole } from '../utils/basic'; +import { Entity } from '../utils/ha/entity-registry/types'; +import { supportsFeature } from '../utils/ha/update'; +import { CardMediaPlayerAPI } from './types'; + +export class MediaPlayerManager { + protected _mediaPlayers: string[] = []; + + protected _api: CardMediaPlayerAPI; + + constructor(api: CardMediaPlayerAPI) { + this._api = api; + } + + public getMediaPlayers(): string[] { + return this._mediaPlayers; + } + + public hasMediaPlayers(): boolean { + return this._mediaPlayers.length > 0; + } + + public async initialize(): Promise { + const hass = this._api.getHASSManager().getHASS(); + if (!hass) { + return; + } + + const isValidMediaPlayer = (entityID: string): boolean => { + if (entityID.startsWith('media_player.')) { + const stateObj = hass.states[entityID]; + if ( + stateObj && + stateObj.state !== 'unavailable' && + supportsFeature(stateObj, MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA) + ) { + return true; + } + } + return false; + }; + + const mediaPlayers = Object.keys(hass.states).filter(isValidMediaPlayer); + let mediaPlayerEntities: Map | null = null; + try { + mediaPlayerEntities = await this._api + .getEntityRegistryManager() + .getEntities(hass, mediaPlayers); + } catch (e) { + // Failing to fetch media player information is not considered + // sufficiently serious to block card startup -- it is just logged and we + // move on. + errorToConsole(e as Error); + } + + // Filter out entities that are marked as hidden (this information is not + // available in the HA state, only in the registry). + this._mediaPlayers = mediaPlayers.filter((entityID) => { + // Specifically allow for media players that are not found in the entity registry: + // See: https://github.com/dermotduffy/frigate-hass-card/issues/1016 + const entity = mediaPlayerEntities?.get(entityID); + return !entity || !entity.hidden_by; + }); + } + + public async stop(mediaPlayer: string): Promise { + await this._api + .getHASSManager() + .getHASS() + ?.callService('media_player', 'media_stop', { + entity_id: mediaPlayer, + }); + } + + public async playLive(mediaPlayer: string, cameraID: string): Promise { + const hass = this._api.getHASSManager().getHASS(); + const cameraConfig = this._api + .getCameraManager() + .getStore() + .getCameraConfig(cameraID); + const cameraEntity = cameraConfig?.camera_entity ?? null; + + if (!hass || !cameraEntity) { + return; + } + + const title = + this._api.getCameraManager().getCameraMetadata(cameraID)?.title ?? null; + const thumbnail = hass.states[cameraEntity]?.attributes?.entity_picture ?? null; + + await hass.callService('media_player', 'play_media', { + entity_id: mediaPlayer, + media_content_id: `media-source://camera/${cameraEntity}`, + media_content_type: 'application/vnd.apple.mpegurl', + extra: { + ...(title && { title: title }), + ...(thumbnail && { thumb: thumbnail }), + }, + }); + } + + public async playMedia(mediaPlayer: string, media?: ViewMedia | null): Promise { + const hass = this._api.getHASSManager().getHASS(); + + if (!hass || !media) { + return; + } + + const title = media.getTitle(); + const thumbnail = media.getThumbnail(); + + await hass.callService('media_player', 'play_media', { + entity_id: mediaPlayer, + media_content_id: media.getContentID(), + media_content_type: ViewMediaClassifier.isVideo(media) ? 'video' : 'image', + extra: { + ...(title && { title: title }), + ...(thumbnail && { thumb: thumbnail }), + }, + }); + } +} diff --git a/src/card-controller/message-manager.ts b/src/card-controller/message-manager.ts new file mode 100644 index 00000000..fdc50335 --- /dev/null +++ b/src/card-controller/message-manager.ts @@ -0,0 +1,67 @@ +import { FrigateCardError, MESSAGE_TYPE_PRIORITIES, Message } from '../types'; +import { errorToConsole } from '../utils/basic'; +import { CardMessageAPI } from './types'; + +export class MessageManager { + protected _message: Message | null = null; + protected _api: CardMessageAPI; + + constructor(api: CardMessageAPI) { + this._api = api; + } + + public getMessage(): Message | null { + return this._message; + } + + public hasMessage(): boolean { + return !!this._message; + } + + public hasErrorMessage(): boolean { + return this._message?.type === 'error'; + } + + public reset(): void { + const hadMessage = this.hasMessage(); + this._message = null; + + if (hadMessage) { + this._api.getCardElementManager().update(); + } + } + + public setErrorIfHigherPriority(error: unknown): void { + // This object should accept unknown objects to be able to seamlessly + // process arguments to catch() which can only be unknown/any. + if (!(error instanceof Error)) { + return; + } + + errorToConsole(error); + this.setMessageIfHigherPriority({ + message: error.message, + type: 'error', + ...(error instanceof FrigateCardError && { context: error.context }), + }); + } + + public setMessageIfHigherPriority(message: Message): boolean { + const currentPriority = this._message + ? MESSAGE_TYPE_PRIORITIES[this._message.type] + : 0; + const newPriority = MESSAGE_TYPE_PRIORITIES[message.type]; + + if (this._message && newPriority < currentPriority) { + return false; + } + + this._message = message; + + // When a message is displayed it effectively unloads the media. + this._api.getMediaLoadedInfoManager().clear(); + this._api.getCardElementManager().scrollReset(); + this._api.getCardElementManager().update(); + return true; + } +} diff --git a/src/utils/microphone.ts b/src/card-controller/microphone-manager.ts similarity index 52% rename from src/utils/microphone.ts rename to src/card-controller/microphone-manager.ts index 97d5f436..d2d878b0 100644 --- a/src/utils/microphone.ts +++ b/src/card-controller/microphone-manager.ts @@ -1,7 +1,9 @@ -import { errorToConsole } from './basic'; -import { Timer } from './timer'; +import { errorToConsole } from '../utils/basic'; +import { Timer } from '../utils/timer'; +import { CardMicrophoneAPI } from './types'; -export class MicrophoneController { +export class MicrophoneManager { + protected _api: CardMicrophoneAPI; protected _stream?: MediaStream | null; protected _timer = new Timer(); @@ -10,10 +12,8 @@ export class MicrophoneController { // have the right mute status. protected _mute = true; - protected _disconnectSeconds: number; - - constructor(disconnectSeconds?: number) { - this._disconnectSeconds = disconnectSeconds ?? 0; + constructor(api: CardMicrophoneAPI) { + this._api = api; } public async connect(): Promise { @@ -32,6 +32,8 @@ export class MicrophoneController { public async disconnect(): Promise { this._stream?.getTracks().forEach((track) => track.stop()); this._stream = undefined; + + this._api.getCardElementManager().update(); } public getStream(): MediaStream | undefined { @@ -43,6 +45,8 @@ export class MicrophoneController { track.enabled = !this._mute; }); this._startTimer(); + + this._api.getCardElementManager().update(); } public mute(): void { @@ -50,9 +54,24 @@ export class MicrophoneController { this._setMute(); } - public unmute(): void { - this._mute = false; - this._setMute(); + public async unmute(): Promise { + const unmute = (): void => { + this._mute = false; + this._setMute(); + }; + + if (!this.isConnected() && !this.isForbidden()) { + // The connect() call is async and make take an arbitrary amount of + // time for the user to grant access to their microphone. With a + // momentary microphone button the mute call (on mouse release) may + // arrive before the connection is even granted, so we unmute first + // before the connection is made, so the mute call on release will not + // be 'overwritten' incorrectly. + unmute(); + await this.connect(); + } else if (this.isConnected()) { + unmute(); + } } public isConnected(): boolean { @@ -70,8 +89,17 @@ export class MicrophoneController { } protected _startTimer(): void { - if (this._disconnectSeconds) { - this._timer.start(this._disconnectSeconds, () => { + const microphoneConfig = this._api.getConfigManager().getConfig() + ?.live.microphone; + + if (microphoneConfig?.always_connected) { + return; + } + + const disconnectSeconds = microphoneConfig?.disconnect_seconds ?? 0; + + if (disconnectSeconds) { + this._timer.start(disconnectSeconds, () => { this.disconnect(); }); } diff --git a/src/card-controller/query-string-manager.ts b/src/card-controller/query-string-manager.ts new file mode 100644 index 00000000..1dabed43 --- /dev/null +++ b/src/card-controller/query-string-manager.ts @@ -0,0 +1,160 @@ +import { FrigateCardCustomAction, FrigateCardViewAction } from '../config/types'; +import { createFrigateCardCustomAction } from '../utils/action.js'; +import { CardQueryStringAPI } from './types'; +import { ViewManagerSetViewParameters } from './view-manager'; + +interface QueryStringViewIntent { + view?: ViewManagerSetViewParameters & { + default?: boolean; + }; + other?: FrigateCardCustomAction[]; +} + +export class QueryStringManager { + protected _api: CardQueryStringAPI; + + constructor(api: CardQueryStringAPI) { + this._api = api; + } + + public hasViewRelatedActions(): boolean { + return !!this._calculateIntent().view; + } + + public executeNonViewRelated(): void { + this._executeNonViewRelated(this._calculateIntent()); + } + + public executeViewRelated(): void { + this._executeViewRelated(this._calculateIntent()); + } + + public executeAll(): void { + const intent = this._calculateIntent(); + this._executeViewRelated(intent); + this._executeNonViewRelated(intent); + } + + protected _executeViewRelated(intent: QueryStringViewIntent): void { + if (intent.view) { + if (intent.view.default) { + this._api.getViewManager().setViewDefault({ + ...(intent.view.cameraID && { cameraID: intent.view.cameraID }), + ...(intent.view.substream && { substream: intent.view.substream }), + }); + } else { + this._api.getViewManager().setViewByParameters({ + ...(intent.view.viewName && { viewName: intent.view.viewName }), + ...(intent.view.cameraID && { cameraID: intent.view.cameraID }), + ...(intent.view.substream && { substream: intent.view.substream }), + }); + } + } + } + + protected _executeNonViewRelated(intent: QueryStringViewIntent): void { + // Only execute non-view actions when the card has rendered at least once. + if (!this._api.getCardElementManager().hasUpdated()) { + return; + } + + intent.other?.forEach((action) => + this._api.getActionsManager().executeAction(action), + ); + } + + protected _calculateIntent(): QueryStringViewIntent { + const result: QueryStringViewIntent = {}; + for (const action of this._getActions()) { + if (this._isViewAction(action)) { + (result.view ??= {}).viewName = action.frigate_card_action; + (result.view ??= {}).default = undefined; + } else if (action.frigate_card_action === 'default') { + (result.view ??= {}).default = true; + (result.view ??= {}).viewName = undefined; + } else if (action.frigate_card_action === 'camera_select') { + (result.view ??= {}).cameraID = action.camera; + } else if (action.frigate_card_action === 'live_substream_select') { + (result.view ??= {}).substream = action.camera; + } else { + (result.other ??= []).push(action); + } + } + return result; + } + + protected _getActions(): FrigateCardCustomAction[] { + const params = new URLSearchParams(window.location.search); + const actions: FrigateCardCustomAction[] = []; + const actionRE = new RegExp( + /^frigate-card-action([.:](?\w+))?[.:](?\w+)/, + ); + for (const [key, value] of params.entries()) { + const match = key.match(actionRE); + if (!match || !match.groups) { + continue; + } + const cardID: string | undefined = match.groups['cardID']; + const action = match.groups['action']; + + let customAction: FrigateCardCustomAction | null = null; + switch (action) { + case 'camera_select': + case 'live_substream_select': + if (value) { + customAction = createFrigateCardCustomAction(action, { + camera: value, + cardID: cardID, + }); + } + break; + case 'camera_ui': + case 'clip': + case 'clips': + case 'default': + case 'diagnostics': + case 'download': + case 'expand': + case 'image': + case 'live': + case 'menu_toggle': + case 'recording': + case 'recordings': + case 'snapshot': + case 'snapshots': + case 'timeline': + customAction = createFrigateCardCustomAction(action, { + cardID: cardID, + }); + break; + default: + console.warn( + `Frigate card received unknown card action in query string: ${action}`, + ); + } + if (customAction) { + actions.push(customAction); + } + } + return actions; + } + + protected _isViewAction = ( + action: FrigateCardCustomAction, + ): action is FrigateCardViewAction => { + switch (action.frigate_card_action) { + case 'clip': + case 'clips': + case 'diagnostics': + case 'image': + case 'live': + case 'recording': + case 'recordings': + case 'snapshot': + case 'snapshots': + case 'timeline': + return true; + } + return false; + }; +} diff --git a/src/card-controller/style-manager.ts b/src/card-controller/style-manager.ts new file mode 100644 index 00000000..d6185d5e --- /dev/null +++ b/src/card-controller/style-manager.ts @@ -0,0 +1,126 @@ +import { FrigateCardConfig } from '../config/types'; +import { setPerformanceCSSStyles } from '../performance'; +import { View } from '../view/view'; +import { setOrRemoveAttribute } from '../utils/basic'; +import { CardStyleAPI } from './types'; + +export class StyleManager { + protected _api: CardStyleAPI; + + constructor(api: CardStyleAPI) { + this._api = api; + } + + public setLightOrDarkMode(): void { + const config = this._api.getConfigManager().getConfig(); + const isDarkMode = + config?.view.dark_mode === 'on' || + (config?.view.dark_mode === 'auto' && + (!this._api.getInteractionManager().hasInteraction() || + !!this._api.getHASSManager().getHASS()?.themes.darkMode)); + + setOrRemoveAttribute( + this._api.getCardElementManager().getElement(), + isDarkMode, + 'dark', + ); + } + + public setExpandedMode(): void { + const card = this._api.getCardElementManager().getElement(); + const view = this._api.getViewManager().getView(); + + // When a new media loads, set the aspect ratio for when the card is + // expanded/popped-up. This is based exclusively on last media content, + // as dimension configuration does not apply in fullscreen or expanded mode. + const lastKnown = this._api.getMediaLoadedInfoManager().getLastKnown(); + card.style.setProperty( + '--frigate-card-expand-aspect-ratio', + view?.isAnyMediaView() && lastKnown + ? `${lastKnown.width} / ${lastKnown.height}` + : 'unset', + ); + // Non-media may have no intrinsic dimensions (or multiple media items in a + // grid) and so we need to explicit request the dialog to use all available + // space. + const isGrid = view?.isGrid(); + card.style.setProperty( + '--frigate-card-expand-width', + !isGrid && view?.isAnyMediaView() + ? 'none' + : 'var(--frigate-card-expand-max-width)', + ); + card.style.setProperty( + '--frigate-card-expand-height', + !isGrid && view?.isAnyMediaView() + ? 'none' + : 'var(--frigate-card-expand-max-height)', + ); + } + + public setMinMaxHeight(): void { + const config = this._api.getConfigManager().getConfig(); + if (config) { + const card = this._api.getCardElementManager().getElement(); + card.style.setProperty('--frigate-card-min-height', config.dimensions.min_height); + card.style.setProperty('--frigate-card-max-height', config.dimensions.max_height); + } + } + + public setPerformance(): void { + setPerformanceCSSStyles( + this._api.getCardElementManager().getElement(), + this._api.getConfigManager().getCardWideConfig()?.performance, + ); + } + + protected _isAspectRatioEnforced( + config: FrigateCardConfig, + view?: View | null, + ): boolean { + const aspectRatioMode = config.dimensions.aspect_ratio_mode; + + // Do not artifically constrain aspect ratio if: + // - It's fullscreen. + // - It's in expanded mode. + // - Aspect ratio enforcement is disabled. + // - Aspect ratio enforcement is dynamic and it's a media view (i.e. not the + // gallery) or diagnostics / timeline. + return !( + this._api.getFullscreenManager().isInFullscreen() || + this._api.getExpandManager().isExpanded() || + aspectRatioMode === 'unconstrained' || + (aspectRatioMode === 'dynamic' && + (!view || + view?.isAnyMediaView() || + view?.is('timeline') || + view?.is('diagnostics'))) + ); + } + + /** + * Get the aspect ratio padding required to enforce the aspect ratio (if it is + * required). + * @returns A padding percentage. + */ + public getAspectRatioStyle(): string { + const config = this._api.getConfigManager().getConfig(); + const view = this._api.getViewManager().getView(); + + if (config) { + if (!this._isAspectRatioEnforced(config, view)) { + return 'auto'; + } + + const aspectRatioMode = config.dimensions.aspect_ratio_mode; + + const lastKnown = this._api.getMediaLoadedInfoManager().getLastKnown(); + if (lastKnown && aspectRatioMode === 'dynamic') { + return `${lastKnown.width} / ${lastKnown.height}`; + } + + return `${config.dimensions.aspect_ratio[0]} / ${config.dimensions.aspect_ratio[1]}`; + } + return '16 / 9'; + } +} diff --git a/src/card-controller/triggers-manager.ts b/src/card-controller/triggers-manager.ts new file mode 100644 index 00000000..95605faf --- /dev/null +++ b/src/card-controller/triggers-manager.ts @@ -0,0 +1,126 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import orderBy from 'lodash-es/orderBy'; +import { getHassDifferences, isTriggeredState } from '../utils/ha'; +import { Timer } from '../utils/timer'; +import { CardTriggersAPI } from './types'; + +export class TriggersManager { + protected _api: CardTriggersAPI; + + protected _triggers: Map = new Map(); + protected _untriggerTimer = new Timer(); + + constructor(api: CardTriggersAPI) { + this._api = api; + } + + public isTriggered(): boolean { + return !!this._triggers.size || this._untriggerTimer.isRunning(); + } + + public updateTriggeredCameras(oldHass?: HomeAssistant | null): boolean { + if (!this._shouldTrackTriggers()) { + return false; + } + + const hass = this._api.getHASSManager().getHASS(); + + const now = new Date(); + let triggerChanges = false; + + const cameras = this._api.getCameraManager().getStore().getVisibleCameras(); + for (const [cameraID, config] of cameras?.entries()) { + const triggerEntities = config.triggers.entities; + const diffs = getHassDifferences(hass, oldHass, triggerEntities, { + stateOnly: true, + }); + const shouldTrigger = diffs.some((diff) => isTriggeredState(diff.newState)); + const shouldUntrigger = triggerEntities.every( + (entity) => !isTriggeredState(hass?.states[entity]), + ); + if (shouldTrigger) { + this._triggers.set(cameraID, now); + triggerChanges = true; + } else if (shouldUntrigger && this._triggers.has(cameraID)) { + this._triggers.delete(cameraID); + triggerChanges = true; + } + } + + if (triggerChanges) { + const targetCameraID = this._getMostRecentTrigger(); + if (targetCameraID) { + this._triggerAction(targetCameraID); + return true; + } else { + this._startUntriggerTimer(); + } + } + return false; + } + + public untrigger(): void { + const wasTriggered = this.isTriggered(); + this._triggers.clear(); + this._untriggerTimer.stop(); + + if (wasTriggered) { + this._untriggerAction(); + } + } + + protected _triggerAction(cameraID: string): void { + const view = this._api.getViewManager().getView(); + if ( + this._isAutomatedViewUpdateAllowed() && + (view?.camera !== cameraID || !view?.is('live')) + ) { + this._api.getViewManager().setViewByParameters({ + viewName: 'live', + cameraID: cameraID, + }); + } + } + + protected _untriggerAction(): void { + if ( + !this.isTriggered() && + this._isAutomatedViewUpdateAllowed() && + this._api.getConfigManager().getConfig()?.view.scan.untrigger_reset + ) { + this._api.getViewManager().setViewDefault(); + } + } + + protected _isAutomatedViewUpdateAllowed(): boolean { + return ( + this._api.getConfigManager().getConfig()?.view.update_force || + !this._api.getInteractionManager().hasInteraction() + ); + } + + protected _shouldTrackTriggers(): boolean { + return !!this._api.getConfigManager().getConfig()?.view.scan.enabled; + } + + protected _startUntriggerTimer(): void { + this._untriggerTimer.start( + /* istanbul ignore next: the case of config being null here cannot be + reached, as there's no way to have the untrigger call happen without + a config. -- @preserve */ + this._api.getConfigManager().getConfig()?.view.scan.untrigger_seconds ?? 0, + () => { + this._untriggerAction(); + }, + ); + } + + protected _getMostRecentTrigger(): string | null { + const sorted = orderBy( + [...this._triggers.entries()], + (entry) => entry[1].getTime(), + 'desc', + ); + return sorted.length ? sorted[0][0] : null; + } +} diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts new file mode 100644 index 00000000..28f0a115 --- /dev/null +++ b/src/card-controller/types.ts @@ -0,0 +1,214 @@ +import { CameraManager } from '../camera-manager/manager'; +import { ConditionsManager } from './conditions-manager'; +import { EntityRegistryManager } from '../utils/ha/entity-registry'; +import { ResolvedMediaCache } from '../utils/ha/resolved-media'; +import { ActionsManager } from './actions-manager'; +import { AutoUpdateManager } from './auto-update-manager'; +import { AutomationsManager } from './automations-manager'; +import { CameraURLManager } from './camera-url-manager'; +import { CardElementManager } from './card-element-manager'; +import { ConfigManager } from './config-manager'; +import { DownloadManager } from './download-manager'; +import { ExpandManager } from './expand-manager'; +import { FullscreenManager } from './fullscreen-manager'; +import { HASSManager } from './hass-manager'; +import { InitializationManager } from './initialization-manager'; +import { InteractionManager } from './interaction-manager'; +import { MediaLoadedInfoManager } from './media-info-manager'; +import { MediaPlayerManager } from './media-player-manager'; +import { MessageManager } from './message-manager'; +import { MicrophoneManager } from './microphone-manager'; +import { StyleManager } from './style-manager'; +import { TriggersManager } from './triggers-manager'; +import { ViewManager } from './view-manager'; +import { QueryStringManager } from './query-string-manager'; + +/** + * This defines a series of limited APIs that various manager helpers use to + * control the card. Explicitly specifying them helps make coupling intentional + * and avoids cyclic importing. + */ + +export interface CardActionsManagerAPI { + getCameraManager(): CameraManager; + getCameraURLManager(): CameraURLManager; + getCardElementManager(): CardElementManager; + getConfigManager(): ConfigManager; + getDownloadManager(): DownloadManager; + getExpandManager(): ExpandManager; + getFullscreenManager(): FullscreenManager; + getHASSManager(): HASSManager; + getMediaLoadedInfoManager(): MediaLoadedInfoManager; + getMediaPlayerManager(): MediaPlayerManager; + getMessageManager(): MessageManager; + getViewManager(): ViewManager; + getMicrophoneManager(): MicrophoneManager; +} + +export interface CardAutomationsAPI { + getCardElementManager(): CardElementManager; + getConditionsManager(): ConditionsManager; + getConfigManager(): ConfigManager; + getHASSManager(): HASSManager; + getMessageManager(): MessageManager; +} + +export interface CardAutoRefreshAPI { + getConfigManager(): ConfigManager; + getViewManager(): ViewManager; + getTriggersManager(): TriggersManager; + getInteractionManager(): InteractionManager; +} + +export interface CardCameraAPI { + getConfigManager(): ConfigManager; + getEntityRegistryManager(): EntityRegistryManager; + getResolvedMediaCache(): ResolvedMediaCache; + getHASSManager(): HASSManager; + getMessageManager(): MessageManager; +} + +export interface CardCameraURLAPI { + getCameraManager(): CameraManager; + getViewManager(): ViewManager; +} + +export interface CardConditionAPI { + getAutomationsManager(): AutomationsManager; + getConfigManager(): ConfigManager; +} + +export interface CardConfigAPI { + getAutomationsManager(): AutomationsManager; + getCardElementManager(): CardElementManager; + getConditionsManager(): ConditionsManager; + getInitializationManager(): InitializationManager; + getMediaLoadedInfoManager(): MediaLoadedInfoManager; + getMessageManager(): MessageManager; + getStyleManager(): StyleManager; + getViewManager(): ViewManager; +} + +export interface CardDownloadAPI { + getCameraManager(): CameraManager; + getHASSManager(): HASSManager; + getMediaLoadedInfoManager(): MediaLoadedInfoManager; + getMessageManager(): MessageManager; + getViewManager(): ViewManager; +} + +export interface CardElementAPI { + getActionsManager(): ActionsManager; + getFullscreenManager(): FullscreenManager; + getInteractionManager(): InteractionManager; + getMediaLoadedInfoManager(): MediaLoadedInfoManager; + getQueryStringManager(): QueryStringManager; +} + +export interface CardExpandAPI { + getFullscreenManager(): FullscreenManager; + getConditionsManager(): ConditionsManager; + getCardElementManager(): CardElementManager; +} + +export interface CardFullscreenAPI { + getCardElementManager(): CardElementManager; + getExpandManager(): ExpandManager; + getConditionsManager(): ConditionsManager; + getMediaPlayerManager(): MediaPlayerManager; +} + +export interface CardHASSAPI { + getCameraManager(): CameraManager; + getCardElementManager(): CardElementManager; + getConditionsManager(): ConditionsManager; + getConfigManager(): ConfigManager; + getInteractionManager(): InteractionManager; + getMediaPlayerManager(): MediaPlayerManager; + getMessageManager(): MessageManager; + getStyleManager(): StyleManager; + getTriggersManager(): TriggersManager; + getViewManager(): ViewManager; +} + +export interface CardInitializerAPI { + getCameraManager(): CameraManager; + getCardElementManager(): CardElementManager; + getConfigManager(): ConfigManager; + getEntityRegistryManager(): EntityRegistryManager; + getHASSManager(): HASSManager; + getMediaPlayerManager(): MediaPlayerManager; + getMessageManager(): MessageManager; + getMicrophoneManager(): MicrophoneManager; + getQueryStringManager(): QueryStringManager; + getResolvedMediaCache(): ResolvedMediaCache; + getViewManager(): ViewManager; +} + +export interface CardInteractionAPI { + getConfigManager(): ConfigManager; + getStyleManager(): StyleManager; + getTriggersManager(): TriggersManager; + getViewManager(): ViewManager; +} + +export interface CardMediaLoadedAPI { + getConditionsManager(): ConditionsManager; + getConfigManager(): ConfigManager; + getCardElementManager(): CardElementManager; + getStyleManager(): StyleManager; +} + +export interface CardMediaPlayerAPI { + getHASSManager(): HASSManager; + getCameraManager(): CameraManager; + getEntityRegistryManager(): EntityRegistryManager; +} + +export interface CardMessageAPI { + getConditionsManager(): ConditionsManager; + getCardElementManager(): CardElementManager; + getMediaLoadedInfoManager(): MediaLoadedInfoManager; +} + +export interface CardMicrophoneAPI { + getCardElementManager(): CardElementManager; + getConfigManager(): ConfigManager; +} + +export interface CardQueryStringAPI { + getCardElementManager(): CardElementManager; + getViewManager(): ViewManager; + getActionsManager(): ActionsManager; +} + +export interface CardStyleAPI { + getCardElementManager(): CardElementManager; + getConfigManager(): ConfigManager; + getExpandManager(): ExpandManager; + getFullscreenManager(): FullscreenManager; + getHASSManager(): HASSManager; + getInteractionManager(): InteractionManager; + getMediaLoadedInfoManager(): MediaLoadedInfoManager; + getViewManager(): ViewManager; +} + +export interface CardTriggersAPI { + getCameraManager(): CameraManager; + getConfigManager(): ConfigManager; + getHASSManager(): HASSManager; + getInteractionManager(): InteractionManager; + getViewManager(): ViewManager; +} + +export interface CardViewAPI { + getAutoUpdateManager(): AutoUpdateManager; + getCameraManager(): CameraManager; + getConfigManager(): ConfigManager; + getHASSManager(): HASSManager; + getMediaLoadedInfoManager(): MediaLoadedInfoManager; + getMessageManager(): MessageManager; + getStyleManager(): StyleManager; + getConditionsManager(): ConditionsManager; + getCardElementManager(): CardElementManager; +} diff --git a/src/card-controller/view-manager.ts b/src/card-controller/view-manager.ts new file mode 100644 index 00000000..3465f710 --- /dev/null +++ b/src/card-controller/view-manager.ts @@ -0,0 +1,286 @@ +import { ViewContext } from 'view'; +import { FrigateCardConfig, FrigateCardView, ViewDisplayMode } from '../config/types'; +import { View } from '../view/view'; +import { getAllDependentCameras } from '../utils/camera'; +import { log } from '../utils/debug'; +import { executeMediaQueryForView } from '../utils/media-to-view'; +import { CardViewAPI } from './types'; + +interface ViewManagerSetViewDefaultParameters { + cameraID?: string; + substream?: string; +} + +export interface ViewManagerSetViewParameters + extends ViewManagerSetViewDefaultParameters { + viewName?: FrigateCardView; +} + +export class ViewManager { + protected _view: View | null = null; + protected _api: CardViewAPI; + + constructor(api: CardViewAPI) { + this._api = api; + } + + public getView(): View | null { + return this._view; + } + + public setView(view: View): void { + this._setView(view); + } + + public setViewDefault(params?: ViewManagerSetViewDefaultParameters): void { + const config = this._api.getConfigManager().getConfig(); + if (config) { + let forceCameraID: string | null = params?.cameraID ?? null; + if (!forceCameraID && this._view?.camera && config.view.update_cycle_camera) { + const cameraIDs = [ + ...this._api.getCameraManager().getStore().getVisibleCameraIDs(), + ]; + const currentIndex = cameraIDs.indexOf(this._view.camera); + const targetIndex = currentIndex + 1 >= cameraIDs.length ? 0 : currentIndex + 1; + forceCameraID = cameraIDs[targetIndex]; + } + + this.setViewByParameters({ + ...params, + viewName: config.view.default, + ...(forceCameraID && { cameraID: forceCameraID }), + }); + + // Restart the refresh timer, so the default view is refreshed at a fixed + // interval from now (if so configured). + this._api.getAutoUpdateManager().startDefaultViewTimer(); + } + } + + public setViewByParameters(params: ViewManagerSetViewParameters): void { + const config = this._api.getConfigManager().getConfig(); + + if (config) { + let cameraID: string | null = null; + + const cameras = this._api.getCameraManager().getStore().getVisibleCameraIDs(); + if (cameras.size) { + if (params?.cameraID && cameras.has(params.cameraID)) { + cameraID = params.cameraID; + } else { + // Reset to the default camera. + cameraID = cameras.keys().next().value; + } + } + const viewName = params?.viewName ?? this._view?.view ?? config.view.default; + if (cameraID && viewName && this.isViewSupportedByCamera(cameraID, viewName)) { + const displayMode = + this._view?.displayMode ?? + this._getDefaultDisplayModeForView(viewName, config); + let view: View = new View({ + view: viewName, + camera: cameraID, + displayMode: displayMode, + }); + if (params.substream) { + view = this._createViewWithSelectedSubstream(view, params.substream); + } + this._setView(view); + } + } + } + + public setViewWithNewContext(context: ViewContext): void { + if (this._view) { + return this._setView(this._view?.clone().mergeInContext(context)); + } + } + + public reset(): void { + this._view = null; + } + + public async setViewWithNewDisplayMode(displayMode: ViewDisplayMode): Promise { + const hass = this._api.getHASSManager().getHASS(); + + if (this._view && hass) { + const view = this._view.evolve({ + displayMode: displayMode, + }); + + const cameraCount = this._api + .getCameraManager() + .getStore() + .getVisibleCameraCount(); + const queryCameraCount = view.query?.getQueryCameraIDs()?.size ?? 0; + const generateNewQuery = + view?.query && + queryCameraCount && + ((view.isGrid() && queryCameraCount < cameraCount) || + (!view.isGrid() && queryCameraCount > 1)); + + if (generateNewQuery && view && view.query) { + // If the user requests a grid but the current query does not have a + // query for more than one camera, reset the query results, change the + // existing query to refer to all cameras and execute it to fetch new + // results. + let viewWithNewQuery: View | null = null; + try { + viewWithNewQuery = await executeMediaQueryForView( + this._api.getCameraManager(), + view, + view.query + .clone() + .setQueryCameraIDs( + view.isGrid() + ? this._api.getCameraManager().getStore().getVisibleCameraIDs() + : view.camera, + ), + ); + } catch (e: unknown) { + this._api.getMessageManager().setErrorIfHigherPriority(e); + } + + if (viewWithNewQuery) { + return this._setView(viewWithNewQuery); + } + } else { + return this._setView(view); + } + } + } + + public setViewWithSubstream(substream?: string): void { + if (!this._view) { + return; + } + this._setView( + substream + ? this._createViewWithSelectedSubstream(this._view, substream) + : this._createViewWithNextStream(this._view), + ); + } + + public setViewWithoutSubstream(): void { + const view = this._createViewWithoutSubstream(); + if (view) { + return this._setView(view); + } + } + + public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean { + const capabilities = this._api.getCameraManager().getCameraCapabilities(cameraID); + switch (view) { + case 'live': + case 'image': + case 'diagnostics': + return true; + case 'clip': + case 'clips': + return !!capabilities?.supportsClips; + case 'snapshot': + case 'snapshots': + return !!capabilities?.supportsSnapshots; + case 'recording': + case 'recordings': + return !!capabilities?.supportsRecordings; + case 'timeline': + return !!capabilities?.supportsTimeline; + case 'media': + return ( + !!capabilities?.supportsClips || + !!capabilities?.supportsSnapshots || + !!capabilities?.supportsRecordings + ); + } + } + + protected _getDefaultDisplayModeForView( + viewName: FrigateCardView, + config?: FrigateCardConfig, + ): ViewDisplayMode { + let mode: ViewDisplayMode | null = null; + switch (viewName) { + case 'media': + case 'clip': + case 'recording': + case 'snapshot': + mode = config?.media_viewer.display?.mode ?? null; + break; + case 'live': + mode = config?.live.display?.mode ?? null; + break; + } + return mode ?? 'single'; + } + + protected _setView(view: View): void { + const oldView = this._view; + View.adoptFromViewIfAppropriate(view, oldView); + + log( + this._api.getConfigManager().getCardWideConfig(), + `Frigate Card view change: `, + view.view, + ); + this._view = view; + + if (View.isMajorMediaChange(oldView, view)) { + this._api.getMediaLoadedInfoManager().clear(); + } + + if (oldView?.view !== view.view) { + this._api.getCardElementManager().scrollReset(); + } + + this._api.getMessageManager().reset(); + this._api.getStyleManager().setExpandedMode(); + + this._api.getConditionsManager()?.setState({ + view: view.view, + camera: view.camera, + displayMode: view.displayMode ?? undefined, + }); + this._api.getCardElementManager().update(); + } + + protected _createViewWithSelectedSubstream(baseView: View, substreamID: string): View { + const overrides: Map = + baseView?.context?.live?.overrides ?? new Map(); + overrides.set(baseView.camera, substreamID); + return baseView.clone().mergeInContext({ + live: { overrides: overrides }, + }); + } + + protected _createViewWithNextStream(baseView: View): View { + const dependencies = [ + ...getAllDependentCameras(this._api.getCameraManager(), baseView.camera), + ]; + if (dependencies.length <= 1) { + return baseView.clone(); + } + + const view = baseView.clone(); + const overrides: Map = view.context?.live?.overrides ?? new Map(); + const currentOverride = overrides.get(view.camera) ?? view.camera; + const currentIndex = dependencies.indexOf(currentOverride); + const newIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length; + overrides.set(view.camera, dependencies[newIndex]); + view.mergeInContext({ live: { overrides: overrides } }); + + return view; + } + + protected _createViewWithoutSubstream(): View | null { + if (!this._view) { + return null; + } + const view = this._view.clone(); + const overrides: Map | undefined = view.context?.live?.overrides; + if (overrides && overrides.has(view.camera)) { + view.context?.live?.overrides?.delete(view.camera); + } + return view; + } +} diff --git a/src/card.ts b/src/card.ts index cecbbbc3..f7eca6c1 100644 --- a/src/card.ts +++ b/src/card.ts @@ -1,28 +1,13 @@ -import { getLovelace, HomeAssistant, LovelaceCardEditor } from 'custom-card-helpers'; -import { - CSSResultGroup, - html, - LitElement, - PropertyValues, - TemplateResult, - unsafeCSS, -} from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import { HomeAssistant, LovelaceCardEditor } from 'custom-card-helpers'; +import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; +import { customElement } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; -import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { Ref, createRef, ref } from 'lit/directives/ref.js'; import { styleMap } from 'lit/directives/style-map.js'; -import cloneDeep from 'lodash-es/cloneDeep'; -import isEqual from 'lodash-es/isEqual'; -import merge from 'lodash-es/merge'; -import throttle from 'lodash-es/throttle'; -import screenfull from 'screenfull'; import { ViewContext } from 'view'; import 'web-dialog'; import pkg from '../package.json'; import { actionHandler } from './action-handler-directive.js'; -import { AutomationsController } from './automations'; -import { CameraManagerEngineFactory } from './camera-manager/engine-factory.js'; -import { CameraManager } from './camera-manager/manager.js'; import './components/elements.js'; import { FrigateCardElements } from './components/elements.js'; import './components/menu.js'; @@ -32,77 +17,26 @@ import { renderMessage, renderProgressIndicator } from './components/message.js' import './components/thumbnail-carousel.js'; import './components/views.js'; import { FrigateCardViews } from './components/views.js'; -import { - ConditionController, - ConditionEvaluateRequestEvent, - getOverriddenConfig, -} from './conditions.js'; -import { isConfigUpgradeable } from './config-mgmt.js'; -import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA, REPO_URL } from './const.js'; -import { getLanguage, loadLanguages, localize } from './localize/localize.js'; -import { setLowPerformanceProfile, setPerformanceCSSStyles } from './performance.js'; +import { FrigateCardConfig, MenuItem, RawFrigateCardConfig } from './config/types'; +import { REPO_URL } from './const.js'; +import { localize } from './localize/localize.js'; import cardStyle from './scss/card.scss'; import { - Actions, - ActionsConfig, - CameraConfig, - CardWideConfig, ExtendedHomeAssistant, - FRIGATE_CARD_VIEW_DEFAULT, - FrigateCardConfig, - frigateCardConfigSchema, - FrigateCardCustomAction, - FrigateCardError, - FrigateCardView, MediaLoadedInfo, - MenuButton, Message, - MESSAGE_TYPE_PRIORITIES, - RawFrigateCardConfig, - ViewDisplayMode, } from './types.js'; -import { - convertActionToFrigateCardCustomAction, - frigateCardHandleActionConfig, - frigateCardHasAction, - getActionConfigGivenAction, - isViewAction, -} from './utils/action.js'; -import { errorToConsole } from './utils/basic.js'; -import { log } from './utils/debug.js'; -import { downloadMedia, downloadURL } from './utils/download.js'; -import { - getHassDifferences, - isCardInPanel, - isHassDifferent, - isTriggeredState, - sideLoadHomeAssistantElements, -} from './utils/ha'; -import { DeviceList, getAllDevices } from './utils/ha/device-registry.js'; -import { EntityCache } from './utils/ha/entity-registry/cache.js'; -import { EntityRegistryManager } from './utils/ha/entity-registry/index.js'; -import { Entity } from './utils/ha/entity-registry/types.js'; -import { ResolvedMediaCache } from './utils/ha/resolved-media.js'; -import { supportsFeature } from './utils/ha/update.js'; -import { FrigateCardInitializer } from './utils/initializer.js'; -import { MediaLoadedInfoController } from './utils/media-info-controller'; -import { isValidMediaLoadedInfo } from './utils/media-info.js'; -import { executeMediaQueryForView } from './utils/media-to-view'; +import { frigateCardHasAction } from './utils/action.js'; +import { ConditionEvaluateRequestEvent } from './card-controller/conditions-manager.js'; +import { CardController } from './card-controller/controller'; import { MenuButtonController } from './utils/menu-controller'; -import { MicrophoneController } from './utils/microphone'; -import { getActionsFromQueryString } from './utils/querystring.js'; -import { generateScreenshotTitle } from './utils/screenshot'; -import { - createViewWithNextStream, - createViewWithoutSubstream, - createViewWithSelectedSubstream, -} from './utils/substream'; -import { Timer } from './utils/timer'; -import { getParseErrorPaths } from './utils/zod.js'; -import { ViewMediaClassifier } from './view/media-classifier'; import { View } from './view/view.js'; -/** A note on media callbacks: +// *************************************************************************** +// General Card-Wide Notes +// *************************************************************************** + +/** Media callbacks: * * Media elements (e.g.