diff --git a/src/camera-manager/engine-factory.ts b/src/camera-manager/engine-factory.ts index 04f02499..896e4251 100644 --- a/src/camera-manager/engine-factory.ts +++ b/src/camera-manager/engine-factory.ts @@ -1,8 +1,11 @@ import { HomeAssistant } from 'custom-card-helpers'; +import { localize } from '../localize/localize'; import { CameraConfig, CardWideConfig } from '../types'; import { EntityRegistryManager } from '../utils/ha/entity-registry'; +import { Entity } from '../utils/ha/entity-registry/types'; import { RecordingSegmentsCache, RequestCache } from './cache'; import { CameraManagerEngine } from './engine'; +import { CameraInitializationError } from './error'; import { FrigateCameraManagerEngine } from './frigate/engine-frigate'; import { GenericCameraManagerEngine } from './generic/engine-generic'; import { Engine } from './types'; @@ -51,7 +54,18 @@ export class CameraManagerEngineFactory { const cameraEntity = cameraConfig.camera_entity; if (cameraEntity) { - const entity = await this._entityRegistryManager.getEntity(hass, cameraEntity); + let entity: Entity | null; + try { + entity = await this._entityRegistryManager.getEntity(hass, cameraEntity); + } catch (e) { + // Throw a slightly friendlier exception (as a typo in the entity is + // likely to be a common failure mode). + throw new CameraInitializationError( + localize('error.no_camera_entity'), + cameraConfig, + ); + } + switch (entity?.platform) { case 'frigate': engine = Engine.Frigate; diff --git a/src/card.ts b/src/card.ts index 0d295f19..28e706f6 100644 --- a/src/card.ts +++ b/src/card.ts @@ -210,6 +210,8 @@ class FrigateCard extends LitElement { protected _conditionManager: CardConditionManager | null = null; + protected _mediaPlayers?: string[]; + constructor() { super(); this._entityRegistryManager = new EntityRegistryManager( @@ -551,29 +553,12 @@ class FrigateCard extends LitElement { }); } - const isValidMediaPlayer = (entity: string): boolean => { - if (entity.startsWith('media_player.')) { - const stateObj = this._hass?.states[entity]; - if ( - stateObj && - stateObj.state !== 'unavailable' && - supportsFeature(stateObj, MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA) - ) { - return true; - } - } - return false; - }; - - const mediaPlayers = Object.keys(this._hass?.states || {}).filter( - isValidMediaPlayer, - ); if ( - mediaPlayers.length && + this._mediaPlayers?.length && (this._view?.isViewerView() || (this._view?.is('live') && selectedCameraConfig?.camera_entity)) ) { - const mediaPlayerItems = mediaPlayers.map((playerEntityID) => { + const mediaPlayerItems = this._mediaPlayers.map((playerEntityID) => { const title = getEntityTitle(this._hass, playerEntityID) || playerEntityID; const state = this._hass?.states[playerEntityID]; const playAction = createFrigateCardCustomAction('media_player', { @@ -865,16 +850,45 @@ class FrigateCard extends LitElement { }); } - protected async _initializeCameras(): Promise { - if (!this._hass || !this._cameraManager) { - return; - } + protected async _initialize( + hass: HomeAssistant, + config: FrigateCardConfig, + cardWideConfig: CardWideConfig, + ): Promise { + // Above arguments are taken (vs usage of `this`) as they must exist prior + // to initialization and this ensures it is the callers responsibility to + // verify that. + + await Promise.all([ + // Side load Home Assistant elements used in the UI. + sideLoadHomeAssistantElements(), + + // Load dynamic language imports. + loadLanguages(), + ]); + + await this._initializeCameras(hass, config, cardWideConfig); + + // 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 }); + } + + protected async _initializeCameras( + hass: HomeAssistant, + config: FrigateCardConfig, + cardWideConfig: CardWideConfig, + ): Promise { + this._cameraManager = new CameraManager( + new CameraManagerEngineFactory(this._entityRegistryManager, cardWideConfig), + this._cardWideConfig, + ); try { await this._cameraManager.initializeCameras( - this._hass, + hass, this._entityRegistryManager, - this._getConfig().cameras, + config.cameras, ); } catch (e: unknown) { if (e instanceof Error) { @@ -888,36 +902,74 @@ class FrigateCard extends LitElement { }); } } + } - // 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 }); + protected async _initializeMediaPlayers(hass: HomeAssistant): Promise { + const isValidMediaPlayer = (entityID: string): boolean => { + if (entityID.startsWith('media_player.')) { + const stateObj = this._hass?.states[entityID]; + if ( + stateObj && + stateObj.state !== 'unavailable' && + supportsFeature(stateObj, MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA) + ) { + return true; + } + } + return false; + }; + + const mediaPlayers = Object.keys(this._hass?.states || {}).filter( + isValidMediaPlayer, + ); + let mediaPlayerEntities: Map; + try { + mediaPlayerEntities = await this._entityRegistryManager.getEntities( + hass, + mediaPlayers, + ); + } catch (e) { + // Failing to fetch media player information is not considered + // sufficiently serious to block card startup. + errorToConsole(e as Error); + return; + } + + // Filter out entities that are marked as hidden (this information is not + // available in the HA state, only in the registry). + this._mediaPlayers = [...mediaPlayerEntities.values()] + .filter((entity) => !entity.hidden_by) + .map((entity) => entity.entity_id); } /** * Called before each update. */ protected willUpdate(changedProps: PropertyValues): void { - if ( - this._cardWideConfig && - (!this._cameraManager || - changedProps.has('_config') || - changedProps.has('_cardWideConfig')) - ) { - this._cameraManager = new CameraManager( - new CameraManagerEngineFactory( - this._entityRegistryManager, - this._cardWideConfig, - ), - this._cardWideConfig, - ); - this._initializeCameras().then(() => this.requestUpdate()); - } - if (changedProps.has('_cardWideConfig')) { setPerformanceCSSStyles(this, this._cardWideConfig?.performance); } + if ( + this._hass && + !this._mediaPlayers && + this._getConfig().menu.buttons.media_player.enabled + ) { + // Media players are initialized outside the main initialization code (the + // `initialize` method) since they may be required depending on an + // overridable configuration value. + + // We also want to initialize media players after since the main camera + // initialization since that may have fetched entity information that will + // be cached and re-used here (minor performance optimization). Only do + // this if the media player button is enabled to further limit the + // performance implications. + + // Prevent a double initialization. + this._mediaPlayers = []; + this._initializeMediaPlayers(this._hass); + } + if (this._view?.is('live')) { import('./components/live/live.js'); } else if (this._view?.isGalleryView()) { @@ -1039,12 +1091,6 @@ class FrigateCard extends LitElement { } } - /** - * Initialize the card. - */ - protected async _initialize(): Promise { - await Promise.all([sideLoadHomeAssistantElements(), loadLanguages()]); - } /** * Determine whether the element should be updated. * @param changedProps The changed properties if any. @@ -1053,8 +1099,15 @@ class FrigateCard extends LitElement { protected shouldUpdate(changedProps: PropertyValues): boolean { // Load the relevant languages. Cannot do anything until then. if (this._initialized !== 'initialized') { - if (this._initialized !== 'initializing') { - this._initialize().then(() => { + const config = this._getConfig(); + if ( + this._initialized !== 'initializing' && + this._hass && + config && + this._cardWideConfig + ) { + this._initialized = 'initializing'; + this._initialize(this._hass, config, this._cardWideConfig).then(() => { this._initialized = 'initialized'; this.requestUpdate(); }); diff --git a/src/performance.ts b/src/performance.ts index a3cdc340..dc505fdb 100644 --- a/src/performance.ts +++ b/src/performance.ts @@ -95,9 +95,12 @@ const LOW_PROFILE_DEFAULTS = { // Hide several buttons that are otherwise visible by default. [`${CONF_MENU_BUTTONS_FRIGATE}.enabled`]: false, [`${CONF_MENU_BUTTONS_TIMELINE}.enabled`]: false, - [`${CONF_MENU_BUTTONS_MEDIA_PLAYER}.enabled`]: false, [`${CONF_MENU_BUTTONS_TIMELINE}.enabled`]: false, + // If the media player button is present media player entity fetches are + // required on initialization. + [`${CONF_MENU_BUTTONS_MEDIA_PLAYER}.enabled`]: false, + // Disable all options in thumbnails. [CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL]: false, [CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL]: false, diff --git a/src/utils/ha/entity-registry/index.ts b/src/utils/ha/entity-registry/index.ts index 8bf3ea8a..e11a1964 100644 --- a/src/utils/ha/entity-registry/index.ts +++ b/src/utils/ha/entity-registry/index.ts @@ -42,7 +42,10 @@ export class EntityRegistryManager { return await this.getExtendedEntity(hass, entityID); } - public async getMatchingEntities(hass: HomeAssistant, func: (arg: Entity) => boolean): Promise { + public async getMatchingEntities( + hass: HomeAssistant, + func: (arg: Entity) => boolean, + ): Promise { await this.fetchEntityList(hass); return this._cache.getMatches(func); } @@ -67,6 +70,21 @@ export class EntityRegistryManager { return extendedEntity; } + public async getEntities( + hass: HomeAssistant, + entityIDs: string[], + ): Promise> { + const output: Map = new Map(); + const _storeEntity = async (entityID: string): Promise => { + const entity = await this.getEntity(hass, entityID); + if (entity) { + output.set(entityID, entity); + } + }; + await Promise.all(entityIDs.map(_storeEntity)); + return output; + } + public async getExtendedEntities( hass: HomeAssistant, entityIDs: string[],