diff --git a/README.md b/README.md index 6898a9ac..678c8b42 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ See the [fully expanded cameras configuration example](#config-expanded-cameras) | `title` | Autodetected from `camera_entity` if that is specified. | :heavy_multiplication_x: | A friendly name for this camera to use in the card. | | `icon` | Autodetected from `camera_entity` if that is specified. | :heavy_multiplication_x: | The icon to use for this camera in the camera menu and in the next & previous controls when using the `icon` style. | | `id` | `camera_entity`, `webrtc_card.entity` or `frigate.camera_name` if set (in that preference order). | :heavy_multiplication_x: | An optional identifier to use throughout the card configuration to refer unambiguously to this camera. See [camera IDs](#camera-ids). | +| `engine` | `auto` | :heavy_multiplication_x: | Which camera engine to use for this camera. If `auto` the card will attempt to choose the correct engine from the specified options. See [engines](#engines) below for valid options.| | `frigate` | | :heavy_multiplication_x: | Options for a Frigate camera. See [Frigate configuration](#camera-frigate-configuration) below. | | `dependencies` | | :heavy_multiplication_x: | Other cameras that this camera should depend upon. See [camera dependencies](#camera-dependencies-configuration) below. | | `triggers` | | :heavy_multiplication_x: | Define what should cause this camera to update/trigger. See [camera triggers](#camera-trigger-configuration) below. | @@ -122,6 +123,15 @@ See the [fully expanded cameras configuration example](#config-expanded-cameras) |`frigate-jsmpeg`|Better|Low|Poor|Builtin|Stream the JSMPEG stream from Frigate (proxied via the Frigate integration). See [note below on the required integration version](#jsmpeg-troubleshooting) for this live provider to function. This is the only live provider that can view the Frigate `birdseye` view.| |`webrtc-card`|Best|High|Better|Separate installation required|Embed's [AlexxIT's WebRTC Card](https://github.com/AlexxIT/WebRTC) to stream live feed, requires manual extra setup, see [below](#webrtc). Not to be confused with native Home Assistant WebRTC (use `ha` provider above).| + + +#### Available Camera Engines + +|Engine|Live|Supports clips|Supports Snapshots|Supports Recordings|Supports Timeline|Favorite events|Favorite recordings| +| - | - | - | - | - | - | - | - | +|`frigate`| :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | +|`generic`| :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | + #### Camera Frigate configuration @@ -1304,6 +1314,7 @@ Reference: [Camera Options](#camera-options). cameras: - camera_entity: camera.front_Door live_provider: ha + engine: auto frigate: url: http://my.frigate.local client_id: frigate @@ -1322,6 +1333,7 @@ cameras: - binary_sensor.front_door_sensor - camera_entity: camera.entrance live_provider: webrtc-card + engine: auto frigate: url: http://my-other.frigate.local client_id: frigate-other diff --git a/src/camera-manager/engine-factory.ts b/src/camera-manager/engine-factory.ts index 07295bb8..04f02499 100644 --- a/src/camera-manager/engine-factory.ts +++ b/src/camera-manager/engine-factory.ts @@ -1,27 +1,30 @@ +import { HomeAssistant } from 'custom-card-helpers'; import { CameraConfig, CardWideConfig } from '../types'; -import { ViewMedia } from '../view/media'; +import { EntityRegistryManager } from '../utils/ha/entity-registry'; import { RecordingSegmentsCache, RequestCache } from './cache'; import { CameraManagerEngine } from './engine'; import { FrigateCameraManagerEngine } from './frigate/engine-frigate'; +import { GenericCameraManagerEngine } from './generic/engine-generic'; import { Engine } from './types'; -type CameraManagerEngineCameraIDMap = Map>; - export class CameraManagerEngineFactory { - protected _engines: Map = new Map(); + protected _entityRegistryManager: EntityRegistryManager; protected _cardWideConfig: CardWideConfig; - constructor(cardWideConfig: CardWideConfig) { + constructor( + entityRegistryManager: EntityRegistryManager, + cardWideConfig: CardWideConfig, + ) { + this._entityRegistryManager = entityRegistryManager; this._cardWideConfig = cardWideConfig; } - public getEngine(engine: Engine): CameraManagerEngine | null { - const cachedEngine = this._engines.get(engine); - if (cachedEngine) { - return cachedEngine; - } + public async createEngine(engine: Engine): Promise { let cameraManagerEngine: CameraManagerEngine | null = null; switch (engine) { + case Engine.Generic: + cameraManagerEngine = new GenericCameraManagerEngine(); + break; case Engine.Frigate: cameraManagerEngine = new FrigateCameraManagerEngine( this._cardWideConfig, @@ -30,64 +33,39 @@ export class CameraManagerEngineFactory { ); break; } - if (cameraManagerEngine) { - this._engines.set(engine, cameraManagerEngine); - } return cameraManagerEngine; } - public getEngineForCamera(cameraConfig?: CameraConfig): CameraManagerEngine | null { + public async getEngineForCamera( + hass: HomeAssistant, + cameraConfig: CameraConfig, + ): Promise { if (!cameraConfig) { return null; } let engine: Engine | null = null; - if (cameraConfig.frigate.camera_name) { + if (cameraConfig.engine === 'frigate') { engine = Engine.Frigate; - } - return engine ? this.getEngine(engine) : null; - } + } else if (cameraConfig.engine === 'auto') { + const cameraEntity = cameraConfig.camera_entity; - public getEnginesForCameraIDs( - cameras: Map, - cameraIDs: Set, - ): CameraManagerEngineCameraIDMap | null { - const output: CameraManagerEngineCameraIDMap = new Map(); - - for (const cameraID of cameraIDs) { - const cameraConfig = cameras.get(cameraID); - if (!cameraConfig) { - continue; + if (cameraEntity) { + const entity = await this._entityRegistryManager.getEntity(hass, cameraEntity); + switch (entity?.platform) { + case 'frigate': + engine = Engine.Frigate; + break; + default: + engine = Engine.Generic; + } + } else if (cameraConfig.frigate.camera_name) { + // Frigate technically does not need an entity, if the camera name is + // manually set the camera is assumed to be Frigate. + engine = Engine.Frigate; } - - const engine = this.getEngineForCamera(cameraConfig); - if (!engine) { - continue; - } - if (!output.has(engine)) { - output.set(engine, new Set()); - } - output.get(engine)?.add(cameraID); } - return output.size ? output : null; - } - public getEngineForMedia( - cameras: Map, - media: ViewMedia, - ): CameraManagerEngine | null { - const cameraID = media.getCameraID(); - if (!cameraID) { - return null; - } - const engines = this.getEnginesForCameraIDs(cameras, new Set([cameraID])); - return engines ? ([...engines.keys()][0] ?? null) : null; - } - - public getAllEngines( - cameras: Map, - ): CameraManagerEngine[] | null { - const engines = this.getEnginesForCameraIDs(cameras, new Set(cameras.keys())); - return engines ? [...engines.keys()] : null; + return engine; } } diff --git a/src/camera-manager/engine.ts b/src/camera-manager/engine.ts index 6e186f51..533a02db 100644 --- a/src/camera-manager/engine.ts +++ b/src/camera-manager/engine.ts @@ -1,5 +1,6 @@ import { HomeAssistant } from 'custom-card-helpers'; import { CameraConfig } from '../types'; +import { EntityRegistryManager } from '../utils/ha/entity-registry'; import { ViewMedia } from '../view/media'; import { DataQuery, @@ -17,57 +18,68 @@ import { CameraManagerCameraCapabilities, CameraManagerMediaCapabilities, CameraManagerCameraMetadata, + CameraURLContext, + CameraConfigs, + Engine, } from './types'; export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; export interface CameraManagerEngine { + getEngineType(): Engine; + + initializeCamera( + hass: HomeAssistant, + entityRegistryManager: EntityRegistryManager, + cameraConfig: CameraConfig, + ): Promise; + generateDefaultEventQuery( - cameras: Map, + cameras: CameraConfigs, cameraIDs: Set, query: PartialEventQuery, ): EventQuery[] | null; generateDefaultRecordingQuery( - cameras: Map, + cameras: CameraConfigs, cameraIDs: Set, query: PartialRecordingQuery, ): RecordingQuery[] | null; generateDefaultRecordingSegmentsQuery( - cameras: Map, + cameras: CameraConfigs, cameraIDs: Set, query: PartialRecordingSegmentsQuery, ): RecordingSegmentsQuery[] | null; getEvents( hass: HomeAssistant, - cameras: Map, + cameras: CameraConfigs, query: EventQuery, ): Promise; getRecordings( hass: HomeAssistant, - cameras: Map, + cameras: CameraConfigs, query: RecordingQuery, ): Promise; getRecordingSegments( hass: HomeAssistant, - cameras: Map, + cameras: CameraConfigs, query: RecordingSegmentsQuery, ): Promise; generateMediaFromEvents( hass: HomeAssistant, - cameras: Map, + cameras: CameraConfigs, query: EventQuery, results: QueryReturnType, ): ViewMedia[] | null; generateMediaFromRecordings( hass: HomeAssistant, - cameras: Map, + cameras: CameraConfigs, query: RecordingQuery, results: QueryReturnType, ): ViewMedia[] | null; @@ -85,14 +97,14 @@ export interface CameraManagerEngine { getMediaSeekTime( hass: HomeAssistant, - cameras: Map, + cameras: CameraConfigs, media: ViewMedia, target: Date, ): Promise; getMediaMetadata( hass: HomeAssistant, - cameras: Map, + cameras: CameraConfigs, ): Promise; getCameraMetadata( @@ -105,4 +117,9 @@ export interface CameraManagerEngine { ): CameraManagerCameraCapabilities | null; getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null; + + getCameraURL( + cameraConfig: CameraConfig, + context?: CameraURLContext, + ): string | null; } diff --git a/src/camera-manager/error.ts b/src/camera-manager/error.ts new file mode 100644 index 00000000..61f9e7e5 --- /dev/null +++ b/src/camera-manager/error.ts @@ -0,0 +1,3 @@ +import { FrigateCardError } from '../types.js'; + +export class CameraInitializationError extends FrigateCardError {} diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index 84cf5f55..f84fd128 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -37,6 +37,8 @@ import { RecordingSegment, RecordingSegmentsQuery, RecordingSegmentsQueryResultsMap, + CameraURLContext, + CameraConfigs, } from '../types'; import { FrigateRecording } from './types'; import { @@ -62,7 +64,14 @@ import { FrigateViewMediaClassifier } from './media-classifier'; import { ViewMediaClassifier } from '../../view/media-classifier'; import { FrigateViewMediaFactory } from './media'; import { log } from '../../utils/debug'; -import { getEntityIcon, getEntityTitle } from '../../utils/ha'; +import { getEntityTitle } from '../../utils/ha'; +import { EntityRegistryManager } from '../../utils/ha/entity-registry'; +import { ExtendedEntity } 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'; const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60; const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60; @@ -92,7 +101,10 @@ class FrigateQueryResultsClassifier { } } -export class FrigateCameraManagerEngine implements CameraManagerEngine { +export class FrigateCameraManagerEngine + extends GenericCameraManagerEngine + implements CameraManagerEngine +{ protected _recordingSegmentsCache: RecordingSegmentsCache; protected _requestCache: RequestCache; protected _cardWideConfig: CardWideConfig; @@ -109,11 +121,162 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { recordingSegmentsCache: RecordingSegmentsCache, requestCache: RequestCache, ) { + super(); this._cardWideConfig = cardWideConfig; this._recordingSegmentsCache = recordingSegmentsCache; this._requestCache = requestCache; } + public getEngineType(): Engine { + return Engine.Frigate; + } + + public async initializeCamera( + hass: HomeAssistant, + entityRegistryManager: EntityRegistryManager, + cameraConfig: CameraConfig, + ): Promise { + const hasCameraName = !!cameraConfig.frigate?.camera_name; + const hasAutoTriggers = + cameraConfig.triggers.motion || cameraConfig.triggers.occupancy; + + let entity: ExtendedEntity | null = null; + + // Extended entity information is required if the Frigate camera name is + // missing, or if the entity requires automatic resolution of + // motion/occupancy sensors. + if (cameraConfig.camera_entity && (!hasCameraName || hasAutoTriggers)) { + try { + entity = await entityRegistryManager.getExtendedEntity( + hass, + cameraConfig.camera_entity, + ); + } catch (e) { + throw new CameraInitializationError( + localize('error.no_camera_entity'), + cameraConfig, + ); + } + } + + if (entity && !hasCameraName) { + const resolvedName = this._getFrigateCameraNameFromEntity(entity); + if (resolvedName) { + cameraConfig.frigate.camera_name = resolvedName; + } + } + + if (hasAutoTriggers) { + // Try to find the correct entities for the motion & occupancy sensors. + // We know they are binary_sensors, and that they'll have the same + // config entry ID as the camera. Searching via unique_id ensures this + // search still works if the user renames the entity_id. + const binarySensorEntities = await entityRegistryManager.getMatchingEntities( + hass, + (ent) => + ent.config_entry_id === entity?.config_entry_id && + !ent.disabled_by && + ent.entity_id.startsWith('binary_sensor.'), + ); + + const extendedEntities = await entityRegistryManager.getExtendedEntities( + hass, + binarySensorEntities.map((entity) => entity.entity_id), + ); + + if (cameraConfig.triggers.motion) { + const motionEntity = this._getMotionSensor(cameraConfig, [ + ...extendedEntities.values(), + ]); + if (motionEntity) { + cameraConfig.triggers.entities.push(motionEntity); + } + } + + if (cameraConfig.triggers.occupancy) { + const occupancyEntity = this._getOccupancySensor(cameraConfig, [ + ...extendedEntities.values(), + ]); + if (occupancyEntity) { + cameraConfig.triggers.entities.push(occupancyEntity); + } + } + + // De-duplicate triggering entities. + cameraConfig.triggers.entities = uniq(cameraConfig.triggers.entities); + } + + return cameraConfig; + } + + /** + * Get the Frigate camera name from an entity. + * @returns The Frigate camera name or null if unavailable. + */ + protected _getFrigateCameraNameFromEntity(entity: ExtendedEntity): string | null { + if (entity.unique_id && entity.platform === 'frigate') { + const match = entity.unique_id.match(/:camera:(?[^:]+)$/); + if (match && match.groups) { + return match.groups['camera']; + } + } + return null; + } + + /** + * Get the motion sensor entity for a given camera. + * @param cache The ExtendedEntityCache of entity registry information. + * @param cameraConfig The camera config in question. + * @returns The entity id of the motion sensor or null. + */ + protected _getMotionSensor( + cameraConfig: CameraConfig, + extendedEntities: ExtendedEntity[], + ): string | null { + if (cameraConfig.frigate.camera_name) { + return ( + extendedEntities.find( + (ent) => + !!ent.unique_id?.match( + new RegExp( + `:motion_sensor:${ + cameraConfig.frigate.zone || cameraConfig.frigate.camera_name + }`, + ), + ), + )?.entity_id ?? null + ); + } + return null; + } + + /** + * Get the occupancy sensor entity for a given camera. + * @param cache The ExtendedEntityCache of entity registry information. + * @param cameraConfig The camera config in question. + * @returns The entity id of the occupancy sensor or null. + */ + protected _getOccupancySensor( + cameraConfig: CameraConfig, + extendedEntities: ExtendedEntity[], + ): string | null { + if (cameraConfig.frigate.camera_name) { + return ( + extendedEntities.find( + (ent) => + !!ent.unique_id?.match( + new RegExp( + `:occupancy_sensor:${ + cameraConfig.frigate.zone || cameraConfig.frigate.camera_name + }_${cameraConfig.frigate.label || 'all'}`, + ), + ), + )?.entity_id ?? null + ); + } + return null; + } + public getMediaDownloadPath( cameraConfig: CameraConfig, media: ViewMedia, @@ -137,7 +300,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { } public generateDefaultEventQuery( - cameras: Map, + cameras: CameraConfigs, cameraIDs: Set, query?: PartialEventQuery, ): EventQuery[] | null { @@ -182,7 +345,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { } public generateDefaultRecordingQuery( - _cameras: Map, + _cameras: CameraConfigs, cameraIDs: Set, query?: PartialRecordingQuery, ): RecordingQuery[] { @@ -196,7 +359,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { } public generateDefaultRecordingSegmentsQuery( - _cameras: Map, + _cameras: CameraConfigs, cameraIDs: Set, query: PartialRecordingSegmentsQuery, ): RecordingSegmentsQuery[] | null { @@ -229,7 +392,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { } protected _buildInstanceToCameraIDMapFromQuery( - cameras: Map, + cameras: CameraConfigs, cameraIDs: Set, ): Map> { const output: Map> = new Map(); @@ -247,7 +410,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { } protected _getFrigateCameraNamesForCameraIDs( - cameras: Map, + cameras: CameraConfigs, cameraIDs: Set, ): Set { const output = new Set(); @@ -262,7 +425,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { public async getEvents( hass: HomeAssistant, - cameras: Map, + cameras: CameraConfigs, query: EventQuery, ): Promise { const output: EventQueryResultsMap = new Map(); @@ -326,7 +489,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { public async getRecordings( hass: HomeAssistant, - cameras: Map, + cameras: CameraConfigs, query: RecordingQuery, ): Promise { const output: RecordingQueryResultsMap = new Map(); @@ -408,7 +571,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { public async getRecordingSegments( hass: HomeAssistant, - cameras: Map, + cameras: CameraConfigs, query: RecordingSegmentsQuery, ): Promise { const output: RecordingSegmentsQueryResultsMap = new Map(); @@ -474,7 +637,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { } protected _getCameraIDMatch( - cameras: Map, + cameras: CameraConfigs, query: DataQuery, instanceID: string, cameraName: string, @@ -499,7 +662,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { public generateMediaFromEvents( _hass: HomeAssistant, - cameras: Map, + cameras: CameraConfigs, query: EventQuery, results: QueryReturnType, ): ViewMedia[] | null { @@ -552,7 +715,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { public generateMediaFromRecordings( hass: HomeAssistant, - cameras: Map, + cameras: CameraConfigs, _query: RecordingQuery, results: QueryReturnType, ): ViewMedia[] | null { @@ -590,7 +753,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { public async getMediaSeekTime( hass: HomeAssistant, - cameras: Map, + cameras: CameraConfigs, media: ViewMedia, target: Date, ): Promise { @@ -623,7 +786,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { } protected _getQueryableCameraConfig( - cameras: Map, + cameras: CameraConfigs, cameraID: string, ): CameraConfig | null { const cameraConfig = cameras.get(cameraID); @@ -635,7 +798,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { public async getMediaMetadata( hass: HomeAssistant, - cameras: Map, + cameras: CameraConfigs, ): Promise { const what: Set = new Set(); const where: Set = new Set(); @@ -715,7 +878,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { */ protected async _garbageCollectSegments( hass: HomeAssistant, - cameras: Map, + cameras: CameraConfigs, ): Promise { const cameraIDs = this._recordingSegmentsCache.getCameraIDs(); const recordingQuery: RecordingQuery = { @@ -812,6 +975,9 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { return { canFavoriteEvents: !isBirdseye, canFavoriteRecordings: !isBirdseye, + supportsClips: !isBirdseye, + supportsSnapshots: !isBirdseye, + supportsRecordings: !isBirdseye, supportsTimeline: !isBirdseye, }; } @@ -826,6 +992,7 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { hass: HomeAssistant, cameraConfig: CameraConfig, ): CameraManagerCameraMetadata { + const metadata = super.getCameraMetadata(hass, cameraConfig); return { title: cameraConfig.title ?? @@ -834,10 +1001,51 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine { prettifyTitle(cameraConfig.frigate?.camera_name) ?? cameraConfig.id ?? '', - icon: - cameraConfig?.icon ?? - getEntityIcon(hass, cameraConfig.camera_entity) ?? - 'mdi:video', + icon: metadata.icon, }; } + + public getCameraURL( + cameraConfig: CameraConfig, + context?: CameraURLContext, + ): string | null { + if (!cameraConfig.frigate.url) { + return null; + } + if (!cameraConfig.frigate.camera_name) { + return cameraConfig.frigate.url; + } + + const eventsURL = + `${cameraConfig.frigate.url}/events?camera=` + cameraConfig.frigate.camera_name; + const recordingsURL = + `${cameraConfig.frigate.url}/recording/` + cameraConfig.frigate.camera_name; + + // If media is available, use it since it may result in a more precisely + // correct URL. + switch (context?.media?.getMediaType()) { + case 'clip': + case 'snapshot': + return eventsURL; + case 'recording': + const startTime = context.media.getStartTime(); + if (startTime) { + return recordingsURL + format(startTime, 'yyyy-MM-dd/HH'); + } + } + + // Otherwise, fall back to just using the view if we have that. + switch (context?.view) { + case 'clip': + case 'clips': + case 'snapshots': + case 'snapshot': + return eventsURL; + case 'recording': + case 'recordings': + return recordingsURL; + } + + return `${cameraConfig.frigate.url}/cameras/${cameraConfig.frigate.camera_name}`; + } } diff --git a/src/camera-manager/generic/engine-generic.ts b/src/camera-manager/generic/engine-generic.ts new file mode 100644 index 00000000..6cbd0110 --- /dev/null +++ b/src/camera-manager/generic/engine-generic.ts @@ -0,0 +1,186 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import { HomeAssistant } from 'custom-card-helpers'; +import { CameraConfig } from '../../types'; +import { ViewMedia } from '../../view/media'; +import { + CameraManagerCameraMetadata, + CameraManagerMediaCapabilities, + DataQuery, + EventQuery, + EventQueryResultsMap, + MediaMetadata, + PartialEventQuery, + PartialRecordingQuery, + PartialRecordingSegmentsQuery, + RecordingQueryResultsMap, + RecordingSegmentsQuery, + RecordingSegmentsQueryResultsMap, + CameraURLContext, + CameraConfigs, + RecordingQuery, + QueryReturnType, + CameraManagerCameraCapabilities, + Engine, +} 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 { + return Engine.Generic; + } + + public async initializeCamera( + _hass: HomeAssistant, + _entityRegistryManager: EntityRegistryManager, + cameraConfig: CameraConfig, + ): Promise { + return cameraConfig; + } + + public generateDefaultEventQuery( + _cameras: CameraConfigs, + _cameraIDs: Set, + _query: PartialEventQuery, + ): EventQuery[] | null { + return null; + } + + public generateDefaultRecordingQuery( + _cameras: CameraConfigs, + _cameraIDs: Set, + _query: PartialRecordingQuery, + ): RecordingQuery[] | null { + return null; + } + + public generateDefaultRecordingSegmentsQuery( + _cameras: CameraConfigs, + _cameraIDs: Set, + _query: PartialRecordingSegmentsQuery, + ): RecordingSegmentsQuery[] | null { + return null; + } + + public async getEvents( + _hass: HomeAssistant, + _cameras: CameraConfigs, + _query: EventQuery, + ): Promise { + return null; + } + + public async getRecordings( + _hass: HomeAssistant, + _cameras: CameraConfigs, + _query: RecordingQuery, + ): Promise { + return null; + } + + public async getRecordingSegments( + _hass: HomeAssistant, + _cameras: CameraConfigs, + _query: RecordingSegmentsQuery, + ): Promise { + return null; + } + + public generateMediaFromEvents( + _hass: HomeAssistant, + _cameras: CameraConfigs, + _query: EventQuery, + _results: QueryReturnType, + ): ViewMedia[] | null { + return null; + } + + public generateMediaFromRecordings( + _hass: HomeAssistant, + _cameras: CameraConfigs, + _query: RecordingQuery, + _results: QueryReturnType, + ): ViewMedia[] | null { + return null; + } + + public getMediaDownloadPath( + _cameraConfig: CameraConfig, + _media: ViewMedia, + ): string | null { + return null; + } + + public async favoriteMedia( + _hass: HomeAssistant, + _cameraConfig: CameraConfig, + _media: ViewMedia, + _favorite: boolean, + ): Promise { + return; + } + + public getQueryResultMaxAge(_query: DataQuery): number | null { + return null; + } + + public async getMediaSeekTime( + _hass: HomeAssistant, + _cameras: CameraConfigs, + _media: ViewMedia, + _target: Date, + ): Promise { + return null; + } + + public async getMediaMetadata( + _hass: HomeAssistant, + _cameras: CameraConfigs, + ): Promise { + return null; + } + + public getCameraMetadata( + hass: HomeAssistant, + cameraConfig: CameraConfig, + ): CameraManagerCameraMetadata { + return { + title: + cameraConfig.title ?? + getEntityTitle(hass, cameraConfig.camera_entity) ?? + getEntityTitle(hass, cameraConfig.webrtc_card?.entity) ?? + cameraConfig.id ?? + '', + icon: + cameraConfig?.icon ?? + getEntityIcon(hass, cameraConfig.camera_entity) ?? + 'mdi:video', + }; + } + + public getCameraCapabilities( + _cameraConfig: CameraConfig, + ): CameraManagerCameraCapabilities | null { + return { + canFavoriteEvents: false, + canFavoriteRecordings:false, + supportsClips: false, + supportsRecordings: false, + supportsSnapshots: false, + supportsTimeline: false, + } + } + + public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities | null { + return null; + } + + public getCameraURL( + _cameraConfig: CameraConfig, + _context?: CameraURLContext, + ): string | null { + return null; + } +} diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index d53c6524..1ed8ac73 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -1,11 +1,12 @@ import { HomeAssistant } from 'custom-card-helpers'; -import { CameraConfig, CardWideConfig } from '../types.js'; +import { CameraConfig, CamerasConfig, CardWideConfig } from '../types.js'; import { allPromises, arrayify, setify } from '../utils/basic.js'; import { CameraManagerCameraCapabilities, CameraManagerCameraMetadata, CameraManagerCapabilities, CameraManagerMediaCapabilities, + CameraURLContext, DataQuery, EventQuery, EventQueryResults, @@ -37,6 +38,12 @@ 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 { CameraManagerStore } from './store.js'; +import { cloneDeep } from 'lodash-es'; class QueryClassifier { public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery { @@ -77,19 +84,132 @@ export interface ExtendedMediaQueryResult { results: ViewMedia[]; } +interface InitializedCamera { + inputConfig: CameraConfig; + initializedConfig: CameraConfig; + engine: CameraManagerEngine; +} + export class CameraManager { protected _engineFactory: CameraManagerEngineFactory; - protected _cameras: Map; protected _cardWideConfig?: CardWideConfig; + protected _store: CameraManagerStore; constructor( engineFactory: CameraManagerEngineFactory, - cameras: Map, cardWideConfig?: CardWideConfig, ) { this._engineFactory = engineFactory; - this._cameras = cameras; this._cardWideConfig = cardWideConfig; + this._store = new CameraManagerStore(); + } + + protected async _initializeCamera( + hass: HomeAssistant, + entityRegistryManager: EntityRegistryManager, + inputCameraConfig: CameraConfig, + ): Promise { + const engineType = await this._engineFactory.getEngineForCamera( + hass, + inputCameraConfig, + ); + const engine = engineType + ? this._store.getEngineOfType(engineType) ?? + (await this._engineFactory.createEngine(engineType)) + : null; + if (!engine) { + throw new CameraInitializationError( + localize('error.no_camera_engine'), + inputCameraConfig, + ); + } + + const initializedConfig = await engine.initializeCamera( + hass, + entityRegistryManager, + // Camera initialization may modify the configuration. Keep the original + // for display in error messages to avoid user confusion. + cloneDeep(inputCameraConfig), + ); + + return { + inputConfig: inputCameraConfig, + initializedConfig: initializedConfig, + engine: engine, + }; + } + + public async initializeCameras( + hass: HomeAssistant, + entityRegistryManager: EntityRegistryManager, + camerasConfig: CamerasConfig, + ): Promise { + const hasAutoTriggers = (config: CameraConfig): boolean => { + return config.triggers.motion || config.triggers.occupancy; + }; + + if ( + // If any camera requires automatic trigger detection ... + camerasConfig.some((config) => hasAutoTriggers(config)) + ) { + // ... then we need to populate the entity cache by fetching all entities + // from Home Assistant. + await entityRegistryManager.fetchEntityList(hass); + } + + const results = await allPromises( + camerasConfig, + async (cameraConfig) => + await this._initializeCamera(hass, entityRegistryManager, cameraConfig), + ); + + // Do the additions based off the result-order, to ensure the map order is + // preserved. + results.forEach((result) => { + const id = getCameraID(result.initializedConfig); + + if (!id) { + throw new CameraInitializationError( + localize('error.no_camera_id'), + result.inputConfig, + ); + } + + if (this._store.hasCameraID(id)) { + throw new CameraInitializationError( + localize('error.duplicate_camera_id'), + result.inputConfig, + ); + } + + this._store.addCamera(id, result.initializedConfig, result.engine); + }); + + if (!this._store.getCameraCount()) { + throw new CameraInitializationError(localize('error.no_cameras')); + } + } + + public isInitialized(): boolean { + return this._store.getCameraCount() > 0; + } + + public getCameras(): Map | null { + return this._store.getCameras(); + } + + public getCameraConfig(cameraID: string): CameraConfig | null { + return this._store.getCameraConfig(cameraID); + } + + public getCameraIDs(): Set | null { + return this._store.getCameraCount() + ? new Set(this._store.getCameras().keys()) + : null; + } + + public hasCameraID(cameraID: string): boolean { + return this._store.hasCameraID(cameraID); } public generateDefaultEventQueries( @@ -127,13 +247,13 @@ export class CameraManager { const where: Set = new Set(); const days: Set = new Set(); - const engines = this._engineFactory.getAllEngines(this._cameras); - if (!engines) { - return null; - } + const engines = this._store.getAllEngines(); const processMetadata = async (engine: CameraManagerEngine): Promise => { - const engineMetadata = await engine.getMediaMetadata(hass, this._cameras); + const engineMetadata = await engine.getMediaMetadata( + hass, + this._store.getCameras(), + ); if (engineMetadata) { if (engineMetadata.what) { engineMetadata.what.forEach(what.add, what); @@ -147,7 +267,7 @@ export class CameraManager { } }; - await allPromises(engines, (engine) => processMetadata(engine)); + await allPromises(engines, processMetadata); if (!what.size && !where.size && !days.size) { return null; @@ -165,11 +285,7 @@ export class CameraManager { ): PartialQueryConcreteType[] | null { const concreteQueries: PartialQueryConcreteType[] = []; const _cameraIDs = setify(cameraIDs); - - const engines = this._engineFactory.getEnginesForCameraIDs( - this._cameras, - _cameraIDs, - ); + const engines = this._store.getEnginesForCameraIDs(_cameraIDs); if (!engines) { return null; @@ -179,19 +295,19 @@ export class CameraManager { let queries: DataQuery[] | null = null; if (QueryClassifier.isEventQuery(partialQuery)) { queries = engine.generateDefaultEventQuery( - this._cameras, + this._store.getCameras(), cameraIDs, partialQuery, ); } else if (QueryClassifier.isRecordingQuery(partialQuery)) { queries = engine.generateDefaultRecordingQuery( - this._cameras, + this._store.getCameras(), cameraIDs, partialQuery, ); } else if (QueryClassifier.isRecordingSegmentsQuery(partialQuery)) { queries = engine.generateDefaultRecordingSegmentsQuery( - this._cameras, + this._store.getCameras(), cameraIDs, partialQuery, ); @@ -303,10 +419,9 @@ export class CameraManager { } public getMediaDownloadPath(media: ViewMedia): string | null { - const cameraConfig = this._cameras.get(media.getCameraID()); - const engine = cameraConfig - ? this._engineFactory.getEngineForCamera(cameraConfig) - : null; + const cameraConfig = this._store.getCameraConfigForMedia(media); + const engine = this._store.getEngineForMedia(media); + if (!cameraConfig || !engine) { return null; } @@ -314,7 +429,7 @@ export class CameraManager { } public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null { - const engine = this._engineFactory.getEngineForMedia(this._cameras, media); + const engine = this._store.getEngineForMedia(media); if (!engine) { return null; } @@ -326,26 +441,26 @@ export class CameraManager { media: ViewMedia, favorite: boolean, ): Promise { - const cameraConfig = this._cameras.get(media.getCameraID()); - if (!cameraConfig) { + const cameraConfig = this._store.getCameraConfigForMedia(media); + const engine = this._store.getEngineForMedia(media); + + if (!cameraConfig || !engine) { return; } - const engine = this._engineFactory.getEngineForCamera(cameraConfig); - if (engine) { - const queryStartTime = new Date(); - await engine.favoriteMedia(hass, cameraConfig, media, favorite); - log( - this._cardWideConfig, - 'Frigate Card CameraManager favorite request (', - `Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`, - 'Media:', - media.getID(), - ', Favorite:', - favorite, - ')', - ); - } + const queryStartTime = new Date(); + await engine.favoriteMedia(hass, cameraConfig, media, favorite); + + log( + this._cardWideConfig, + 'Frigate Card CameraManager favorite request (', + `Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`, + 'Media:', + media.getID(), + ', Favorite:', + favorite, + ')', + ); } public areMediaQueriesResultsFresh( @@ -355,10 +470,7 @@ export class CameraManager { const now = new Date(); for (const query of queries) { - const engines = this._engineFactory.getEnginesForCameraIDs( - this._cameras, - query.cameraIDs, - ); + const engines = this._store.getEnginesForCameraIDs(query.cameraIDs); for (const [engine, cameraIDs] of engines ?? []) { const maxAgeSeconds = engine.getQueryResultMaxAge({ ...query, @@ -382,9 +494,11 @@ export class CameraManager { ): Promise { const startTime = media.getStartTime(); const endTime = media.getEndTime(); - const cameraConfig = this._cameras.get(media.getCameraID()); + const cameraConfig = this._store.getCameraConfigForMedia(media); + const engine = this._store.getEngineForMedia(media); if ( !cameraConfig || + !engine || !startTime || !endTime || target < startTime || @@ -393,8 +507,7 @@ export class CameraManager { return null; } - const engine = this._engineFactory.getEngineForCamera(cameraConfig); - return (await engine?.getMediaSeekTime(hass, this._cameras, media, target)) ?? null; + return await engine.getMediaSeekTime(hass, this._store.getCameras(), media, target); } protected async _handleQuery( @@ -415,19 +528,21 @@ export class CameraManager { let engineResult: Map> | null = null; if (QueryClassifier.isEventQuery(query)) { - engineResult = (await engine.getEvents(hass, this._cameras, query)) as Map< - QT, - QueryReturnType - > | null; + engineResult = (await engine.getEvents( + hass, + this._store.getCameras(), + query, + )) as Map> | null; } else if (QueryClassifier.isRecordingQuery(query)) { - engineResult = (await engine.getRecordings(hass, this._cameras, query)) as Map< - QT, - QueryReturnType - > | null; + engineResult = (await engine.getRecordings( + hass, + this._store.getCameras(), + query, + )) as Map> | null; } else if (QueryClassifier.isRecordingSegmentsQuery(query)) { engineResult = (await engine.getRecordingSegments( hass, - this._cameras, + this._store.getCameras(), query, )) as Map> | null; } @@ -436,10 +551,7 @@ export class CameraManager { }; const processQuery = async (query: QT): Promise => { - const engines = this._engineFactory.getEnginesForCameraIDs( - this._cameras, - query.cameraIDs, - ); + const engines = this._store.getEnginesForCameraIDs(query.cameraIDs); if (!engines) { return; } @@ -481,7 +593,7 @@ export class CameraManager { ): ViewMedia[] { const mediaArray: ViewMedia[] = []; for (const [query, result] of results.entries()) { - const engine = this._engineFactory.getEngine(result.engine); + const engine = this._store.getEngineOfType(result.engine); if (engine) { let media: ViewMedia[] | null = null; @@ -489,12 +601,22 @@ export class CameraManager { QueryClassifier.isEventQuery(query) && QueryResultClassifier.isEventQueryResult(result) ) { - media = engine.generateMediaFromEvents(hass, this._cameras, query, result); + media = engine.generateMediaFromEvents( + hass, + this._store.getCameras(), + query, + result, + ); } else if ( QueryClassifier.isRecordingQuery(query) && QueryResultClassifier.isRecordingQuery(result) ) { - media = engine.generateMediaFromRecordings(hass, this._cameras, query, result); + media = engine.generateMediaFromRecordings( + hass, + this._store.getCameras(), + query, + result, + ); } if (media) { mediaArray.push(...media); @@ -517,13 +639,22 @@ export class CameraManager { ); } + public getCameraURL(cameraID: string, context?: CameraURLContext): string | null { + const cameraConfig = this._store.getCameraConfig(cameraID); + const engine = this._store.getEngineForCameraID(cameraID); + if (!cameraConfig || !engine) { + return null; + } + return engine.getCameraURL(cameraConfig, context); + } + public getCameraMetadata( hass: HomeAssistant, cameraID: string, ): CameraManagerCameraMetadata | null { - const cameraConfig = this._cameras.get(cameraID); - const engine = this._engineFactory.getEngineForCamera(cameraConfig); - if (!engine || !cameraConfig) { + const cameraConfig = this._store.getCameraConfig(cameraID); + const engine = this._store.getEngineForCameraID(cameraID); + if (!cameraConfig || !engine) { return null; } return engine.getCameraMetadata(hass, cameraConfig); @@ -532,23 +663,18 @@ export class CameraManager { public getCameraCapabilities( cameraID: string, ): CameraManagerCameraCapabilities | null { - const cameraConfig = this._cameras.get(cameraID); - if (!cameraConfig) { + const cameraConfig = this._store.getCameraConfig(cameraID); + const engine = this._store.getEngineForCameraID(cameraID); + if (!cameraConfig || !engine) { return null; } - - const engine = this._engineFactory.getEngineForCamera(cameraConfig); - if (!engine) { - return null; - } - return engine.getCameraCapabilities(cameraConfig); } public getAggregateCameraCapabilities( cameraIDs?: Set, - ): CameraManagerCapabilities { - const perCameraCapabilities = [...(cameraIDs ?? this._cameras.keys())].map( + ): CameraManagerCapabilities | null { + const perCameraCapabilities = [...(cameraIDs ?? this._store.getCameraIDs())].map( (cameraID) => this.getCameraCapabilities(cameraID), ); @@ -557,6 +683,10 @@ export class CameraManager { canFavoriteRecordings: perCameraCapabilities.some( (cap) => cap?.canFavoriteRecordings, ), + + supportsClips: perCameraCapabilities.some((cap) => cap?.supportsClips), + supportsRecordings: perCameraCapabilities.some((cap) => cap?.supportsRecordings), + supportsSnapshots: perCameraCapabilities.some((cap) => cap?.supportsSnapshots), supportsTimeline: perCameraCapabilities.some((cap) => cap?.supportsTimeline), }; } diff --git a/src/camera-manager/store.ts b/src/camera-manager/store.ts new file mode 100644 index 00000000..7d13415c --- /dev/null +++ b/src/camera-manager/store.ts @@ -0,0 +1,89 @@ +import uniq from 'lodash-es/uniq'; +import { CameraConfig } from '../types'; +import { ViewMedia } from '../view/media'; +import { CameraManagerEngine } from './engine'; +import { CameraConfigs, Engine } from './types'; + +type CameraManagerEngineCameraIDMap = Map>; + +export class CameraManagerStore { + protected _configs: Map = new Map(); + protected _engines: Map = new Map(); + protected _enginesByType: Map = new Map(); + + public addCamera( + cameraID: string, + cameraConfig: CameraConfig, + engine: CameraManagerEngine, + ): void { + this._configs.set(cameraID, cameraConfig); + this._engines.set(cameraID, engine); + this._enginesByType.set(engine.getEngineType(), engine); + } + + public getCameraCount(): number { + return this._configs.size; + } + + public hasCameraID(cameraID: string): boolean { + return this._configs.has(cameraID); + } + + public getCameraConfig(cameraID: string): CameraConfig | null { + return this._configs.get(cameraID) ?? null; + } + + public getCameras(): CameraConfigs { + return this._configs; + } + + public getCameraIDs(): Set { + return new Set(this._configs.keys()); + } + + public getCameraConfigForMedia(media: ViewMedia): CameraConfig | null { + const cameraID = media.getCameraID(); + if (!cameraID) { + return null; + } + return this.getCameraConfig(cameraID); + } + + public getEngineOfType(engine: Engine): CameraManagerEngine | null { + return this._enginesByType.get(engine) ?? null; + } + + public getEngineForCameraID(cameraID: string): CameraManagerEngine | null { + return this._engines.get(cameraID) ?? null; + } + + public getEnginesForCameraIDs( + cameraIDs: Set, + ): CameraManagerEngineCameraIDMap | null { + const output: CameraManagerEngineCameraIDMap = new Map(); + + for (const cameraID of cameraIDs) { + const engine = this.getEngineForCameraID(cameraID); + if (!engine) { + continue; + } + if (!output.has(engine)) { + output.set(engine, new Set()); + } + output.get(engine)?.add(cameraID); + } + return output.size ? output : null; + } + + public getEngineForMedia(media: ViewMedia): CameraManagerEngine | null { + const cameraID = media.getCameraID(); + if (!cameraID) { + return null; + } + return this.getEngineForCameraID(cameraID); + } + + public getAllEngines(): CameraManagerEngine[] { + return uniq([...this._engines.values()]); + } +} diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts index 88d49c4c..ee660957 100644 --- a/src/camera-manager/types.ts +++ b/src/camera-manager/types.ts @@ -1,3 +1,5 @@ +import { CameraConfig, FrigateCardView } from '../types'; +import { ViewMedia } from '../view/media'; import { FrigateEvent, FrigateRecording } from './frigate/types'; // ==== @@ -18,6 +20,7 @@ export enum QueryResultsType { export enum Engine { Frigate = 'frigate', + Generic = 'generic', } export interface DataQuery { @@ -85,6 +88,10 @@ export interface MediaMetadata { interface BaseCapabilities { canFavoriteEvents: boolean; canFavoriteRecordings: boolean; + + supportsClips: boolean; + supportsRecordings: boolean; + supportsSnapshots: boolean; supportsTimeline: boolean; } @@ -99,6 +106,13 @@ export interface CameraManagerCameraMetadata { icon: string; } +export interface CameraURLContext { + media?: ViewMedia; + view?: FrigateCardView; +} + +export type CameraConfigs = Map; + // =========== // Event Query // =========== diff --git a/src/card.ts b/src/card.ts index eebdae04..0d295f19 100644 --- a/src/card.ts +++ b/src/card.ts @@ -11,7 +11,6 @@ import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { StyleInfo, styleMap } from 'lit/directives/style-map.js'; -import { until } from 'lit/directives/until.js'; import throttle from 'lodash-es/throttle'; import screenfull from 'screenfull'; import { z } from 'zod'; @@ -33,19 +32,13 @@ import './components/message.js'; import { renderMessage, renderProgressIndicator } from './components/message.js'; import './components/thumbnail-carousel.js'; import { isConfigUpgradeable } from './config-mgmt.js'; -import { - CAMERA_BIRDSEYE, - MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA, - REPO_URL, -} from './const.js'; +import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA, REPO_URL } from './const.js'; import { getLanguage, loadLanguages, localize } from './localize/localize.js'; import cardStyle from './scss/card.scss'; import { Actions, ActionType, CameraConfig, - EntityList, - ExtendedEntity, ExtendedHomeAssistant, FrigateCardConfig, frigateCardConfigSchema, @@ -58,6 +51,8 @@ import { Message, RawFrigateCardConfig, CardWideConfig, + FrigateCardError, + FRIGATE_CARD_VIEW_DEFAULT, } from './types.js'; import { convertActionToFrigateCardCustomAction, @@ -67,7 +62,6 @@ import { getActionConfigGivenAction, } from './utils/action.js'; import { contentsChanged, errorToConsole } from './utils/basic.js'; -import { getCameraID } from './utils/camera.js'; import { getEntityIcon, getEntityTitle, @@ -79,12 +73,6 @@ import { sideLoadHomeAssistantElements, } from './utils/ha'; import { DeviceList, getAllDevices } from './utils/ha/device-registry.js'; -import { - ExtendedEntityCache, - getAllEntities, - getExtendedEntities, - getExtendedEntity, -} from './utils/ha/entity-registry.js'; import { ResolvedMediaCache } from './utils/ha/resolved-media.js'; import { supportsFeature } from './utils/ha/update.js'; import { isValidMediaLoadedInfo } from './utils/media-info.js'; @@ -95,6 +83,10 @@ import { CameraManager } from './camera-manager/manager.js'; import { setLowPerformanceProfile, setPerformanceCSSStyles } from './performance.js'; import { CameraManagerEngineFactory } from './camera-manager/engine-factory.js'; import { log } from './utils/debug.js'; +import { EntityRegistryManager } from './utils/ha/entity-registry/index.js'; +import { EntityCache } from './utils/ha/entity-registry/cache.js'; +import { Entity, ExtendedEntity } from './utils/ha/entity-registry/types.js'; +import { getAllDependentCameras } from './utils/camera.js'; /** A note on media callbacks: * @@ -196,9 +188,6 @@ class FrigateCard extends LitElement { // Array of dynamic menu buttons to be added to menu. protected _dynamicMenuButtons: MenuButton[] = []; - @state() - protected _cameras?: Map; - // Error/info message to render. protected _message: Message | null = null; @@ -207,6 +196,8 @@ class FrigateCard extends LitElement { protected _cameraManager?: CameraManager; + protected _entityRegistryManager: EntityRegistryManager; + // The mouse handler may be called continually, throttle it to at most once // per second for performance reasons. protected _boundMouseHandler = throttle(this._mouseHandler.bind(this), 1 * 1000); @@ -219,6 +210,14 @@ class FrigateCard extends LitElement { protected _conditionManager: CardConditionManager | null = null; + constructor() { + super(); + this._entityRegistryManager = new EntityRegistryManager( + new EntityCache(), + new EntityCache(), + ); + } + /** * Set the Home Assistant object. */ @@ -383,6 +382,17 @@ class FrigateCard extends LitElement { protected _getMenuButtons(): MenuButton[] { const buttons: MenuButton[] = []; + const cameras = this._cameraManager?.getCameras(); + const selectedCameraID = this._view?.camera; + const selectedCameraConfig = this._getSelectedCameraConfig(); + const allSelectedCameraIDs = + cameras && selectedCameraID + ? getAllDependentCameras(cameras, selectedCameraID) + : null; + const cameraCapabilities = allSelectedCameraIDs + ? this._cameraManager?.getAggregateCameraCapabilities(allSelectedCameraIDs) + : null; + buttons.push({ // Use a magic icon value that the menu will use to render the custom // Frigate icon. @@ -398,8 +408,8 @@ class FrigateCard extends LitElement { ) as FrigateCardCustomAction, }); - if (this._cameras && this._cameras.size > 1) { - const menuItems = Array.from(this._cameras, ([cameraID, config]) => { + if (cameras) { + const menuItems = Array.from(cameras, ([cameraID, config]) => { const action = createFrigateCardCustomAction('camera_select', { camera: cameraID, }); @@ -436,16 +446,7 @@ class FrigateCard extends LitElement { tap_action: createFrigateCardCustomAction('live') as FrigateCardCustomAction, }); - const cameraConfig = this._getSelectedCameraConfig(); - - // Don't show `clips` button if there's no `camera_name` (e.g. non-Frigate - // cameras), or is birdseye (unless there are dependent cameras). - if ( - cameraConfig?.frigate.camera_name && - (cameraConfig?.frigate.camera_name !== CAMERA_BIRDSEYE || - cameraConfig.dependencies.cameras.length || - cameraConfig.dependencies.all_cameras) - ) { + if (cameraCapabilities?.supportsClips) { buttons.push({ icon: 'mdi:filmstrip', ...this._getConfig().menu.buttons.clips, @@ -457,14 +458,7 @@ class FrigateCard extends LitElement { }); } - // Don't show `snapshots` button if there's no `camera_name` (e.g. non-Frigate - // cameras), or is birdseye (unless there are dependent cameras). - if ( - cameraConfig?.frigate.camera_name && - (cameraConfig?.frigate.camera_name !== CAMERA_BIRDSEYE || - cameraConfig?.dependencies.cameras.length || - cameraConfig?.dependencies.all_cameras) - ) { + if (cameraCapabilities?.supportsSnapshots) { buttons.push({ icon: 'mdi:camera', ...this._getConfig().menu.buttons.snapshots, @@ -480,14 +474,7 @@ class FrigateCard extends LitElement { }); } - // Don't show `recordings` button if there's no `camera_name` (e.g. non-Frigate - // cameras), or is birdseye (unless there are dependent cameras). - if ( - cameraConfig?.frigate.camera_name && - (cameraConfig?.frigate.camera_name !== CAMERA_BIRDSEYE || - cameraConfig?.dependencies.cameras.length || - cameraConfig?.dependencies.all_cameras) - ) { + if (cameraCapabilities?.supportsRecordings) { buttons.push({ icon: 'mdi:album', ...this._getConfig().menu.buttons.recordings, @@ -514,13 +501,7 @@ class FrigateCard extends LitElement { // Don't show the timeline button unless there's at least one non-birdseye // camera with a Frigate camera name. - if ( - this._cameras && - [...this._cameras.values()].some( - (config) => - config.frigate.camera_name && config.frigate.camera_name !== CAMERA_BIRDSEYE, - ) - ) { + if (cameraCapabilities?.supportsTimeline) { buttons.push({ icon: 'mdi:chart-gantt', ...this._getConfig().menu.buttons.timeline, @@ -545,7 +526,7 @@ class FrigateCard extends LitElement { }); } - if (cameraConfig?.frigate.url) { + if (this._getCameraURLFromContext()) { buttons.push({ icon: 'mdi:web', ...this._getConfig().menu.buttons.frigate_ui, @@ -590,7 +571,7 @@ class FrigateCard extends LitElement { if ( mediaPlayers.length && (this._view?.isViewerView() || - (this._view?.is('live') && cameraConfig?.camera_entity)) + (this._view?.is('live') && selectedCameraConfig?.camera_entity)) ) { const mediaPlayerItems = mediaPlayers.map((playerEntityID) => { const title = getEntityTitle(this._hass, playerEntityID) || playerEntityID; @@ -660,237 +641,15 @@ class FrigateCard extends LitElement { } } - /** - * Get the motion sensor entity for a given camera. - * @param cache The ExtendedEntityCache of entity registry information. - * @param cameraConfig The camera config in question. - * @returns The entity id of the motion sensor or null. - */ - protected _getMotionSensor( - cache: ExtendedEntityCache, - cameraConfig: CameraConfig, - ): string | null { - if (cameraConfig.frigate.camera_name) { - return ( - cache.getMatch( - (ent) => - !!ent.unique_id?.match( - new RegExp( - `:motion_sensor:${ - cameraConfig.frigate.zone || cameraConfig.frigate.camera_name - }`, - ), - ), - )?.entity_id ?? null - ); - } - return null; - } - - /** - * Get the occupancy sensor entity for a given camera. - * @param cache The ExtendedEntityCache of entity registry information. - * @param cameraConfig The camera config in question. - * @returns The entity id of the occupancy sensor or null. - */ - protected _getOccupancySensor( - cache: ExtendedEntityCache, - cameraConfig: CameraConfig, - ): string | null { - if (cameraConfig.frigate.camera_name) { - return ( - cache.getMatch( - (ent) => - !!ent.unique_id?.match( - new RegExp( - `:occupancy_sensor:${ - cameraConfig.frigate.zone || cameraConfig.frigate.camera_name - }_${cameraConfig.frigate.label || 'all'}`, - ), - ), - )?.entity_id ?? null - ); - } - return null; - } - - /** - * Fully load the configured cameras. - */ - protected async _loadCameras(): Promise { - if (!this._hass) { - return; - } - - const hasAutoTriggers = (config: CameraConfig): boolean => { - return config.triggers.motion || config.triggers.occupancy; - }; - const hasAnyTriggers = (config: CameraConfig): boolean => { - return hasAutoTriggers(config) || !!config.triggers.entities.length; - }; - const hasCameraName = (config: CameraConfig): boolean => { - return !!config.frigate?.camera_name; - }; - - // Loading cameras may require a number of calls to Home Assistant. - // - // - getAllEntities: Required if any camera has auto-triggers (motion or - // occupancy sensors). - // - getExtendedEntity: Per camera entity to autodetect the Frigate camera - // name or to compute auto-triggers (motion or occupancy sensors). - // - getExtendedEntities: Per binary sensor associated with a Frigate config - // entry, to compute auto-triggers (motion or occupancy sensors). - // - // For loading performance these are only called when absolutely needed. - - let entityList: EntityList | undefined; - const cache = new ExtendedEntityCache(); - const loadedCameras: CameraConfig[] = []; - - const addCameraConfig = async (config: CameraConfig, index: number) => { - if (!this._hass) { - return; - } - - let entity: ExtendedEntity | null = null; - if (config.camera_entity && (hasAnyTriggers(config) || !hasCameraName(config))) { - try { - entity = await getExtendedEntity(this._hass, config.camera_entity, cache); - } catch (e) { - // Silently ignore errors here, as non-Frigate camera entities may not - // necessarily have a registry entry and otherwise this would cause - // log spam for those cases. - } - } - - if (entity && !hasCameraName(config)) { - const resolvedName = this._getFrigateCameraNameFromEntity(entity); - if (resolvedName) { - config.frigate.camera_name = resolvedName; - } - } - - if (entity && entityList && hasAnyTriggers(config)) { - if (hasAutoTriggers(config)) { - // Try to find the correct entities for the motion & occupancy sensors. - // We know they are binary_sensors, and that they'll have the same - // config entry ID as the camera. Searching via unique_id ensures this - // search still works if the user renames the entity_id. - const binarySensorEntities = entityList.filter( - (ent) => - ent.config_entry_id === entity?.config_entry_id && - !ent.disabled_by && - ent.entity_id.startsWith('binary_sensor.'), - ); - - try { - await getExtendedEntities( - this._hass, - binarySensorEntities.map((ent) => ent.entity_id), - cache, - ); - } catch (e) { - errorToConsole(e as Error); - } - - if (config.triggers.motion) { - const motionEntity = this._getMotionSensor(cache, config); - if (motionEntity) { - config.triggers.entities.push(motionEntity); - } - } - - if (config.triggers.occupancy) { - const occupancyEntity = this._getOccupancySensor(cache, config); - if (occupancyEntity) { - config.triggers.entities.push(occupancyEntity); - } - } - } - - config.triggers.entities = [...new Set(config.triggers.entities)]; - } - - loadedCameras[index] = config; - }; - - let errorFree = true; - const cameras: Map = new Map(); - const configCameras = this._getConfig().cameras; - - if (configCameras && Array.isArray(configCameras)) { - if (configCameras.some((config) => hasAutoTriggers(config))) { - try { - entityList = await getAllEntities(this._hass); - } catch (e) { - errorToConsole(e as Error); - } - } - - // Load all cameras in parallel, but remember the order they were provided - // (they must be added to the cameraMap in this same order). - await Promise.all( - configCameras.map((configCamera, index) => addCameraConfig(configCamera, index)), - ); - - loadedCameras.forEach((loadedCamera: CameraConfig) => { - const id = getCameraID(loadedCamera); - if (!id) { - this._setMessageAndUpdate({ - message: localize('error.no_camera_id'), - type: 'error', - context: loadedCamera, - }); - errorFree = false; - } else if (cameras.has(id)) { - this._setMessageAndUpdate({ - message: localize('error.duplicate_camera_id'), - type: 'error', - context: loadedCamera, - }); - errorFree = false; - } else { - cameras.set(id, loadedCamera); - } - }); - } - - if (!cameras.size) { - return this._setMessageAndUpdate({ - message: localize('error.no_cameras'), - type: 'error', - }); - errorFree = false; - } - - if (errorFree) { - this._cameras = cameras; - } - } - /** * Get the camera configuration for the selected camera. * @returns The CameraConfig object or null if not found. */ protected _getSelectedCameraConfig(): CameraConfig | null { - if (!this._cameras || !this._cameras.size || !this._view?.camera) { + if (!this._view || !this._cameraManager) { return null; } - return this._cameras.get(this._view.camera) || null; - } - - /** - * Get the Frigate camera name from an entity. - * @returns The Frigate camera name or null if unavailable. - */ - protected _getFrigateCameraNameFromEntity(entity: ExtendedEntity): string | null { - if (entity.unique_id && entity.platform === 'frigate') { - const match = entity.unique_id.match(/:camera:(?[^:]+)$/); - if (match && match.groups) { - return match.groups['camera']; - } - } - return null; + return this._cameraManager.getCameraConfig(this._view.camera); } /** @@ -997,7 +756,7 @@ class FrigateCard extends LitElement { }; this._overriddenConfig = undefined; - this._cameras = undefined; + this._cameraManager = undefined; this._view = undefined; this._message = null; @@ -1039,24 +798,27 @@ class FrigateCard extends LitElement { if (!args?.view) { // Load the default view. - let camera; - if (this._cameras?.size) { - if (this._view?.camera && this._getConfig().view.update_cycle_camera) { - const keys = Array.from(this._cameras.keys()); - const currentIndex = keys.indexOf(this._view.camera); - const targetIndex = currentIndex + 1 >= keys.length ? 0 : currentIndex + 1; - camera = keys[targetIndex]; - } else { - // Reset to the default camera. - camera = this._cameras.keys().next().value; + let cameraID: string | null = null; + if (this._cameraManager) { + const cameras = this._cameraManager.getCameras(); + if (cameras) { + if (this._view?.camera && this._getConfig().view.update_cycle_camera) { + const keys = Array.from(cameras.keys()); + const currentIndex = keys.indexOf(this._view.camera); + const targetIndex = currentIndex + 1 >= keys.length ? 0 : currentIndex + 1; + cameraID = keys[targetIndex]; + } else { + // Reset to the default camera. + cameraID = cameras.keys().next().value; + } } } - if (camera) { + if (cameraID) { changeView( new View({ view: this._getConfig().view.default, - camera: camera, + camera: cameraID, }), ); @@ -1103,22 +865,53 @@ class FrigateCard extends LitElement { }); } + protected async _initializeCameras(): Promise { + if (!this._hass || !this._cameraManager) { + return; + } + + try { + await this._cameraManager.initializeCameras( + this._hass, + this._entityRegistryManager, + this._getConfig().cameras, + ); + } catch (e: unknown) { + if (e instanceof Error) { + errorToConsole(e); + } + if (e instanceof FrigateCardError) { + this._setMessageAndUpdate({ + message: e.message, + type: 'error', + context: e.context, + }); + } + } + + // Don't reset the message which may be set to an error above. This sets the + // first view using the newly loaded cameras. + this._changeView({ resetMessage: false }); + } + /** * Called before each update. */ protected willUpdate(changedProps: PropertyValues): void { if ( - this._cameras && this._cardWideConfig && - (changedProps.has('_config') || - changedProps.has('_cameras') || + (!this._cameraManager || + changedProps.has('_config') || changedProps.has('_cardWideConfig')) ) { this._cameraManager = new CameraManager( - new CameraManagerEngineFactory(this._cardWideConfig), - this._cameras, + new CameraManagerEngineFactory( + this._entityRegistryManager, + this._cardWideConfig, + ), this._cardWideConfig, ); + this._initializeCameras().then(() => this.requestUpdate()); } if (changedProps.has('_cardWideConfig')) { @@ -1162,7 +955,8 @@ class FrigateCard extends LitElement { let changedCamera = false; let triggerChanges = false; - for (const [camera, config] of this._cameras?.entries() ?? []) { + const cameras = this._cameraManager?.getCameras(); + for (const [cameraID, config] of cameras?.entries() ?? []) { const triggerEntities = config.triggers.entities ?? []; const diffs = getHassDifferences(this._hass, oldHass, triggerEntities, { stateOnly: true, @@ -1172,10 +966,10 @@ class FrigateCard extends LitElement { (entity) => !isTriggeredState(this._hass?.states[entity]), ); if (shouldTrigger) { - this._triggers.set(camera, now); + this._triggers.set(cameraID, now); triggerChanges = true; - } else if (shouldUntrigger && this._triggers.has(camera)) { - this._triggers.delete(camera); + } else if (shouldUntrigger && this._triggers.has(cameraID)) { + this._triggers.delete(cameraID); triggerChanges = true; } } @@ -1419,7 +1213,7 @@ class FrigateCard extends LitElement { const cameraEntity = cameraConfig.camera_entity ?? null; const media = this._view.queryResults?.getSelectedResult(); - if (this._view.isViewerView() && media && this._cameras) { + if (this._view.isViewerView() && media) { media_content_id = media.getContentID(); media_content_type = media.getContentType(); title = media.getTitle(); @@ -1491,9 +1285,9 @@ class FrigateCard extends LitElement { this._downloadViewerMedia(); break; case 'frigate_ui': - const frigate_url = this._getFrigateURLFromContext(); - if (frigate_url) { - window.open(frigate_url); + const url = this._getCameraURLFromContext(); + if (url) { + window.open(url); } break; case 'fullscreen': @@ -1508,22 +1302,15 @@ class FrigateCard extends LitElement { this._refMenu.value?.toggleMenu(); break; case 'camera_select': - const camera = frigateCardAction.camera; - if (this._cameras?.has(camera) && this._view) { - const targetView = View.selectBestViewForUserSpecified( - this._getConfig().view.camera_select === 'current' - ? this._view.view - : (this._getConfig().view.camera_select as FrigateCardView), - ); - this._changeView({ - view: new View({ - view: this._cameras?.get(camera)?.frigate.camera_name - ? targetView - : // Fallback to supported views for non-Frigate cameras. - View.selectBestViewForNonFrigateCameras(targetView), - camera: camera, - }), - }); + const cameraID = frigateCardAction.camera; + if (this._cameraManager?.hasCameraID(cameraID) && this._view) { + const viewOnCameraSelect = this._getConfig().view.camera_select; + const targetView = + viewOnCameraSelect === 'current' ? this._view.view : viewOnCameraSelect; + const actualView = this.isViewSupportedByCamera(cameraID, targetView) + ? targetView + : FRIGATE_CARD_VIEW_DEFAULT; + this._changeView({ view: new View({ view: actualView, camera: cameraID }) }); } break; case 'media_player': @@ -1540,6 +1327,33 @@ class FrigateCard extends LitElement { } } + public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean { + const capabilities = this._cameraManager?.getCameraCapabilities(cameraID); + switch (view) { + case 'live': + case 'image': + 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 + ); + } + return false; + } + /** * Generate diagnostics for issue reports. */ @@ -1593,18 +1407,16 @@ class FrigateCard extends LitElement { * Get the Frigate UI URL from context. * @returns The URL or null if unavailable. */ - protected _getFrigateURLFromContext(): string | null { - const cameraConfig = this._getSelectedCameraConfig(); - if (!cameraConfig || !cameraConfig.frigate.url || !this._view) { - return null; - } - if (!cameraConfig.frigate.camera_name) { - return cameraConfig.frigate.url; - } - if (this._view.isViewerView() || this._view.isGalleryView()) { - return `${cameraConfig.frigate.url}/events?camera=${cameraConfig.frigate.camera_name}`; - } - return `${cameraConfig.frigate.url}/cameras/${cameraConfig.frigate.camera_name}`; + protected _getCameraURLFromContext(): string | null { + const view = this._view; + const selectedCameraID = view?.camera; + const media = view?.queryResults?.getSelectedResult() ?? null; + return this._hass && view && selectedCameraID + ? this._cameraManager?.getCameraURL(selectedCameraID, { + ...(media && { media: media }), + ...(view && { view: view.view }), + }) ?? null + : null; } /** @@ -1961,23 +1773,14 @@ class FrigateCard extends LitElement { > ${renderMenuAbove ? this._renderMenu() : ''}
- ${this._cameras === undefined && !this._message - ? until( - (async () => { - await this._loadCameras(); - // Don't reset messages as errors may have been generated - // during the camera load. - this._changeView({ resetMessage: false }); - return this._render(); - })(), - renderProgressIndicator({ cardWideConfig: this._cardWideConfig }), - ) + ${!this._cameraManager?.isInitialized() && !this._message + ? renderProgressIndicator({ cardWideConfig: this._cardWideConfig }) : // Always want to call render even if there's a message, to // ensure live preload is always present (even if not displayed). this._render()} ${ - // Keep message rendering to last to show messages that may have - // been generated during the render. + // Keep message rendering to last to show messages that may have been + // generated during the render. this._message ? renderMessage(this._message) : '' }
@@ -2011,7 +1814,7 @@ class FrigateCard extends LitElement { protected _render(): TemplateResult | void { const cameraConfig = this._getSelectedCameraConfig(); - if (!this._hass || !this._view || !cameraConfig || !this._cameras) { + if (!this._hass || !this._view || !cameraConfig) { return html``; } @@ -2037,7 +1840,6 @@ class FrigateCard extends LitElement { ? html` @@ -2082,7 +1882,6 @@ class FrigateCard extends LitElement { .liveConfig=${this._config.live} .conditionState=${this._conditionState} .liveOverrides=${getOverridesByKey(this._getConfig().overrides, 'live')} - .cameras=${this._cameras} .cameraManager=${this._cameraManager} .cardWideConfig=${this._cardWideConfig} class="${classMap(liveClasses)}" diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 27158c30..591ce222 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -10,7 +10,6 @@ import { import { customElement, property, state } from 'lit/decorators.js'; import galleryStyle from '../scss/gallery.scss'; import { - CameraConfig, CardWideConfig, ExtendedHomeAssistant, frigateCardConfigDefaults, @@ -24,7 +23,7 @@ import { } from '../utils/media-to-view.js'; import { CameraManager, ExtendedMediaQueryResult } from '../camera-manager/manager.js'; import { View } from '../view/view.js'; -import { renderProgressIndicator } from './message.js'; +import { dispatchMessageEvent, renderProgressIndicator } from './message.js'; import './thumbnail.js'; import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js'; import { createRef, Ref } from 'lit/directives/ref.js'; @@ -36,6 +35,7 @@ import { errorToConsole } from '../utils/basic'; import './media-filter'; import "./surround-basic"; import { ViewMedia } from '../view/media'; +import { localize } from '../localize/localize'; const GALLERY_MEDIA_CHUNK_SIZE = 100; @@ -55,9 +55,6 @@ export class FrigateCardGallery extends LitElement { @property({ attribute: false }) public galleryConfig?: GalleryConfig; - @property({ attribute: false }) - public cameras?: Map; - @property({ attribute: false }) public cameraManager?: CameraManager; @@ -72,7 +69,6 @@ export class FrigateCardGallery extends LitElement { if ( !this.hass || !this.view || - !this.cameras || !this.view.isGalleryView() || !this.cameraManager ) { @@ -85,7 +81,6 @@ export class FrigateCardGallery extends LitElement { this, this.hass, this.cameraManager, - this.cameras, this.view, ); } else { @@ -98,7 +93,6 @@ export class FrigateCardGallery extends LitElement { this, this.hass, this.cameraManager, - this.cameras, this.view, { ...(mediaType && { mediaType: mediaType }), @@ -120,7 +114,6 @@ export class FrigateCardGallery extends LitElement { ${this.galleryConfig && this.galleryConfig.controls.filter.mode !== 'none' ? html` @@ -166,9 +158,6 @@ export class FrigateCardGalleryCore extends LitElement { @property({ attribute: false }) public galleryConfig?: GalleryConfig; - @property({ attribute: false }) - public cameras?: Map; - @property({ attribute: false }) public cameraManager?: CameraManager; @@ -330,12 +319,17 @@ export class FrigateCardGalleryCore extends LitElement { !this._media || !this.hass || !this.view || - !this.view.isGalleryView() || - !this.cameras + !this.view.isGalleryView() ) { return html``; } + if ((this.view?.queryResults?.getResultsCount() ?? 0) === 0) { + return dispatchMessageEvent(this, localize('common.no_media'), 'info', { + icon: 'mdi:multimedia', + }); + } + return html` ${this._media.map( (media, index) => diff --git a/src/components/live/live.ts b/src/components/live/live.ts index 2b5da9a5..769fe5cd 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -104,9 +104,6 @@ export class FrigateCardLive extends LitElement { @property({ attribute: false }) public view?: Readonly; - @property({ attribute: false }) - public cameras?: Map; - @property({ attribute: false }) public liveConfig?: LiveConfig; @@ -200,7 +197,7 @@ export class FrigateCardLive extends LitElement { * @returns A rendered template. */ protected render(): TemplateResult | void { - if (!this.hass || !this.liveConfig || !this.cameras || !this.view) { + if (!this.hass || !this.liveConfig || !this.cameraManager || !this.view) { return; } @@ -229,7 +226,6 @@ export class FrigateCardLive extends LitElement { .fetchMedia=${config.controls.thumbnails.media} .thumbnailConfig=${config.controls.thumbnails} .timelineConfig=${config.controls.timeline} - .cameras=${this.cameras} .cameraManager=${this.cameraManager} .inBackground=${this._inBackground} @frigate-card:message=${(ev: CustomEvent) => { @@ -254,7 +250,6 @@ export class FrigateCardLive extends LitElement { ; - @property({ attribute: false }) - public cameras?: Map; - @property({ attribute: false }) public liveConfig?: LiveConfig; @@ -345,10 +337,11 @@ export class FrigateCardLiveCarousel extends LitElement { } protected _getSelectedCameraIndex(): number { - if (!this.cameras || !this.view) { + const cameraIDs = this.cameraManager?.getCameraIDs(); + if (!cameraIDs || !this.view) { return 0; } - return Math.max(0, Array.from(this.cameras.keys()).indexOf(this.view.camera)); + return Math.max(0, Array.from(cameraIDs).indexOf(this.view.camera)); } /** @@ -367,9 +360,10 @@ export class FrigateCardLiveCarousel extends LitElement { * @returns A list of EmblaOptionsTypes. */ protected _getPlugins(): EmblaCarouselPlugins { + const cameras = this.cameraManager?.getCameraIDs(); return [ // Only enable wheel plugin if there is more than one camera. - ...(this.cameras && this.cameras.size > 1 + ...(cameras && cameras.size > 1 ? [ WheelGesturesPlugin({ // Whether the carousel is vertical or horizontal, interpret y-axis wheel @@ -424,14 +418,15 @@ export class FrigateCardLiveCarousel extends LitElement { * name to slide number. */ protected _getSlides(): [TemplateResult[], Record] { - if (!this.cameras) { + const cameras = this.cameraManager?.getCameras(); + if (!cameras) { return [[], {}]; } const slides: TemplateResult[] = []; const cameraToSlide: Record = {}; - for (const [camera, cameraConfig] of this.cameras) { + for (const [camera, cameraConfig] of cameras) { const slide = this._renderLive(camera, cameraConfig, slides.length); if (slide) { cameraToSlide[camera] = slides.length; @@ -445,8 +440,9 @@ export class FrigateCardLiveCarousel extends LitElement { * Handle the user selecting a new slide in the carousel. */ protected _setViewHandler(ev: CustomEvent): void { - if (this.cameras && ev.detail.index !== this._getSelectedCameraIndex()) { - this._setViewCameraID(Array.from(this.cameras.keys())[ev.detail.index]); + const cameras = this.cameraManager?.getCameras(); + if (cameras && ev.detail.index !== this._getSelectedCameraIndex()) { + this._setViewCameraID(Array.from(cameras.keys())[ev.detail.index]); } } @@ -534,19 +530,20 @@ export class FrigateCardLiveCarousel extends LitElement { } protected _getCameraIDsOfNeighbors(): [string | null, string | null] { - if (!this.cameras || !this.view || !this.hass) { + const cameras = this.cameraManager?.getCameras(); + if (!cameras || !this.view || !this.hass) { return [null, null]; } - const keys = Array.from(this.cameras.keys()); + const keys = Array.from(cameras.keys()); const currentIndex = keys.indexOf(this.view.camera); - if (currentIndex < 0 || this.cameras.size <= 1) { + if (currentIndex < 0 || cameras.size <= 1) { return [null, null]; } return [ - keys[currentIndex > 0 ? currentIndex - 1 : this.cameras.size - 1], - keys[currentIndex + 1 < this.cameras.size ? currentIndex + 1 : 0], + keys[currentIndex > 0 ? currentIndex - 1 : cameras.size - 1], + keys[currentIndex + 1 < cameras.size ? currentIndex + 1 : 0], ]; } @@ -602,11 +599,11 @@ export class FrigateCardLiveCarousel extends LitElement { ; - @property({ attribute: false }) public cameraManager?: CameraManager; @@ -173,7 +169,8 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { // eslint-disable-next-line @typescript-eslint/no-unused-vars _ev: CustomEvent<{ value: unknown }>, ): Promise { - if (!this.hass || !this.cameras || !this.cameraManager || !this.view) { + const cameras = this.cameraManager?.getCameras(); + if (!this.hass || !cameras || !this.cameraManager || !this.view) { return; } @@ -187,7 +184,7 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { }; const cameraIDs = - getArrayValueAsSet(this._refCamera.value?.value) ?? new Set(this.cameras.keys()); + getArrayValueAsSet(this._refCamera.value?.value) ?? new Set(cameras.keys()); const mediaType = this._refMediaType.value?.value as | MediaFilterMediaType | undefined; @@ -229,20 +226,13 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { ]; ( - await createViewForEvents( - this, - this.hass, - this.cameraManager, - this.cameras, - this.view, - { - query: new EventMediaQueries(queries), + await createViewForEvents(this, this.hass, this.cameraManager, this.view, { + query: new EventMediaQueries(queries), - // See 'A note on views' above for these two arguments. - ...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }), - targetView: mediaType === MediaFilterMediaType.Clips ? 'clips' : 'snapshots', - }, - ) + // See 'A note on views' above for these two arguments. + ...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }), + targetView: mediaType === MediaFilterMediaType.Clips ? 'clips' : 'snapshots', + }) )?.dispatchChangeEvent(this); } else if (mediaType === MediaFilterMediaType.Recordings) { const query: RecordingQuery = { @@ -252,32 +242,28 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { }; ( - await createViewForRecordings( - this, - this.hass, - this.cameraManager, - this.cameras, - this.view, - { - query: new RecordingMediaQueries([query]), + await createViewForRecordings(this, this.hass, this.cameraManager, this.view, { + query: new RecordingMediaQueries([query]), - // See 'A note on views' above for these two arguments. - ...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }), - targetView: 'recordings', - }, - ) + // See 'A note on views' above for these two arguments. + ...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }), + targetView: 'recordings', + }) )?.dispatchChangeEvent(this); } } protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('cameras') && this.cameras) { - this._cameraOptions = Array.from(this.cameras.keys()).map((cameraID) => ({ - value: cameraID, - label: this.hass - ? this.cameraManager?.getCameraMetadata(this.hass, cameraID)?.title ?? '' - : '', - })); + if (changedProps.has('cameraManager')) { + const cameras = this.cameraManager?.getCameras(); + if (cameras) { + this._cameraOptions = Array.from(cameras.keys()).map((cameraID) => ({ + value: cameraID, + label: this.hass + ? this.cameraManager?.getCameraMetadata(this.hass, cameraID)?.title ?? '' + : '', + })); + } } if (changedProps.has('cameraManager') && this.hass && this.cameraManager) { @@ -321,7 +307,8 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { protected _getDefaultsFromView(): MediaFilterCoreDefaults | null { const queries = this.view?.query?.getQueries(); - if (!this.view || !queries) { + const cameras = this.cameraManager?.getCameras(); + if (!this.view || !queries || !cameras) { return null; } @@ -337,7 +324,7 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { ); // Special note: If all cameras are selected, this is the same as no // selector at all. - if (cameraIDSets.length === 1 && queries[0].cameraIDs.size !== this.cameras?.size) { + if (cameraIDSets.length === 1 && queries[0].cameraIDs.size !== cameras.size) { cameraIDs = [...queries[0].cameraIDs]; } diff --git a/src/components/surround.ts b/src/components/surround.ts index 9e85a249..12ee3b60 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -9,7 +9,6 @@ import { import { customElement, property } from 'lit/decorators.js'; import surroundStyle from '../scss/surround.scss'; import { - CameraConfig, ClipsOrSnapshotsOrAll, ExtendedHomeAssistant, MiniTimelineControlConfig, @@ -55,9 +54,6 @@ export class FrigateCardSurround extends LitElement { @property({ attribute: false, hasChanged: contentsChanged }) public fetchMedia?: ClipsOrSnapshotsOrAll; - @property({ attribute: false }) - public cameras?: Map; - @property({ attribute: false }) public cameraManager?: CameraManager; @@ -71,7 +67,6 @@ export class FrigateCardSurround extends LitElement { */ protected async _fetchMedia(): Promise { if ( - !this.cameras || !this.cameraManager || !this.fetchMedia || this.inBackground || @@ -88,7 +83,6 @@ export class FrigateCardSurround extends LitElement { this, this.hass, this.cameraManager, - this.cameras, this.view, { targetView: this.view.view, @@ -138,11 +132,12 @@ export class FrigateCardSurround extends LitElement { } protected _getCameraIDsForTimeline(): Set | null { - if (!this.view || !this.cameras) { + const cameras = this.cameraManager?.getCameras(); + if (!this.view || !cameras) { return null; } if (this.view?.is('live')) { - return getAllDependentCameras(this.cameras, this.view.camera); + return getAllDependentCameras(cameras, this.view.camera); } if (this.view.isViewerView()) { return new Set( @@ -160,7 +155,7 @@ export class FrigateCardSurround extends LitElement { * @returns A rendered template. */ protected render(): TemplateResult | void { - if (!this.hass || !this.view || !this.thumbnailConfig || !this.cameras) { + if (!this.hass || !this.view || !this.thumbnailConfig) { return; } @@ -220,7 +215,6 @@ export class FrigateCardSurround extends LitElement { slot=${this.timelineConfig.mode} .hass=${this.hass} .view=${this.view} - .cameras=${this.cameras} .cameraIDs=${this._cameraIDsForTimeline} .mini=${true} .timelineConfig=${this.timelineConfig} diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index b0283f6b..4fb63264 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -13,7 +13,6 @@ import { classMap } from 'lit/directives/class-map.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss'; import { - CameraConfig, ExtendedHomeAssistant, ThumbnailsControlConfig, } from '../types.js'; diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index ccc708b5..7e68c7a7 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -26,7 +26,6 @@ import { TimelineOptionsCluster, TimelineWindow, } from 'vis-timeline/esnext'; -import { CAMERA_BIRDSEYE } from '../const'; import { localize } from '../localize/localize'; import timelineCoreStyle from '../scss/timeline-core.scss'; import { @@ -165,9 +164,6 @@ export class FrigateCardTimelineCore extends LitElement { @property({ attribute: false }) public view?: Readonly; - @property({ attribute: false }) - public cameras?: Map; - @property({ attribute: false, hasChanged: contentsChanged }) public timelineConfig?: TimelineCoreConfig; @@ -240,11 +236,12 @@ export class FrigateCardTimelineCore extends LitElement { protected _handleThumbnailDataRequest(request: ThumbnailDataRequestEvent): void { const item = request.detail.item; const media = this._timelineSource?.dataset.get(item)?.media; + const cameraConfig = media + ? this.cameraManager?.getCameraConfig(media.getCameraID()) ?? undefined + : undefined; request.detail.hass = this.hass; - request.detail.cameraConfig = media - ? this.cameras?.get(media.getCameraID()) - : undefined; + request.detail.cameraConfig = cameraConfig; request.detail.cameraManager = this.cameraManager; request.detail.media = media; request.detail.view = this.view; @@ -255,13 +252,13 @@ export class FrigateCardTimelineCore extends LitElement { * @returns A rendered template. */ protected render(): TemplateResult | void { - if (!this.hass || !this.view || !this.timelineConfig) { + const cameraIDs = this._getTimelineCameraIDs(); + + if (!this.hass || !this.view || !this.timelineConfig || !cameraIDs) { return; } - const capabilities = this.cameraManager?.getAggregateCameraCapabilities( - this._getTimelineCameraIDs(), - ); + const capabilities = this.cameraManager?.getAggregateCameraCapabilities(cameraIDs); return html` ${capabilities?.supportsTimeline ? html`
{ + protected _getTimelineCameraIDs(): Set | null { return this.cameraIDs ?? this._getAllCameraIDs(); } @@ -300,8 +297,8 @@ export class FrigateCardTimelineCore extends LitElement { * Get all the keys of all cameras. * @returns A set of camera ids (may be empty). */ - protected _getAllCameraIDs(): Set { - return new Set(this.cameras?.keys()); + protected _getAllCameraIDs(): Set | null { + return this.cameraManager?.getCameraIDs() ?? null; } /** @@ -395,6 +392,7 @@ export class FrigateCardTimelineCore extends LitElement { ): Promise { const results = this.view?.queryResults; const media = results?.getResults(); + const cameraIDs = this._getTimelineCameraIDs(); if ( !media || !results || @@ -402,7 +400,7 @@ export class FrigateCardTimelineCore extends LitElement { !this.view || !this.hass || !this.cameraManager || - !this.cameraManager || + !cameraIDs || // Skip range changes that do not have hammerjs pan directions associated // with them, as these outliers cause media matching issues below. !properties.event.additionalEvent @@ -420,7 +418,7 @@ export class FrigateCardTimelineCore extends LitElement { findClosestMediaIndex( media, targetTime, - this._getTimelineCameraIDs(), + cameraIDs, properties.event.additionalEvent === 'panright' ? 'end' : 'start', ), ); @@ -462,13 +460,14 @@ export class FrigateCardTimelineCore extends LitElement { stopEventFromActivatingCardWideActions(properties.event); } + const timelineCameraIDs = this._getTimelineCameraIDs(); if ( this._ignoreClick || !this.hass || !this._timeline || - !this.cameras || !this.view || !this.cameraManager || + !timelineCameraIDs || !properties.what ) { return; @@ -484,7 +483,6 @@ export class FrigateCardTimelineCore extends LitElement { this, this.hass, this.cameraManager, - this.cameras, this.view, { targetTime: @@ -501,10 +499,9 @@ export class FrigateCardTimelineCore extends LitElement { this, this.hass, this.cameraManager, - this.cameras, this.view, { - cameraIDs: this._getAllCameraIDs(), + cameraIDs: timelineCameraIDs, start: startOfHour(properties.time), end: endOfHour(properties.time), targetTime: properties.time, @@ -514,9 +511,7 @@ export class FrigateCardTimelineCore extends LitElement { const newResults = this.view.queryResults ?.clone() .resetSelectedResult() - .selectResultIfFound( - (media) => !!this.cameras && media.getID() === properties.item, - ); + .selectResultIfFound((media) => media.getID() === properties.item); if (!newResults || !newResults.hasSelectedResult()) { // This can happen if this is a recording query (with recorded hours) @@ -588,7 +583,7 @@ export class FrigateCardTimelineCore extends LitElement { } this._removeTargetBar(); - if (!this.hass || !this.cameras) { + if (!this.hass) { return; } @@ -646,14 +641,13 @@ export class FrigateCardTimelineCore extends LitElement { selectedItem?: IdType; }, ): Promise { - if (!this.hass || !this.cameraManager || !this.cameras || !this.view || !query) { + if (!this.hass || !this.cameraManager || !this.view || !query) { return null; } const view = await createViewForEvents( this, this.hass, this.cameraManager, - this.cameras, this.view, { query: query, @@ -666,7 +660,7 @@ export class FrigateCardTimelineCore extends LitElement { } if (options?.selectedItem) { view.queryResults?.selectResultIfFound( - (media) => !!this.cameras && media.getID() === options.selectedItem, + (media) => media.getID() === options.selectedItem, ); } else { // If not asked to select a new item, persist the currently selected item @@ -687,10 +681,8 @@ export class FrigateCardTimelineCore extends LitElement { */ protected _getGroups(): DataGroupCollectionType { const groups: FrigateCardGroupData[] = []; - - this._getTimelineCameraIDs().forEach((cameraID) => { - const cameraConfig = this.cameras?.get(cameraID); - if (!this.hass || !cameraConfig || !this.cameraManager) { + (this._getTimelineCameraIDs() ?? []).forEach((cameraID) => { + if (!this.hass || !this.cameraManager) { return; } const cameraMetadata = this.cameraManager.getCameraMetadata(this.hass, cameraID); @@ -806,10 +798,6 @@ export class FrigateCardTimelineCore extends LitElement { maxItems: this.timelineConfig.clustering_threshold, clusterCriteria: (first: TimelineItem, second: TimelineItem): boolean => { - if (!this.cameras) { - return false; - } - const media = this.view?.queryResults?.getSelectedResult(); const selectedId = media?.getID(); const firstMedia = (first).media; @@ -866,7 +854,7 @@ export class FrigateCardTimelineCore extends LitElement { */ // eslint-disable-next-line @typescript-eslint/no-unused-vars protected shouldUpdate(_changedProps: PropertyValues): boolean { - return !!this.hass && !!this.cameras && this.cameras.size > 0; + return !!this.hass && !!this.cameraManager; } /** @@ -875,7 +863,6 @@ export class FrigateCardTimelineCore extends LitElement { protected async _updateTimelineFromView(): Promise { if ( !this.hass || - !this.cameras || !this.view || !this.timelineConfig || !this._timelineSource || @@ -1040,10 +1027,11 @@ export class FrigateCardTimelineCore extends LitElement { changedProps.has('timelineConfig') || changedProps.has('cameraIDs') ) { - if (this.cameraManager && this.cameras && this.timelineConfig) { + const cameraIDs = this._getTimelineCameraIDs(); + if (cameraIDs && this.cameraManager && this.timelineConfig) { this._timelineSource = new TimelineDataSource( this.cameraManager, - this._getTimelineCameraIDs(), + cameraIDs, this.timelineConfig.media, ); } else { diff --git a/src/components/timeline.ts b/src/components/timeline.ts index b8a8446b..31aafbe1 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -1,7 +1,7 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import timelineStyle from '../scss/timeline.scss'; -import { CameraConfig, ExtendedHomeAssistant, TimelineConfig } from '../types'; +import { ExtendedHomeAssistant, TimelineConfig } from '../types'; import { CameraManager } from '../camera-manager/manager'; import { View } from '../view/view'; import './surround.js'; @@ -20,9 +20,6 @@ export class FrigateCardTimeline extends LitElement { @property({ attribute: false }) public view?: Readonly; - @property({ attribute: false }) - public cameras?: Map; - @property({ attribute: false }) public timelineConfig?: TimelineConfig; @@ -43,12 +40,10 @@ export class FrigateCardTimeline extends LitElement { .view=${this.view} .thumbnailConfig=${this.timelineConfig.controls.thumbnails} .cameraManager=${this.cameraManager} - .cameras=${this.cameras} > ; - @property({ attribute: false }) public resolvedMediaCache?: ResolvedMediaCache; @@ -98,7 +94,6 @@ export class FrigateCardViewer extends LitElement { if ( !this.hass || !this.view || - !this.cameras || !this.viewerConfig || !this.cameraManager ) { @@ -120,7 +115,6 @@ export class FrigateCardViewer extends LitElement { this, this.hass, this.cameraManager, - this.cameras, this.view, { targetView: 'recording', @@ -131,7 +125,6 @@ export class FrigateCardViewer extends LitElement { this, this.hass, this.cameraManager, - this.cameras, this.view, { targetView: 'media', @@ -148,12 +141,10 @@ export class FrigateCardViewer extends LitElement { .thumbnailConfig=${this.viewerConfig.controls.thumbnails} .timelineConfig=${this.viewerConfig.controls.timeline} .cameraManager=${this.cameraManager} - .cameras=${this.cameras} > ; - @property({ attribute: false }) public cameraManager?: CameraManager; @@ -205,19 +193,17 @@ export class FrigateCardViewerCarousel extends LitElement { // A task to resolve target media if lazy loading is disabled. protected _mediaResolutionTask = new Task< - [ViewerConfig | undefined, Map | undefined, View | undefined], + [ViewerConfig | undefined, View | undefined], void >( this, - async ([viewerConfig, cameras, view]: [ + async ([viewerConfig, view]: [ ViewerConfig | undefined, - Map | undefined, View | undefined, ]): Promise => { if ( !this.hass || !viewerConfig?.lazy_load || - !cameras || !view || !view.queryResults?.hasResults() ) { @@ -234,7 +220,7 @@ export class FrigateCardViewerCarousel extends LitElement { }); await Promise.all(promises); }, - () => [this.viewerConfig, this.cameras, this.view], + () => [this.viewerConfig, this.view], ); /** @@ -453,7 +439,7 @@ export class FrigateCardViewerCarousel extends LitElement { * @param slide The slide to lazy load. */ protected _lazyloadSlide(index: number, slide: HTMLElement): void { - if (!this.hass || !this.view || !this.view.query || !this.cameras) { + if (!this.hass || !this.view || !this.view.query) { return; } @@ -512,7 +498,7 @@ export class FrigateCardViewerCarousel extends LitElement { * Determine if all the media in the carousel are resolved. */ protected _isMediaFullyResolved(): boolean { - if (!this.resolvedMediaCache || !this.cameras) { + if (!this.resolvedMediaCache) { return false; } for (const media of this.view?.queryResults?.getResults() ?? []) { @@ -560,7 +546,7 @@ export class FrigateCardViewerCarousel extends LitElement { } const media = this.view?.queryResults?.getSelectedResult(); - if (!media || !this.cameras || !this.view || !this.view.queryResults) { + if (!media || !this.view || !this.view.queryResults) { return; } @@ -649,7 +635,7 @@ export class FrigateCardViewerCarousel extends LitElement { */ protected _renderMediaItem(media: ViewMedia, index: number): TemplateResult | null { // Skip folders as they cannot be rendered by this viewer. - if (!this.hass || !this.view || !this.viewerConfig || !this.cameras) { + if (!this.hass || !this.view || !this.viewerConfig) { return null; } diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index c2dafc51..33c69413 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -354,6 +354,8 @@ "jsmpeg_no_sign": "Could not retrieve or sign JSMPEG websocket path", "live_camera_not_found": "The configured camera_entity was not found", "live_camera_unavailable": "Camera unavailable", + "no_camera_engine": "Could not determine suitable engine for camera", + "no_camera_entity": "Could not find camera entity", "no_camera_id": "Could not determine camera id for the following camera, may need to set 'id' parameter manually", "no_camera_name": "Could not determine a Frigate camera name for camera (or one of its dependents), please specify either 'camera_entity' or 'camera_name'", "no_cameras": "No valid cameras found, you must configure at least one camera entry", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index dd735149..d1a05a9e 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -325,6 +325,8 @@ "jsmpeg_no_sign": "Impossibile recuperare o firmare il percorso WebSocket JSMPEG", "live_camera_not_found": "La telecamera configurata non è stata trovata", "live_camera_unavailable": "Telecamera non disponibile", + "no_camera_engine": "", + "no_camera_entity": "", "no_camera_id": "Impossibile determinare l'ID della telecamera , potrebbe essere necessario impostare manualmente il parametro 'ID'", "no_camera_name": "Impossibile determinare un nome della telecamera in Frigate, si prega di specificare 'camera_enty' o 'camera_name'", "no_cameras": "Nessuna telecamera valida trovata, è necessario configurare almeno una voce della telecamera", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 24b125b3..453183e7 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -325,6 +325,8 @@ "jsmpeg_no_sign": "Não foi possível recuperar ou assinar o caminho do websocket JSMPEG", "live_camera_not_found": "", "live_camera_unavailable": "", + "no_camera_engine": "", + "no_camera_entity": "", "no_camera_id": "Não foi possível determinar o ID da câmera para a câmera a seguir, pode ser necessário definir o parâmetro 'id' manualmente", "no_camera_name": "Não foi possível determinar o nome da câmera da Frigate, especifique 'camera_entity' ou 'camera_name' para a câmera a seguir", "no_cameras": "Nenhuma câmera válida encontrada, você deve configurar pelo menos uma câmera", diff --git a/src/types.ts b/src/types.ts index 9f1d433a..67f94e90 100644 --- a/src/types.ts +++ b/src/types.ts @@ -49,8 +49,6 @@ const FRIGATE_CARD_VIEWS = [ ] as const; export type FrigateCardView = typeof FRIGATE_CARD_VIEWS[number]; -export type FrigateCardUserSpecifiedView = - typeof FRIGATE_CARD_VIEWS_USER_SPECIFIED[number]; export const FRIGATE_CARD_VIEW_DEFAULT = 'live' as const; const FRIGATE_MENU_STYLES = [ @@ -89,6 +87,12 @@ const MEDIA_ACTION_POSITIVE_CONDITIONS = [ export type AutoUnmuteCondition = typeof MEDIA_ACTION_POSITIVE_CONDITIONS[number]; export type AutoPlayCondition = typeof MEDIA_ACTION_POSITIVE_CONDITIONS[number]; +const ENGINES = [ + 'auto', + 'frigate', + 'generic', +] as const; + export class FrigateCardError extends Error { context?: unknown; @@ -382,6 +386,7 @@ const customSchema = z */ const cameraConfigDefault = { live_provider: 'auto' as const, + engine: 'auto' as const, frigate: { client_id: 'frigate' as const, }, @@ -413,6 +418,8 @@ const cameraConfigSchema = z // this card. id: z.string().optional(), + engine: z.enum(ENGINES).default('auto'), + frigate: z .object({ // No URL validation to allow relative URLs within HA (e.g. Frigate addon). @@ -445,6 +452,9 @@ const cameraConfigSchema = z .default(cameraConfigDefault); export type CameraConfig = z.infer; +const camerasConfigSchema = cameraConfigSchema.array().nonempty(); +export type CamerasConfig = z.infer; + /** * Custom Element Types. */ @@ -1234,7 +1244,7 @@ export interface CardWideConfig { */ export const frigateCardConfigSchema = z.object({ // Main configuration sections. - cameras: cameraConfigSchema.array().nonempty(), + cameras: camerasConfigSchema, view: viewConfigSchema, menu: menuConfigSchema, live: liveConfigSchema, @@ -1351,20 +1361,3 @@ export const signedPathSchema = z.object({ path: z.string(), }); export type SignedPath = z.infer; - -const entitySchema = z.object({ - config_entry_id: z.string().nullable(), - disabled_by: z.string().nullable(), - entity_id: z.string(), - platform: z.string(), -}); -export type Entity = z.infer; - -export const extendedEntitySchema = entitySchema.extend({ - // Extended entity results. - unique_id: z.string().optional(), -}); -export type ExtendedEntity = z.infer; - -export const entityListSchema = entitySchema.array(); -export type EntityList = z.infer; diff --git a/src/utils/basic.ts b/src/utils/basic.ts index dc89e6d8..57316fc7 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -167,11 +167,11 @@ export function getDurationString(start: Date, end: Date): string { return duration; } -export const allPromises = async ( - items: T[], - func: (arg: T) => void, -): Promise => { - await Promise.all(Array.from(items).map((item) => func(item))); +export const allPromises = async ( + items: Iterable, + func: (arg: T) => R, +): Promise[]> => { + return await Promise.all(Array.from(items).map((item) => func(item))); }; /** diff --git a/src/utils/camera.ts b/src/utils/camera.ts index bcc19710..71af9945 100644 --- a/src/utils/camera.ts +++ b/src/utils/camera.ts @@ -26,7 +26,7 @@ export function getCameraID( /** * Get all cameras that depend on a given camera. * @param cameras Cameras map. - * @param camera Name of the target camera. + * @param cameraID ID of the target camera. * @returns A set of query parameters. */ export const getAllDependentCameras = ( diff --git a/src/utils/ha/entity-registry.ts b/src/utils/ha/entity-registry.ts deleted file mode 100644 index e77f5edf..00000000 --- a/src/utils/ha/entity-registry.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { HomeAssistant } from 'custom-card-helpers'; -import { homeAssistantWSRequest } from '.'; -import { - Entity, - EntityList, - entityListSchema, - ExtendedEntity, - extendedEntitySchema, -} from '../../types.js'; - -export class ExtendedEntityCache { - protected _cache: Map = new Map(); - - /** - * Determine if the cache has a given entity_id. - * @param id - * @returns `true` if the id is in the cache, `false` otherwise. - */ - public has(id: string): boolean { - return this._cache.has(id); - } - - /** - * Get the first value that returns true for the given predicate. - * @param func A callback function that returns a boolean. - * @returns The first matching value. - */ - public getMatch(func: (arg: ExtendedEntity) => boolean): ExtendedEntity | null { - return [...this._cache.values()].find(func) ?? null; - } - - /** - * Get entity information given an id. - * @param id The entity id. - * @returns The `ExtendedEntity` for this id. - */ - public get(id: string): ExtendedEntity | undefined { - return this._cache.get(id); - } - - /** - * Add a given ExtendedEntity to the cache. - * @param extendedEntity - */ - public set(extendedEntity: ExtendedEntity): void { - this._cache.set(extendedEntity.entity_id, extendedEntity); - } -} - -/** - * Get the extended entity information for an entity. May throw. - * @param hass The Home Assistant object. - * @param entity The entity id. - * @param cache An optional ExtendedEntityCache. - * @returns The ExtendedEntity information. - */ -export const getExtendedEntity = async ( - hass: HomeAssistant, - entity: string, - cache?: ExtendedEntityCache, -): Promise => { - const cachedValue = cache ? cache.get(entity) : undefined; - if (cachedValue) { - return cachedValue; - } - const result = await homeAssistantWSRequest( - hass, - extendedEntitySchema, - { - type: 'config/entity_registry/get', - entity_id: entity, - }, - ); - if (cache) { - cache.set(result); - } - return result; -}; - -/** - * Get the extended entity information for an array of entities. May throw. - * @param hass The Home Assistant object. - * @param entities An array of entity ids. - * @param cache An optional ExtendedEntityCache. - * @returns A map of entity id to ExtendedEntity objects. - */ -export const getExtendedEntities = async ( - hass: HomeAssistant, - entities: string[], - cache?: ExtendedEntityCache, -): Promise> => { - const output: Map = new Map(); - const _storeExtendedEntity = async (entity: string): Promise => { - output.set(entity, await getExtendedEntity(hass, entity, cache)); - }; - await Promise.all(entities.map(_storeExtendedEntity)); - return output; -}; - -/** - * Get a list of all entities from the entity registry. May throw. - * @param hass The Home Assistant object. - * @returns An entity list object. - */ -export const getAllEntities = async (hass: HomeAssistant): Promise => { - return await homeAssistantWSRequest(hass, entityListSchema, { - type: 'config/entity_registry/list', - }); -}; diff --git a/src/utils/ha/entity-registry/cache.ts b/src/utils/ha/entity-registry/cache.ts new file mode 100644 index 00000000..13938296 --- /dev/null +++ b/src/utils/ha/entity-registry/cache.ts @@ -0,0 +1,50 @@ +import { Entity, ExtendedEntity } from './types.js'; + +export class EntityCache { + protected _cache: Map = new Map(); + + /** + * Determine if the cache has a given entity_id. + * @param id + * @returns `true` if the id is in the cache, `false` otherwise. + */ + public has(id: string): boolean { + return this._cache.has(id); + } + + /** + * Get the first value that returns true for the given predicate. + * @param func A callback function that returns a boolean. + * @returns The first matching value. + */ + // public getFirstMatch(func: (arg: T) => boolean): T | null { + // return [...this._cache.values()].find(func) ?? null; + // } + + public getMatches(func: (arg: T) => boolean): T[] { + return [...this._cache.values()].filter(func); + } + + /** + * Get entity information given an id. + * @param id The entity id. + * @returns The `ExtendedEntity` for this id. + */ + public get(id: string): T | undefined { + return this._cache.get(id); + } + + /** + * Add a given entity to the cache. + * @param extendedEntity + */ + public set(input: T | T[]): void { + const _set = (entity: T) => this._cache.set(entity.entity_id, entity); + + if (Array.isArray(input)) { + input.forEach(_set); + } else { + _set(input); + } + } +} diff --git a/src/utils/ha/entity-registry/index.ts b/src/utils/ha/entity-registry/index.ts new file mode 100644 index 00000000..8bf3ea8a --- /dev/null +++ b/src/utils/ha/entity-registry/index.ts @@ -0,0 +1,95 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { homeAssistantWSRequest } from '..'; +import { EntityCache } from './cache'; +import { + Entity, + EntityList, + entityListSchema, + ExtendedEntity, + extendedEntitySchema, +} from './types.js'; + +type EntityRegistryCache = EntityCache; +type ExtendedEntityRegistryCache = EntityCache; + +// Tne `entity_registry/list` call returns a smaller set of information for +// every entity, than the full `entity_registry/get` call returns for a single +// entity. This class manages interactions with entities, caching results +// (either the partial or extended versions) and fetching as necessary. Some +// calls require every entity to be fetched, which may be non-trivial in size +// (after which it is cached forever). + +export class EntityRegistryManager { + protected _cache: EntityRegistryCache; + protected _extendedCache: ExtendedEntityRegistryCache; + protected _fetchedEntityList = false; + + constructor(cache: EntityCache, extendedCache: EntityCache) { + this._cache = cache; + this._extendedCache = extendedCache; + } + + public async getEntity(hass: HomeAssistant, entityID: string): Promise { + const cachedEntity = this._cache.get(entityID); + if (cachedEntity) { + return cachedEntity; + } + + const cachedExtendedEntity = this._extendedCache.get(entityID); + if (cachedExtendedEntity) { + return cachedExtendedEntity; + } + return await this.getExtendedEntity(hass, entityID); + } + + public async getMatchingEntities(hass: HomeAssistant, func: (arg: Entity) => boolean): Promise { + await this.fetchEntityList(hass); + return this._cache.getMatches(func); + } + + public async getExtendedEntity( + hass: HomeAssistant, + entityID: string, + ): Promise { + const cachedValue = this._extendedCache.get(entityID); + if (cachedValue) { + return cachedValue; + } + const extendedEntity = await homeAssistantWSRequest( + hass, + extendedEntitySchema, + { + type: 'config/entity_registry/get', + entity_id: entityID, + }, + ); + this._extendedCache.set(extendedEntity); + return extendedEntity; + } + + public async getExtendedEntities( + hass: HomeAssistant, + entityIDs: string[], + ): Promise> { + const output: Map = new Map(); + const _storeExtendedEntity = async (entityID: string): Promise => { + const extendedEntity = await this.getExtendedEntity(hass, entityID); + if (extendedEntity) { + output.set(entityID, extendedEntity); + } + }; + await Promise.all(entityIDs.map(_storeExtendedEntity)); + return output; + } + + public async fetchEntityList(hass: HomeAssistant): Promise { + if (this._fetchedEntityList) { + return; + } + const entityList = await homeAssistantWSRequest(hass, entityListSchema, { + type: 'config/entity_registry/list', + }); + this._cache.set(entityList); + this._fetchedEntityList = true; + } +} diff --git a/src/utils/ha/entity-registry/types.ts b/src/utils/ha/entity-registry/types.ts new file mode 100644 index 00000000..ac076259 --- /dev/null +++ b/src/utils/ha/entity-registry/types.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +const entitySchema = z.object({ + config_entry_id: z.string().nullable(), + disabled_by: z.string().nullable(), + entity_id: z.string(), + hidden_by: z.string().nullable(), + platform: z.string(), +}); +export type Entity = z.infer; + +export const extendedEntitySchema = entitySchema.extend({ + // Extended entity results. + unique_id: z.string().optional(), +}); +export type ExtendedEntity = z.infer; + +export const entityListSchema = entitySchema.array(); +export type EntityList = z.infer; diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index 9a82106f..9b127e16 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -1,7 +1,7 @@ import add from 'date-fns/add'; import sub from 'date-fns/sub'; import { ViewContext } from 'view'; -import { CameraConfig, ClipsOrSnapshotsOrAll, FrigateCardView } from '../types'; +import { ClipsOrSnapshotsOrAll, FrigateCardView } from '../types'; import { View } from '../view/view'; import { EventMediaQueries, @@ -21,7 +21,6 @@ export const changeViewToRecentEventsForCameraAndDependents = async ( element: HTMLElement, hass: HomeAssistant, cameraManager: CameraManager, - cameras: Map, view: View, options?: { mediaType?: ClipsOrSnapshotsOrAll; @@ -29,7 +28,7 @@ export const changeViewToRecentEventsForCameraAndDependents = async ( }, ): Promise => { ( - await createViewForEvents(element, hass, cameraManager, cameras, view, { + await createViewForEvents(element, hass, cameraManager, view, { ...options, limit: 50, // Capture the 50 most recent events. }) @@ -40,7 +39,6 @@ export const createViewForEvents = async ( element: HTMLElement, hass: HomeAssistant, cameraManager: CameraManager, - cameras: Map, view: View, options?: { query?: EventMediaQueries; @@ -51,6 +49,10 @@ export const createViewForEvents = async ( limit?: number; }, ): Promise => { + const cameras = cameraManager.getCameras(); + if (!cameras) { + return null; + } let query: EventMediaQueries; const cameraIDs: Set = options?.cameraIDs ? options.cameraIDs @@ -94,7 +96,6 @@ export const changeViewToRecentRecordingForCameraAndDependents = async ( element: HTMLElement, hass: HomeAssistant, cameraManager: CameraManager, - cameras: Map, view: View, options?: { targetView?: 'recording' | 'recordings'; @@ -102,7 +103,7 @@ export const changeViewToRecentRecordingForCameraAndDependents = async ( ): Promise => { const now = new Date(); ( - await createViewForRecordings(element, hass, cameraManager, cameras, view, { + await createViewForRecordings(element, hass, cameraManager, view, { ...options, // Fetch 7 days worth of recordings (including recordings that are for the // current hour). @@ -127,7 +128,6 @@ export const createViewForRecordings = async ( element: HTMLElement, hass: HomeAssistant, cameraManager: CameraManager, - cameras: Map, view: View, options?: { query?: RecordingMediaQueries; @@ -139,6 +139,10 @@ export const createViewForRecordings = async ( end?: Date; }, ): Promise => { + const cameras = cameraManager.getCameras(); + if (!cameras) { + return null; + } const cameraIDs: Set = options?.cameraIDs ? options.cameraIDs : new Set(getAllDependentCameras(cameras, view.camera)); diff --git a/src/view/view.ts b/src/view/view.ts index 2dc4705f..07c91e1c 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,10 +1,5 @@ import { ViewContext } from 'view'; -import { - FrigateCardUserSpecifiedView, - FrigateCardView, - FRIGATE_CARD_VIEWS_USER_SPECIFIED, - FRIGATE_CARD_VIEW_DEFAULT, -} from '../types.js'; +import { FrigateCardView } from '../types.js'; import { dispatchFrigateCardEvent } from '../utils/basic.js'; import { MediaQueries } from './media-queries'; import { MediaQueriesResults } from './media-queries-results'; @@ -37,28 +32,6 @@ export class View { this.context = params.context ?? null; } - /** - * Selects the best view for a non-Frigate camera. - * @param view The wanted view. - * @returns The closest view supported by the non-Frigate camera. - */ - public static selectBestViewForNonFrigateCameras(view: FrigateCardView) { - return ['timeline', 'image'].includes(view) ? view : FRIGATE_CARD_VIEW_DEFAULT; - } - - /** - * Selects the best view for a user specified view. - * @param view The wanted view. - * @returns The closest view supported that is user changeable. - */ - public static selectBestViewForUserSpecified(view: FrigateCardView) { - return FRIGATE_CARD_VIEWS_USER_SPECIFIED.includes( - view as FrigateCardUserSpecifiedView, - ) - ? view - : FRIGATE_CARD_VIEW_DEFAULT; - } - /** * Detect if a view change represents a major "media change" for the given * view. @@ -138,8 +111,8 @@ export class View { /** * Determine if current view matches a named view. */ - public is(name: string): boolean { - return this.view == name; + public is(view: FrigateCardView): boolean { + return this.view == view; } /**