diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index 93dd9a3b..8a6af9a3 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -111,7 +111,6 @@ export class CameraManager { protected _api: CardCameraAPI; protected _engineFactory: CameraManagerEngineFactory; protected _store: CameraManagerStore; - protected _initializationLimit = new PQueue({ concurrency: 1 }); protected _requestLimit = new PQueue(); constructor( @@ -147,16 +146,8 @@ export class CameraManager { recursivelyMergeObjectsNotArrays({}, cloneDeep(config?.cameras_global), camera), ); - const resetAndInitialize = async () => { - await this._reset(); - await this._initializeCameras(cameras); - }; - try { - // This concurrency limit prevents multiple rapidly arriving configs from - // generating reset-n-initialize race conditions (e.g. changing values - // rapidly in the config editor). - await this._initializationLimit.add(resetAndInitialize); + await this._initializeCameras(cameras); } catch (e: unknown) { this._api .getMessageManager() @@ -166,7 +157,7 @@ export class CameraManager { return true; } - protected async _reset(): Promise { + public async reset(): Promise { await this._store.reset(); } @@ -246,6 +237,8 @@ export class CameraManager { async ([cameraConfig, engine]) => await engine.createCamera(hass, cameraConfig), ); + const cameraIDs: Set = new Set(); + // Do the additions based off the result-order, to ensure the map order is // preserved. cameras.forEach((camera) => { @@ -258,7 +251,7 @@ export class CameraManager { ); } - if (this._store.hasCameraID(cameraID)) { + if (cameraIDs.has(cameraID)) { throw new CameraInitializationError( localize('error.duplicate_camera_id'), camera.getConfig(), @@ -267,9 +260,11 @@ export class CameraManager { // Always ensure the actual ID used in the card is in the configuration itself. camera.setID(cameraID); - this._store.addCamera(camera); + cameraIDs.add(cameraID); }); + await this._store.setCameras(cameras); + log( this._api.getConfigManager().getCardWideConfig(), 'Frigate Card CameraManager initialized (Cameras: ', diff --git a/src/camera-manager/store.ts b/src/camera-manager/store.ts index 1cc58742..58ef7906 100644 --- a/src/camera-manager/store.ts +++ b/src/camera-manager/store.ts @@ -44,6 +44,30 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore { this._enginesByType.set(camera.getEngine().getEngineType(), camera.getEngine()); } + public async setCameras(cameras: Camera[]): Promise { + // In setting the store cameras, take great care to replace/add first before + // remove. Otherwise, there may be race conditions where the card attempts + // to render a view with (momentarily) no camera. + // See: https://github.com/dermotduffy/frigate-hass-card/issues/1533 + + // Replace/Add the new cameras. + for (const camera of cameras) { + const oldCamera = this._cameras.get(camera.getID()); + if (oldCamera !== camera) { + this.addCamera(camera); + await oldCamera?.destroy(); + } + } + + // Remove the old cameras. + for (const camera of this._cameras.values()) { + if (!cameras.includes(camera)) { + await camera.destroy(); + this._cameras.delete(camera.getID()); + } + } + } + public async reset(): Promise { await allPromises(this._cameras.values(), (camera) => camera.destroy()); this._cameras.clear(); diff --git a/src/card-controller/card-element-manager.ts b/src/card-controller/card-element-manager.ts index 1590b556..b08bff51 100644 --- a/src/card-controller/card-element-manager.ts +++ b/src/card-controller/card-element-manager.ts @@ -67,7 +67,12 @@ export class CardElementManager { this._api.getMediaLoadedInfoManager().initialize(); this._api.getMicrophoneManager().initialize(); this._api.getKeyboardStateManager().initialize(); + + // These initializers are called when the config is updated, but on initial + // creation of the card hass is not yet available when the config is first + // loaded. this._api.getDefaultManager().initialize(); + this._api.getMediaPlayerManager().initialize(); this._api .getHASSManager() @@ -146,6 +151,8 @@ export class CardElementManager { // reconnection, to ensure the state subscription/unsubscription works // correctly for triggers. this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS); + this._api.getCameraManager().reset(); + this._element.removeEventListener( 'mousemove', this._api.getInteractionManager().reportInteraction, diff --git a/src/card-controller/config/config-manager.ts b/src/card-controller/config/config-manager.ts index 28be50a8..7471f1e6 100644 --- a/src/card-controller/config/config-manager.ts +++ b/src/card-controller/config/config-manager.ts @@ -92,16 +92,20 @@ export class ConfigManager { camera: undefined, }); this._api.getMediaLoadedInfoManager().clear(); + + this._api.getInitializationManager().uninitialize(InitializationAspect.VIEW); this._api.getViewManager().reset(); + this._api.getMessageManager().reset(); this._api.getStyleManager().setPerformance(); - this._api.getCardElementManager().update(); this._api.getStatusBarItemManager().removeAllDynamicStatusBarItems(); setKeyboardShortcutsFromConfig(this._api, this); setAutomationsFromConfig(this._api); this.computeOverrideConfig(); + + this._api.getCardElementManager().update(); } public computeOverrideConfig(): void { @@ -144,17 +148,18 @@ export class ConfigManager { .uninitialize(InitializationAspect.MICROPHONE_CONNECT); } - if ( - previousConfig && - !isEqual( - previousConfig?.view.default_reset, - this._overriddenConfig?.view.default_reset, - ) - ) { - this._api - .getInitializationManager() - .uninitialize(InitializationAspect.DEFAULT_RESET); - } + /* async */ this._initializeBackground(previousConfig); + } + + /** + * Initialize config dependent items in the background. For items that the + * card hard requires, use InitializationManager instead. + */ + protected async _initializeBackground( + previousConfig: FrigateCardConfig | null, + ): Promise { + await this._api.getDefaultManager().initializeIfNecessary(previousConfig); + await this._api.getMediaPlayerManager().initializeIfNecessary(previousConfig); this._api.getCardElementManager().update(); } diff --git a/src/card-controller/default-manager.ts b/src/card-controller/default-manager.ts index 60eb7383..6ba7a380 100644 --- a/src/card-controller/default-manager.ts +++ b/src/card-controller/default-manager.ts @@ -1,4 +1,5 @@ -import PQueue from 'p-queue'; +import { isEqual } from 'lodash-es'; +import { FrigateCardConfig } from '../config/types'; import { createGeneralAction } from '../utils/action'; import { isActionAllowedBasedOnInteractionState } from '../utils/interaction-mode'; import { Timer } from '../utils/timer'; @@ -10,20 +11,45 @@ import { CardDefaultManagerAPI } from './types'; export class DefaultManager { protected _timer = new Timer(); protected _api: CardDefaultManagerAPI; - protected _initializationLimit = new PQueue({ concurrency: 1 }); constructor(api: CardDefaultManagerAPI) { this._api = api; } + public async initializeIfNecessary( + previousConfig: FrigateCardConfig | null, + ): Promise { + if ( + !isEqual( + previousConfig?.view.default_reset, + this._api.getConfigManager().getConfig()?.view.default_reset, + ) + ) { + await this.initialize(); + } + } + /** - * Initialize the default manager. Requires both hass and configuration to be - * effective (so cannot be called from just the configuration manager, as hass - * will not be available yet) + * This needs to be public since the first initialization requires both hass + * and the config, so it is not suitable from calling exclusively from the + * config manager. */ public async initialize(): Promise { - const result = await this._initializationLimit.add(() => this._reconfigure()); - this._startTimer(); + this.uninitialize(); + + const config = this._api.getConfigManager().getConfig()?.view.default_reset; + if (config?.entities.length) { + this._api + .getHASSManager() + .getStateWatcher() + .subscribe(this._stateChangeHandler, config.entities); + } + + const timerSeconds = this._api.getConfigManager().getConfig()?.view + .default_reset.every_seconds; + if (timerSeconds) { + this._timer.startRepeated(timerSeconds, () => this._setToDefaultIfAllowed()); + } if (this._api.getConfigManager().getConfig()?.view.default_reset.after_interaction) { this._api.getAutomationsManager().addAutomations([ @@ -40,7 +66,7 @@ export class DefaultManager { ]); } - return !!result; + return true; } public uninitialize(): void { @@ -49,28 +75,6 @@ export class DefaultManager { this._api.getAutomationsManager().deleteAutomations(this); } - protected async _reconfigure(): Promise { - const hass = this._api.getHASSManager().getHASS(); - const config = this._api.getConfigManager().getConfig()?.view.default_reset; - if (!hass || !config) { - return false; - } - - this._api.getHASSManager().getStateWatcher().unsubscribe(this._stateChangeHandler); - this._api - .getHASSManager() - .getStateWatcher() - .subscribe(this._stateChangeHandler, config.entities); - - // If the timer is running, restart it with the newly configured timer. - if (this._timer.isRunning()) { - this._timer.stop(); - this._startTimer(); - } - - return true; - } - protected _stateChangeHandler = (): void => { this._setToDefaultIfAllowed(); }; @@ -92,12 +96,4 @@ export class DefaultManager { ) ); } - - protected _startTimer(): void { - const timerSeconds = this._api.getConfigManager().getConfig()?.view - .default_reset.every_seconds; - if (timerSeconds) { - this._timer.startRepeated(timerSeconds, () => this._setToDefaultIfAllowed()); - } - } } diff --git a/src/card-controller/initialization-manager.ts b/src/card-controller/initialization-manager.ts index 7b5a78da..127f69bb 100644 --- a/src/card-controller/initialization-manager.ts +++ b/src/card-controller/initialization-manager.ts @@ -1,3 +1,4 @@ +import PQueue from 'p-queue'; import { loadLanguages } from '../localize/localize'; import { sideLoadHomeAssistantElements } from '../utils/ha'; import { Initializer } from '../utils/initializer/initializer'; @@ -6,14 +7,29 @@ import { CardInitializerAPI } from './types'; export enum InitializationAspect { LANGUAGES = 'languages', SIDE_LOAD_ELEMENTS = 'side-load-elements', - MEDIA_PLAYERS = 'media-players', CAMERAS = 'cameras', MICROPHONE_CONNECT = 'microphone-connect', - DEFAULT_RESET = 'default-reset', + VIEW = 'view', } +// ========================================================================= +// Rules for initialization. Initializers must be reentrant as these situations +// may occur: +// +// 1. Multiple JS async contexts may execute these functions at the same time. +// 2. At any point, something may uninitialize a part of the card (including +// while a different async context is in the middle of running the +// initialization method). +// ========================================================================= + export class InitializationManager { protected _api: CardInitializerAPI; + + // A concurrency limit is placed to ensure that on card load multiple async + // contexts do not attempt to initialize the card at the same time. This is + // not strictly necessary, just more efficient, as long as the "Rules for + // initialization" (above) are followed. + protected _initializationQueue = new PQueue({ concurrency: 1 }); protected _initializer: Initializer; constructor(api: CardInitializerAPI, initializer?: Initializer) { @@ -27,28 +43,29 @@ export class InitializationManager { return false; } - return ( - this._initializer.isInitializedMultiple([ - InitializationAspect.LANGUAGES, - InitializationAspect.SIDE_LOAD_ELEMENTS, - InitializationAspect.CAMERAS, - ...(config.live.microphone.always_connected - ? [InitializationAspect.MICROPHONE_CONNECT] - : []), - ]) && - // If there's no view, re-initialize (e.g. config changes). - this._api.getViewManager().hasView() - ); + return this._initializer.isInitializedMultiple([ + InitializationAspect.LANGUAGES, + InitializationAspect.SIDE_LOAD_ELEMENTS, + InitializationAspect.CAMERAS, + ...(config.live.microphone.always_connected + ? [InitializationAspect.MICROPHONE_CONNECT] + : []), + InitializationAspect.VIEW, + ]); } /** * Initialize the hard requirements for rendering anything. * @returns `true` if card rendering can continue. */ - public async initializeMandatory(): Promise { + public async initializeMandatory(): Promise { + await this._initializationQueue.add(() => this._initializeMandatory()); + } + + protected async _initializeMandatory(): Promise { const hass = this._api.getHASSManager().getHASS(); - if (!hass) { - return false; + if (!hass || this.isInitializedMandatory()) { + return; } if ( @@ -60,12 +77,12 @@ export class InitializationManager { await sideLoadHomeAssistantElements(), })) ) { - return false; + return; } const config = this._api.getConfigManager().getConfig(); if (!config) { - return false; + return; } if ( @@ -83,76 +100,23 @@ export class InitializationManager { }), })) ) { - return false; - } - - if (!this._api.getMessageManager().hasMessage()) { - if (!this._api.getViewManager().hasView()) { - // Set a view on initial load. However, if the query string contains a - // view related action, we don't set any view here and allow that content - // to be triggered by the firstUpdated() call that runs query string - // actions. To do otherwise may cause a race condition between the default - // view and the querystring view, see: - // https://github.com/dermotduffy/frigate-hass-card/issues/1200 - const hasViewRelatedActions = this._api - .getQueryStringManager() - .hasViewRelatedActions(); - if (hasViewRelatedActions) { - this._api.getQueryStringManager().executeViewRelated(); - } else { - this._api.getViewManager().setViewDefaultWithNewQuery({ failSafe: true }); - } - } else { - // If we already have a view, something (e.g. cameras) may have been - // reinitialized, be sure to ask for an update. - this._api.getCardElementManager().update(); - } - } - - return true; - } - - /** - * Initialize aspects of the card that can load in the 'background'. - * @returns `true` if card rendering can continue. - */ - public async initializeBackgroundIfNecessary(): Promise { - const hass = this._api.getHASSManager().getHASS(); - const config = this._api.getConfigManager().getConfig(); - - if (!hass || !config) { - return false; + return; } if ( - this._initializer.isInitializedMultiple([ - InitializationAspect.DEFAULT_RESET, - ...(config.menu.buttons.media_player.enabled - ? [InitializationAspect.MEDIA_PLAYERS] - : []), - ]) + this._api.getMessageManager().hasMessage() || + !(await this._initializer.initializeIfNecessary( + InitializationAspect.VIEW, + this._api.getViewManager().initialize, + )) ) { - return true; - } - - if ( - !(await this._initializer.initializeMultipleIfNecessary({ - [InitializationAspect.DEFAULT_RESET]: async () => - await this._api.getDefaultManager().initialize(), - ...(config.menu.buttons.media_player.enabled && { - [InitializationAspect.MEDIA_PLAYERS]: async () => - await this._api.getMediaPlayerManager().initialize(), - }), - })) - ) { - return false; + return; } this._api.getCardElementManager().update(); - return true; } - public uninitialize(aspect: InitializationAspect) { - return this._initializer.uninitialize(aspect); + public uninitialize(aspect: InitializationAspect): void { + this._initializer.uninitialize(aspect); } } diff --git a/src/card-controller/media-player-manager.ts b/src/card-controller/media-player-manager.ts index 2cda70be..ff26d201 100644 --- a/src/card-controller/media-player-manager.ts +++ b/src/card-controller/media-player-manager.ts @@ -1,4 +1,4 @@ -import { CameraConfig } from '../config/types'; +import { CameraConfig, FrigateCardConfig } from '../config/types'; import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA } from '../const'; import { localize } from '../localize/localize'; import { errorToConsole } from '../utils/basic'; @@ -25,9 +25,23 @@ export class MediaPlayerManager { return this._mediaPlayers.length > 0; } + public async initializeIfNecessary( + previousConfig: FrigateCardConfig | null, + ): Promise { + if ( + previousConfig?.menu.buttons.media_player.enabled !== + this._api.getConfigManager().getConfig()?.menu.buttons.media_player.enabled + ) { + await this.initialize(); + } + } + public async initialize(): Promise { const hass = this._api.getHASSManager().getHASS(); - if (!hass) { + if ( + !hass || + !this._api.getConfigManager().getConfig()?.menu.buttons.media_player.enabled + ) { return false; } diff --git a/src/card-controller/query-string-manager.ts b/src/card-controller/query-string-manager.ts index 9009bb67..ee5c9569 100644 --- a/src/card-controller/query-string-manager.ts +++ b/src/card-controller/query-string-manager.ts @@ -23,18 +23,18 @@ export class QueryStringManager { return !!this._calculateIntent().view; } - public executeNonViewRelated = (): void => { - this._executeNonViewRelated(this._calculateIntent()); + public executeNonViewRelated = async (): Promise => { + await this._executeNonViewRelated(this._calculateIntent()); }; - public executeViewRelated = (): void => { - this._executeViewRelated(this._calculateIntent()); + public executeViewRelated = async (): Promise => { + await this._executeViewRelated(this._calculateIntent()); }; - public executeAll = (): void => { + public executeAll = async (): Promise => { const intent = this._calculateIntent(); - this._executeViewRelated(intent); - this._executeNonViewRelated(intent); + await this._executeViewRelated(intent); + await this._executeNonViewRelated(intent); }; protected async _executeViewRelated(intent: QueryStringViewIntent): Promise { @@ -62,7 +62,7 @@ export class QueryStringManager { } } - protected _executeNonViewRelated(intent: QueryStringViewIntent): void { + protected async _executeNonViewRelated(intent: QueryStringViewIntent): Promise { if ( // Only execute non-view actions when the card has rendered at least once. !this._api.getCardElementManager().hasUpdated() || @@ -71,7 +71,7 @@ export class QueryStringManager { return; } - this._api.getActionsManager().executeActions(intent.other); + await this._api.getActionsManager().executeActions(intent.other); } protected _calculateIntent(): QueryStringViewIntent { diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index 834d7f8a..41884bb5 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -91,6 +91,7 @@ export interface CardConfigAPI { getDefaultManager(): DefaultManager; getInitializationManager(): InitializationManager; getMediaLoadedInfoManager(): MediaLoadedInfoManager; + getMediaPlayerManager(): MediaPlayerManager; getMessageManager(): MessageManager; getStatusBarItemManager(): StatusBarItemManager; getStyleManager(): StyleManager; @@ -201,6 +202,7 @@ export interface CardMediaLoadedAPI { export interface CardMediaPlayerAPI { getCameraManager(): CameraManager; + getConfigManager(): ConfigManager; getEntityRegistryManager(): EntityRegistryManager; getHASSManager(): HASSManager; getMessageManager(): MessageManager; @@ -257,6 +259,7 @@ export interface CardViewAPI { getHASSManager(): HASSManager; getMediaLoadedInfoManager(): MediaLoadedInfoManager; getMessageManager(): MessageManager; + getQueryStringManager(): QueryStringManager; getStyleManager(): StyleManager; getTriggersManager(): TriggersManager; } diff --git a/src/card-controller/view/view-manager.ts b/src/card-controller/view/view-manager.ts index a3718a75..6fcff9a1 100644 --- a/src/card-controller/view/view-manager.ts +++ b/src/card-controller/view/view-manager.ts @@ -133,6 +133,24 @@ export class ViewManager implements ViewManagerInterface { ); } + public initialize = async (): Promise => { + // Set a view on initial load. However, if the query string contains a view + // related action, we don't set any view here and allow that content to be + // triggered by the firstUpdated() call that runs query string actions. To + // do otherwise may cause a race condition between the default view and the + // querystring view, see: + // https://github.com/dermotduffy/frigate-hass-card/issues/1200 + const hasViewRelatedActions = this._api + .getQueryStringManager() + .hasViewRelatedActions(); + if (hasViewRelatedActions) { + await this._api.getQueryStringManager().executeViewRelated(); + } else { + await this.setViewDefaultWithNewQuery({ failSafe: true }); + } + return true; + }; + protected _setView(view: View | null): void { const oldView = this._view; diff --git a/src/card.ts b/src/card.ts index 035974df..57321d83 100644 --- a/src/card.ts +++ b/src/card.ts @@ -15,7 +15,7 @@ import { FrigateCardElements } from './components/elements.js'; import './components/menu.js'; import { FrigateCardMenu } from './components/menu.js'; import './components/message.js'; -import { renderMessage, renderProgressIndicator } from './components/message.js'; +import { renderMessage } from './components/message.js'; import './components/overlay.js'; import { FrigateCardOverlay } from './components/overlay.js'; import './components/status-bar'; @@ -188,10 +188,6 @@ class FrigateCard extends LitElement { return true; } - protected willUpdate(): void { - this._controller.getInitializationManager().initializeBackgroundIfNecessary(); - } - protected _renderMenuStatusContainer( position: 'top' | 'bottom' | 'overlay', ): TemplateResult | void { @@ -373,37 +369,32 @@ class FrigateCard extends LitElement { ${this._renderMenuStatusContainer('top')}
${this._renderMenuStatusContainer('overlay')} - ${!cameraManager.isInitialized() && - !this._controller.getMessageManager().hasMessage() - ? renderProgressIndicator({ - cardWideConfig: this._controller.getConfigManager().getCardWideConfig(), - }) - : // Always want to render even if there's a message, to - // ensure live preload is always present (even if not displayed). - html``} + ${ + // Always want to render even if there's a message, to + // ensure live preload is always present (even if not displayed). + html`` + } ${ // Keep message rendering to last to show messages that may have been // generated during the render. diff --git a/src/images/README.md b/src/images/README.md index b65d2855..1c3f584c 100644 --- a/src/images/README.md +++ b/src/images/README.md @@ -21,3 +21,9 @@ $ convert -extent 2048x1152 -flop -gravity center 47543120431_b285c45ac8_k.jpg frigate-bird-in-sky.jpg $ mogrify -strip -interlace Plane -quality 85% -scale 492x277 frigate-bird-in-sky.jpg ``` + +## camera-iris.svg + +**Link**: https://pictogrammers.com/library/mdi/icon/camera-iris/ + +**License**: https://www.apache.org/licenses/LICENSE-2.0 diff --git a/src/images/camera-iris.svg b/src/images/camera-iris.svg new file mode 100644 index 00000000..dd0cbf5e --- /dev/null +++ b/src/images/camera-iris.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 990deea5..447447e9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,8 @@ import { HomeAssistant, + LovelaceCard, LovelaceCardConfig, + LovelaceCardEditor, Themes, } from '@dermotduffy/custom-card-helpers'; import { StyleInfo } from 'lit/directives/style-map.js'; @@ -86,12 +88,14 @@ export interface FrigateCardMediaPlayer { isPaused(): boolean; } +export type LovelaceCardWithEditor = LovelaceCard & { + constructor: { + getConfigElement(): Promise; + }; +}; + export interface CardHelpers { - createCardElement(config: LovelaceCardConfig): Promise<{ - constructor: { - getConfigElement(): HTMLElement; - }; - }>; + createCardElement(config: LovelaceCardConfig): Promise; } export type PTZMovementType = 'relative' | 'continuous'; diff --git a/src/utils/ha/index.ts b/src/utils/ha/index.ts index bd0d5ec2..3da4cece 100644 --- a/src/utils/ha/index.ts +++ b/src/utils/ha/index.ts @@ -11,6 +11,7 @@ import { CardHelpers, ExtendedHomeAssistant, FrigateCardError, + LovelaceCardWithEditor, SignedPath, signedPathSchema, StateParameters, @@ -316,17 +317,21 @@ export const sideLoadHomeAssistantElements = async (): Promise => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const helpers: CardHelpers = await (window as any).loadCardHelpers(); - // The picture-glance editor loads everything this card needs. - const pictureGlance = await helpers.createCardElement({ + // This bizarre combination of hacks creates a dummy picture glance card, then + // waits for it to be fully loaded/upgraded as a custom element, so it will + // have the getConfigElement() method which is necessary to load all the + // elements this card requires. + await helpers.createCardElement({ type: 'picture-glance', entities: [], camera_image: 'dummy-to-load-editor-components', }); - if (pictureGlance.constructor.getConfigElement) { - await pictureGlance.constructor.getConfigElement(); - return true; - } - return false; + + const pgcConstructor = await customElements.whenDefined('hui-picture-glance-card'); + const pgc = new pgcConstructor() as LovelaceCardWithEditor; + + await pgc.constructor.getConfigElement(); + return true; }; /** diff --git a/src/utils/initializer/initializer.ts b/src/utils/initializer/initializer.ts index 3ab033a9..2a44a5df 100644 --- a/src/utils/initializer/initializer.ts +++ b/src/utils/initializer/initializer.ts @@ -1,21 +1,14 @@ import { allPromises } from '../basic'; -enum InitializationState { - INITIALIZING = 'initializing', - INITIALIZED = 'initialized', -} - type InitializationCallback = () => Promise; /** - * Manages initialization state & calling initializers. + * Manages initialization state & calling initializers. There is no guarantee + * something will not be initialized twice unless there are concurrency controls + * applied to the usage of this class. */ export class Initializer { - protected _state: Map; - - constructor() { - this._state = new Map(); - } + protected _initialized: Set = new Set(); public async initializeMultipleIfNecessary( aspects: Record, @@ -27,43 +20,30 @@ export class Initializer { return results.every(Boolean); } - /** - * - * @param aspect The aspect to initialize. - * @param initializer The initializer to call. - * @returns `true` if the state is confirmed as initialized, `false` - * otherwise (i.e. initializing). - */ public async initializeIfNecessary( aspect: string, initializer?: InitializationCallback, ): Promise { - const state = this._state.get(aspect); - if (state !== InitializationState.INITIALIZED) { - if (state !== InitializationState.INITIALIZING) { - if (initializer) { - this._state.set(aspect, InitializationState.INITIALIZING); - if (await initializer()) { - this._state.set(aspect, InitializationState.INITIALIZED); - } else { - this.uninitialize(aspect); - } - } else { - this._state.set(aspect, InitializationState.INITIALIZED); - } - return true; - } - return false; + if (this._initialized.has(aspect)) { + return true; } - return true; + if (!initializer) { + this._initialized.add(aspect); + return true; + } + if (await initializer()) { + this._initialized.add(aspect); + return true; + } + return false; } - public uninitialize(aspect: string) { - return this._state.delete(aspect); + public uninitialize(aspect: string): void { + this._initialized.delete(aspect); } public isInitialized(aspect: string): boolean { - return this._state.get(aspect) == InitializationState.INITIALIZED; + return this._initialized.has(aspect); } public isInitializedMultiple(aspects: string[]): boolean { diff --git a/tests/camera-manager/manager.test.ts b/tests/camera-manager/manager.test.ts index 39e2e8f1..6d30c615 100644 --- a/tests/camera-manager/manager.test.ts +++ b/tests/camera-manager/manager.test.ts @@ -1289,4 +1289,19 @@ describe('CameraManager', async () => { expect(await manager.getMediaSeekTime(media, middleTime)).toBeNull(); }); }); + + it('should reset', async () => { + const api = createCardAPI(); + const engine = mock(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + const manager = createCameraManager(api, engine); + + expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + + expect(manager.getStore().getCameraCount()).toBe(1); + + await manager.reset(); + + expect(manager.getStore().getCameraCount()).toBe(0); + }); }); diff --git a/tests/camera-manager/store.test.ts b/tests/camera-manager/store.test.ts index 13daec36..f0184b83 100644 --- a/tests/camera-manager/store.test.ts +++ b/tests/camera-manager/store.test.ts @@ -308,4 +308,39 @@ describe('CameraManagerStore', async () => { ); expect(store.getCameraIDsWithCapability('clips')).toEqual(new Set(['one'])); }); + + it('setCameras', async () => { + const store = new CameraManagerStore(); + const camera_1 = new Camera(createCameraConfig({ id: 'camera-1' }), engineGeneric); + camera_1.destroy = vi.fn(); + + const camera_2 = new Camera(createCameraConfig({ id: 'camera-2' }), engineGeneric); + camera_2.destroy = vi.fn(); + + const camera_3 = new Camera(createCameraConfig({ id: 'camera-3' }), engineGeneric); + camera_3.destroy = vi.fn(); + + const camera_3_new = new Camera( + createCameraConfig({ id: 'camera-3' }), + engineGeneric, + ); + camera_3_new.destroy = vi.fn(); + + const camera_4 = new Camera(createCameraConfig({ id: 'camera-4' }), engineGeneric); + camera_4.destroy = vi.fn(); + + await store.setCameras([camera_1, camera_2, camera_3]); + await store.setCameras([camera_2, camera_3_new, camera_4]); + + expect(store.getCamera('camera-1')).toBeNull(); + expect(store.getCamera('camera-2')).toBe(camera_2); + expect(store.getCamera('camera-3')).toBe(camera_3_new); + expect(store.getCamera('camera-4')).toBe(camera_4); + + expect(camera_1.destroy).toBeCalled(); + expect(camera_2.destroy).not.toBeCalled(); + expect(camera_3.destroy).toBeCalled(); + expect(camera_3_new.destroy).not.toBeCalled(); + expect(camera_4.destroy).not.toBeCalled(); + }); }); diff --git a/tests/card-controller/config/config-manager.test.ts b/tests/card-controller/config/config-manager.test.ts index 2272b86e..90b1b42b 100644 --- a/tests/card-controller/config/config-manager.test.ts +++ b/tests/card-controller/config/config-manager.test.ts @@ -1,10 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ZodError } from 'zod'; -import { frigateCardConfigSchema } from '../../../src/config/types'; import { getOverriddenConfig } from '../../../src/card-controller/conditions-manager'; import { ConfigManager } from '../../../src/card-controller/config/config-manager'; import { InitializationAspect } from '../../../src/card-controller/initialization-manager'; -import { createCardAPI, createConfig } from '../../test-utils'; +import { frigateCardConfigSchema } from '../../../src/config/types'; +import { createCardAPI, createConfig, flushPromises } from '../../test-utils'; vi.mock('../../../src/card-controller/conditions-manager.js'); @@ -229,7 +229,9 @@ describe('ConfigManager', () => { vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_1)); manager.setConfig(config_1); - expect(api.getInitializationManager().uninitialize).not.toBeCalled(); + expect(api.getInitializationManager().uninitialize).toHaveBeenLastCalledWith( + InitializationAspect.VIEW, + ); const config_2 = { type: 'custom:frigate-card', @@ -238,7 +240,7 @@ describe('ConfigManager', () => { vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_2)); manager.computeOverrideConfig(); - expect(api.getInitializationManager().uninitialize).toBeCalledWith( + expect(api.getInitializationManager().uninitialize).toHaveBeenLastCalledWith( InitializationAspect.CAMERAS, ); }); @@ -253,7 +255,9 @@ describe('ConfigManager', () => { vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_1)); manager.setConfig(config_1); - expect(api.getInitializationManager().uninitialize).not.toBeCalled(); + expect(api.getInitializationManager().uninitialize).toHaveBeenLastCalledWith( + InitializationAspect.VIEW, + ); const config_2 = { ...config_1, @@ -264,7 +268,7 @@ describe('ConfigManager', () => { vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_2)); manager.computeOverrideConfig(); - expect(api.getInitializationManager().uninitialize).toBeCalledWith( + expect(api.getInitializationManager().uninitialize).toHaveBeenLastCalledWith( InitializationAspect.CAMERAS, ); }); @@ -284,7 +288,9 @@ describe('ConfigManager', () => { vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_1)); manager.setConfig(config_1); - expect(api.getInitializationManager().uninitialize).not.toBeCalled(); + expect(api.getInitializationManager().uninitialize).toHaveBeenLastCalledWith( + InitializationAspect.VIEW, + ); const config_2 = { ...config_1, @@ -297,42 +303,26 @@ describe('ConfigManager', () => { vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_2)); manager.computeOverrideConfig(); - expect(api.getInitializationManager().uninitialize).toBeCalledWith( + expect(api.getInitializationManager().uninitialize).toHaveBeenLastCalledWith( InitializationAspect.MICROPHONE_CONNECT, ); }); + }); - it('view.default_reset', () => { - const api = createCardAPI(); - const manager = new ConfigManager(api); - const config_1 = { - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - view: { - default_reset: { - every_seconds: 1, - }, - }, - }; - vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_1)); + it('should initialize background items', async () => { + const api = createCardAPI(); + const manager = new ConfigManager(api); + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + }; + vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config)); - manager.setConfig(config_1); - expect(api.getInitializationManager().uninitialize).not.toBeCalled(); + manager.setConfig(config); - const config_2 = { - ...config_1, - view: { - default_reset: { - every_seconds: 2, - }, - }, - }; - vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_2)); - manager.computeOverrideConfig(); + await flushPromises(); - expect(api.getInitializationManager().uninitialize).toBeCalledWith( - InitializationAspect.DEFAULT_RESET, - ); - }); + expect(api.getDefaultManager().initializeIfNecessary).toBeCalledWith(null); + expect(api.getMediaPlayerManager().initializeIfNecessary).toBeCalledWith(null); }); }); diff --git a/tests/card-controller/default-manager.test.ts b/tests/card-controller/default-manager.test.ts index e266f66f..ab4b4748 100644 --- a/tests/card-controller/default-manager.test.ts +++ b/tests/card-controller/default-manager.test.ts @@ -28,6 +28,7 @@ describe('DefaultManager', () => { describe('time based', () => { it('should set default view when allowed', async () => { const api = createCardAPIWithStateWatcher(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ view: { @@ -62,7 +63,8 @@ describe('DefaultManager', () => { }); it('should not set default view when not configured', () => { - const api = createCardAPI(); + const api = createCardAPIWithStateWatcher(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ view: { @@ -88,6 +90,7 @@ describe('DefaultManager', () => { it('should restart timer when reconfigured', async () => { const api = createCardAPIWithStateWatcher(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ view: { @@ -99,9 +102,6 @@ describe('DefaultManager', () => { ); vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false); - const hass = createHASS(); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); - vi.useFakeTimers(); const manager = new DefaultManager(api); @@ -120,6 +120,7 @@ describe('DefaultManager', () => { it('should set default view when state changed', async () => { const api = createCardAPIWithStateWatcher(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ view: { @@ -210,4 +211,47 @@ describe('DefaultManager', () => { expect(api.getAutomationsManager().deleteAutomations).toBeCalledWith(manager); }); }); + + it('should reinitialize when there is a config change', async () => { + const configOn = createConfig({ + view: { + default_reset: { + every_seconds: 10, + }, + }, + }); + const configOff = createConfig({ + view: { + default_reset: { + every_seconds: 0, + }, + }, + }); + + const api = createCardAPIWithStateWatcher(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false); + + vi.useFakeTimers(); + + const manager = new DefaultManager(api); + + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(configOn); + await manager.initializeIfNecessary(null); + + vi.runOnlyPendingTimers(); + expect(api.getViewManager().setViewDefault).toBeCalledTimes(1); + + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(configOff); + await manager.initializeIfNecessary(configOn); + + vi.runOnlyPendingTimers(); + expect(api.getViewManager().setViewDefault).toBeCalledTimes(1); + + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(configOff); + await manager.initializeIfNecessary(configOff); + + vi.runOnlyPendingTimers(); + expect(api.getViewManager().setViewDefault).toBeCalledTimes(1); + }); }); diff --git a/tests/card-controller/initialization-manager.test.ts b/tests/card-controller/initialization-manager.test.ts index add474ce..628cf94e 100644 --- a/tests/card-controller/initialization-manager.test.ts +++ b/tests/card-controller/initialization-manager.test.ts @@ -21,50 +21,23 @@ describe('InitializationManager', () => { describe('should correctly determine when mandatory initialization is required', () => { it('without config', () => { const api = createCardAPI(); - const initializer = mock(); - const manager = new InitializationManager(api, initializer); + const manager = new InitializationManager(api); expect(manager.isInitializedMandatory()).toBeFalsy(); }); it('without aspects', () => { const api = createCardAPI(); - const initializer = mock(); - const manager = new InitializationManager(api, initializer); + const manager = new InitializationManager(api); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - initializer.isInitializedMultiple.mockReturnValue(false); expect(manager.isInitializedMandatory()).toBeFalsy(); }); - it('without view', () => { - const api = createCardAPI(); - const initializer = mock(); - const manager = new InitializationManager(api, initializer); - - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - initializer.isInitializedMultiple.mockReturnValue(true); - - expect(manager.isInitializedMandatory()).toBeFalsy(); - }); - - it('with aspects and view', () => { - const api = createCardAPI(); - const initializer = mock(); - const manager = new InitializationManager(api, initializer); - - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - initializer.isInitializedMultiple.mockReturnValue(true); - vi.mocked(api.getViewManager().hasView).mockReturnValue(true); - - expect(manager.isInitializedMandatory()).toBeTruthy(); - }); - it('with microphone if configured', () => { const api = createCardAPI(); - const initializer = mock(); - const manager = new InitializationManager(api, initializer); + const manager = new InitializationManager(api); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ @@ -75,24 +48,25 @@ describe('InitializationManager', () => { }, }), ); - initializer.isInitializedMultiple.mockReturnValue(true); - vi.mocked(api.getViewManager().hasView).mockReturnValue(true); - expect(manager.isInitializedMandatory()).toBeTruthy(); + expect(manager.isInitializedMandatory()).toBeFalsy(); }); }); describe('should initialize mandatory', () => { it('without hass', async () => { const manager = new InitializationManager(createCardAPI()); - expect(await manager.initializeMandatory()).toBeFalsy(); + await manager.initializeMandatory(); }); it('without config', async () => { const api = createCardAPI(); const manager = new InitializationManager(api); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); - expect(await manager.initializeMandatory()).toBeFalsy(); + vi.mocked(loadLanguages).mockResolvedValue(true); + vi.mocked(sideLoadHomeAssistantElements).mockResolvedValue(true); + + await manager.initializeMandatory(); }); it('successfully', async () => { @@ -103,15 +77,23 @@ describe('InitializationManager', () => { vi.mocked(api.getQueryStringManager().hasViewRelatedActions).mockReturnValue( false, ); + vi.mocked(loadLanguages).mockResolvedValue(true); + vi.mocked(sideLoadHomeAssistantElements).mockResolvedValue(true); + vi.mocked(api.getCameraManager().initializeCamerasFromConfig).mockResolvedValue( + true, + ); + vi.mocked(api.getViewManager().initialize).mockResolvedValue(true); + const manager = new InitializationManager(api); - expect(await manager.initializeMandatory()).toBeTruthy(); + await manager.initializeMandatory(); expect(loadLanguages).toBeCalled(); expect(sideLoadHomeAssistantElements).toBeCalled(); expect(api.getCameraManager().initializeCamerasFromConfig).toBeCalled(); - expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled(); + expect(api.getViewManager().initialize).toBeCalled(); expect(api.getMicrophoneManager().connect).not.toBeCalled(); + expect(api.getCardElementManager().update).toBeCalled(); }); it('successfully with microphone if configured', async () => { @@ -126,25 +108,19 @@ describe('InitializationManager', () => { }, }), ); + vi.mocked(loadLanguages).mockResolvedValue(true); + vi.mocked(sideLoadHomeAssistantElements).mockResolvedValue(true); + vi.mocked(api.getCameraManager().initializeCamerasFromConfig).mockResolvedValue( + true, + ); + const manager = new InitializationManager(api); - expect(await manager.initializeMandatory()).toBeTruthy(); + await manager.initializeMandatory(); + expect(api.getMicrophoneManager().connect).toBeCalled(); }); - it('successfully with querystring view', async () => { - const api = createCardAPI(); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - vi.mocked(api.getMessageManager().hasMessage).mockReturnValue(false); - vi.mocked(api.getQueryStringManager().hasViewRelatedActions).mockReturnValue(true); - const manager = new InitializationManager(api); - - expect(await manager.initializeMandatory()).toBeTruthy(); - - expect(api.getQueryStringManager().executeViewRelated).toBeCalled(); - }); - it('with message set during initialization', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); @@ -153,12 +129,17 @@ describe('InitializationManager', () => { vi.mocked(api.getQueryStringManager().hasViewRelatedActions).mockReturnValue( false, ); + vi.mocked(loadLanguages).mockResolvedValue(true); + vi.mocked(sideLoadHomeAssistantElements).mockResolvedValue(true); + vi.mocked(api.getCameraManager().initializeCamerasFromConfig).mockResolvedValue( + true, + ); + const manager = new InitializationManager(api); - expect(await manager.initializeMandatory()).toBeTruthy(); + await manager.initializeMandatory(); - expect(api.getViewManager().setViewByParameters).not.toBeCalled(); - expect(api.getViewManager().setViewDefault).not.toBeCalled(); + expect(api.getViewManager().initialize).not.toBeCalled(); }); it('with languages and side load elements in progress', async () => { @@ -168,7 +149,7 @@ describe('InitializationManager', () => { const manager = new InitializationManager(api, initializer); initializer.initializeMultipleIfNecessary.mockResolvedValue(false); - expect(await manager.initializeMandatory()).toBeFalsy(); + await manager.initializeMandatory(); }); it('with cameras in progress', async () => { @@ -182,100 +163,7 @@ describe('InitializationManager', () => { .mockResolvedValueOnce(true) .mockResolvedValueOnce(false); - expect(await manager.initializeMandatory()).toBeFalsy(); - }); - - it('with existing view', async () => { - const api = createCardAPI(); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - vi.mocked(api.getViewManager().hasView).mockReturnValue(true); - - const initializer = mock(); - const manager = new InitializationManager(api, initializer); - initializer.initializeMultipleIfNecessary - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true); - - expect(await manager.initializeMandatory()).toBeTruthy(); - expect(api.getCardElementManager().update).toBeCalled(); - }); - }); - - describe('should initialize background', () => { - it('without hass and config', async () => { - const manager = new InitializationManager(createCardAPI()); - expect(await manager.initializeBackgroundIfNecessary()).toBeFalsy(); - }); - - it('successfully when already initialized', async () => { - const api = createCardAPI(); - - const initializer = mock(); - initializer.isInitializedMultiple.mockReturnValue(true); - - const manager = new InitializationManager(api, initializer); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - menu: { - buttons: { - media_player: { - enabled: false, - }, - }, - }, - }), - ); - - expect(await manager.initializeBackgroundIfNecessary()).toBeTruthy(); - expect(api.getMediaPlayerManager().initialize).not.toBeCalled(); - expect(api.getDefaultManager().initialize).not.toBeCalled(); - expect(api.getCardElementManager().update).not.toBeCalled(); - }); - - it('successfully with all inititalizers', async () => { - const api = createCardAPI(); - const manager = new InitializationManager(api); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - menu: { - buttons: { - media_player: { - enabled: true, - }, - }, - }, - }), - ); - - expect(await manager.initializeBackgroundIfNecessary()).toBeTruthy(); - expect(api.getMediaPlayerManager().initialize).toBeCalled(); - expect(api.getDefaultManager().initialize).toBeCalled(); - expect(api.getCardElementManager().update).toBeCalled(); - }); - - it('with initializers in progress', async () => { - const api = createCardAPI(); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - menu: { - buttons: { - media_player: { - enabled: true, - }, - }, - }, - }), - ); - const initializer = mock(); - - const manager = new InitializationManager(api, initializer); - initializer.initializeMultipleIfNecessary.mockResolvedValue(false); - - expect(await manager.initializeBackgroundIfNecessary()).toBeFalsy(); + await manager.initializeMandatory(); }); }); diff --git a/tests/card-controller/media-player-manager.test.ts b/tests/card-controller/media-player-manager.test.ts index 9e4cb610..85776868 100644 --- a/tests/card-controller/media-player-manager.test.ts +++ b/tests/card-controller/media-player-manager.test.ts @@ -8,6 +8,7 @@ import { createCameraConfig, createCameraManager, createCardAPI, + createConfig, createHASS, createRegistryEntity, createStateEntity, @@ -75,6 +76,17 @@ describe('MediaPlayerManager', () => { vi.mocked(api.getHASSManager().getHASS).mockReturnValue( createHASSWithMediaPlayers(), ); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + menu: { + buttons: { + media_player: { + enabled: true, + }, + }, + }, + }), + ); vi.mocked(api.getEntityRegistryManager).mockReturnValue(entityRegistryManager); const manager = new MediaPlayerManager(api); @@ -108,6 +120,17 @@ describe('MediaPlayerManager', () => { vi.mocked(api.getHASSManager().getHASS).mockReturnValue( createHASSWithMediaPlayers(), ); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + menu: { + buttons: { + media_player: { + enabled: true, + }, + }, + }, + }), + ); vi.mocked(api.getEntityRegistryManager).mockReturnValue(entityRegistryManager); const manager = new MediaPlayerManager(api); @@ -121,6 +144,39 @@ describe('MediaPlayerManager', () => { expect(manager.hasMediaPlayers()).toBeTruthy(); expect(spy).toBeCalled(); }); + + it('should reinitialize when there is a config change', async () => { + const entityRegistryManager = mock(); + entityRegistryManager.getEntities.mockResolvedValue( + new Map([ + ['media_player.ok1', createRegistryEntity({ hidden_by: '' })], + ['media_player.ok2', createRegistryEntity({ hidden_by: 'user' })], + ]), + ); + + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue( + createHASSWithMediaPlayers(), + ); + const config = createConfig({ + menu: { + buttons: { + media_player: { + enabled: true, + }, + }, + }, + }); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(config); + vi.mocked(api.getEntityRegistryManager).mockReturnValue(entityRegistryManager); + const manager = new MediaPlayerManager(api); + + await manager.initializeIfNecessary(null); + expect(manager.hasMediaPlayers()).toBeTruthy(); + + await manager.initializeIfNecessary(config); + expect(manager.hasMediaPlayers()).toBeTruthy(); + }); }); it('should stop', async () => { diff --git a/tests/card-controller/query-string-manager.test.ts b/tests/card-controller/query-string-manager.test.ts index 6ee8ae42..5ebf67dd 100644 --- a/tests/card-controller/query-string-manager.test.ts +++ b/tests/card-controller/query-string-manager.test.ts @@ -16,13 +16,13 @@ describe('QueryStringManager', () => { global.window.location = mock(); }); - it('should reject malformed query string', () => { + it('should reject malformed query string', async () => { setQueryString('BOGUS_KEY=BOGUS_VALUE'); const api = createCardAPI(); vi.mocked(api.getMessageManager().hasMessage).mockReturnValue(true); const manager = new QueryStringManager(api); - manager.executeAll(); + await manager.executeAll(); expect(manager.hasViewRelatedActions()).toBeFalsy(); expect(api.getActionsManager().executeActions).not.toBeCalled(); @@ -41,7 +41,7 @@ describe('QueryStringManager', () => { ['snapshot' as const], ['snapshots' as const], ['timeline' as const], - ])('%s', (viewName: string) => { + ])('%s', async (viewName: string) => { setQueryString(`?frigate-card-action.id.${viewName}=`); const api = createCardAPI(); @@ -49,7 +49,7 @@ describe('QueryStringManager', () => { vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(false); const manager = new QueryStringManager(api); - manager.executeAll(); + await manager.executeAll(); expect(manager.hasViewRelatedActions()).toBeTruthy(); expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({ @@ -66,13 +66,13 @@ describe('QueryStringManager', () => { ['download' as const], ['expand' as const], ['menu_toggle' as const], - ])('%s', (action: string) => { + ])('%s', async (action: string) => { setQueryString(`?frigate-card-action.id.${action}=`); const api = createCardAPI(); vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true); const manager = new QueryStringManager(api); - manager.executeAll(); + await manager.executeAll(); expect(manager.hasViewRelatedActions()).toBeFalsy(); expect(api.getActionsManager().executeActions).toBeCalledWith([ @@ -85,7 +85,7 @@ describe('QueryStringManager', () => { }); }); - it('should execute view default action', () => { + it('should execute view default action', async () => { setQueryString('?frigate-card-action.id.default='); const api = createCardAPI(); // View actions do not need the card to have been updated. @@ -93,7 +93,7 @@ describe('QueryStringManager', () => { const manager = new QueryStringManager(api); - manager.executeAll(); + await manager.executeAll(); expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled(); @@ -102,13 +102,13 @@ describe('QueryStringManager', () => { expect(api.getViewManager().setViewByParameters).not.toBeCalled(); }); - it('should execute camera_select action', () => { + it('should execute camera_select action', async () => { setQueryString('?frigate-card-action.id.camera_select=camera.office'); const api = createCardAPI(); vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true); const manager = new QueryStringManager(api); - manager.executeAll(); + await manager.executeAll(); expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({ params: { @@ -121,13 +121,13 @@ describe('QueryStringManager', () => { expect(api.getViewManager().setViewDefault).not.toBeCalled(); }); - it('should execute live_substream_select action', () => { + it('should execute live_substream_select action', async () => { setQueryString('?frigate-card-action.id.live_substream_select=camera.office_hd'); const api = createCardAPI(); vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true); const manager = new QueryStringManager(api); - manager.executeAll(); + await manager.executeAll(); expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({ modifiers: [expect.any(SubstreamSelectViewModifier)], @@ -142,13 +142,13 @@ describe('QueryStringManager', () => { describe('should ignore action without value', () => { it.each([['camera_select' as const], ['live_substream_select' as const]])( '%s', - (action: string) => { + async (action: string) => { setQueryString(`?frigate-card-action.id.${action}=`); const api = createCardAPI(); vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true); const manager = new QueryStringManager(api); - manager.executeAll(); + await manager.executeAll(); expect(manager.hasViewRelatedActions()).toBeFalsy(); expect(api.getActionsManager().executeActions).not.toBeCalled(); @@ -158,7 +158,7 @@ describe('QueryStringManager', () => { ); }); - it('should handle unknown action', () => { + it('should handle unknown action', async () => { const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined); setQueryString('?frigate-card-action.id.not_an_action=value'); @@ -166,7 +166,7 @@ describe('QueryStringManager', () => { vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true); const manager = new QueryStringManager(api); - manager.executeAll(); + await manager.executeAll(); expect(manager.hasViewRelatedActions()).toBeFalsy(); expect(api.getActionsManager().executeActions).not.toBeCalled(); @@ -187,13 +187,13 @@ describe('QueryStringManager', () => { ['snapshot' as const], ['snapshots' as const], ['timeline' as const], - ])('%s', (viewName: string) => { + ])('%s', async (viewName: string) => { setQueryString(`?frigate-card-action.id.${viewName}=`); const api = createCardAPI(); vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true); const manager = new QueryStringManager(api); - manager.executeAll(); + await manager.executeAll(); expect(manager.hasViewRelatedActions()).toBeTruthy(); expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({ @@ -210,13 +210,13 @@ describe('QueryStringManager', () => { ['download' as const], ['expand' as const], ['menu_toggle' as const], - ])('%s', (action: string) => { + ])('%s', async (action: string) => { setQueryString(`?frigate-card-action.id.${action}=value`); const api = createCardAPI(); vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(false); const manager = new QueryStringManager(api); - manager.executeAll(); + await manager.executeAll(); expect(api.getActionsManager().executeActions).not.toBeCalled(); expect(api.getViewManager().setViewDefault).not.toBeCalled(); @@ -225,7 +225,7 @@ describe('QueryStringManager', () => { }); describe('should handle conflicting but valid actions', () => { - it('view and default with camera and substream specified', () => { + it('view and default with camera and substream specified', async () => { setQueryString( '?frigate-card-action.id.clips=' + '&frigate-card-action.id.live_substream_select=camera.kitchen_hd' + @@ -236,7 +236,7 @@ describe('QueryStringManager', () => { vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true); const manager = new QueryStringManager(api); - manager.executeAll(); + await manager.executeAll(); expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalledWith({ params: { @@ -247,7 +247,7 @@ describe('QueryStringManager', () => { expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); }); - it('multiple cameras specified', () => { + it('multiple cameras specified', async () => { setQueryString( '?frigate-card-action.id.camera_select=camera.kitchen' + '&frigate-card-action.id.camera_select=camera.office', @@ -256,7 +256,7 @@ describe('QueryStringManager', () => { vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true); const manager = new QueryStringManager(api); - manager.executeAll(); + await manager.executeAll(); expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({ params: { @@ -279,13 +279,13 @@ describe('QueryStringManager', () => { ['snapshot' as const], ['snapshots' as const], ['timeline' as const], - ])('%s', (viewName: string) => { + ])('%s', async (viewName: string) => { setQueryString(`?frigate-card-action.id.${viewName}=`); const api = createCardAPI(); vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true); const manager = new QueryStringManager(api); - manager.executeNonViewRelated(); + await manager.executeNonViewRelated(); expect(api.getViewManager().setViewByParameters).not.toBeCalled(); expect(api.getViewManager().setViewDefault).not.toBeCalled(); @@ -298,13 +298,13 @@ describe('QueryStringManager', () => { ['download' as const], ['expand' as const], ['menu_toggle' as const], - ])('%s', (viewName: string) => { + ])('%s', async (viewName: string) => { setQueryString(`?frigate-card-action.id.${viewName}=`); const api = createCardAPI(); vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true); const manager = new QueryStringManager(api); - manager.executeViewRelated(); + await manager.executeViewRelated(); expect(api.getActionsManager().executeActions).not.toBeCalled(); }); diff --git a/tests/card-controller/view/view-manager.test.ts b/tests/card-controller/view/view-manager.test.ts index 0f4094a6..de65c1d4 100644 --- a/tests/card-controller/view/view-manager.test.ts +++ b/tests/card-controller/view/view-manager.test.ts @@ -4,6 +4,8 @@ import { mock } from 'vitest-mock-extended'; import { ViewFactory } from '../../../src/card-controller/view/factory'; import { ViewManager } from '../../../src/card-controller/view/view-manager'; import { FrigateCardView } from '../../../src/config/types'; +import { ViewMedia } from '../../../src/view/media'; +import { MediaQueriesResults } from '../../../src/view/media-queries-results'; import { createCameraManager, createCapabilities, @@ -11,8 +13,6 @@ import { createStore, createView, } from '../../test-utils'; -import { ViewMedia } from '../../../src/view/media'; -import { MediaQueriesResults } from '../../../src/view/media-queries-results'; describe('should act correctly when view is set', () => { it('basic view', () => { @@ -360,4 +360,33 @@ describe('hasMajorMediaChange', () => { manager.hasMajorMediaChange(createView({ queryResults: queryResults_2 })), ).toBeFalsy(); }); + + describe('should initialize', () => { + it('without querystring', async () => { + const api = createCardAPI(); + const factory = mock(); + const manager = new ViewManager(api, factory); + + const view = createView({ + view: 'live', + camera: 'camera', + }); + factory.getViewDefaultWithNewQuery.mockResolvedValue(view); + + expect(await manager.initialize()).toBeTruthy(); + + expect(manager.getView()).toBe(view); + }); + + it('with querystring', async () => { + const api = createCardAPI(); + const factory = mock(); + const manager = new ViewManager(api, factory); + vi.mocked(api.getQueryStringManager().hasViewRelatedActions).mockReturnValue(true); + + expect(await manager.initialize()).toBeTruthy(); + + expect(api.getQueryStringManager().executeViewRelated).toBeCalled(); + }); + }); }); diff --git a/tests/utils/initializer.test.ts b/tests/utils/initializer.test.ts new file mode 100644 index 00000000..7e0a34f0 --- /dev/null +++ b/tests/utils/initializer.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { Initializer } from '../../src/utils/initializer/initializer'; + +describe('Initializer', () => { + it('should initialize with initializer', async () => { + const initializer = new Initializer(); + + expect(initializer.isInitialized('foo')).toBeFalsy(); + expect( + await initializer.initializeIfNecessary('foo', async () => true), + ).toBeTruthy(); + expect(initializer.isInitialized('foo')).toBeTruthy(); + }); + + it('should initialize without initializer', async () => { + const initializer = new Initializer(); + + expect(initializer.isInitialized('foo')).toBeFalsy(); + expect( + await initializer.initializeIfNecessary('foo', async () => true), + ).toBeTruthy(); + expect(initializer.isInitialized('foo')).toBeTruthy(); + }); + + it('should initialize when already initialized', async () => { + const initializer = new Initializer(); + + expect(initializer.isInitialized('foo')).toBeFalsy(); + expect( + await initializer.initializeIfNecessary('foo', async () => true), + ).toBeTruthy(); + expect( + await initializer.initializeIfNecessary('foo', async () => true), + ).toBeTruthy(); + expect(initializer.isInitialized('foo')).toBeTruthy(); + }); + + it('should not initialize with failed initializer', async () => { + const initializer = new Initializer(); + + expect(initializer.isInitialized('foo')).toBeFalsy(); + expect( + await initializer.initializeIfNecessary('foo', async () => false), + ).toBeFalsy(); + expect(initializer.isInitialized('foo')).toBeFalsy(); + }); + + it('should initialize multiple', async () => { + const initializer = new Initializer(); + + expect(initializer.isInitializedMultiple(['foo', 'bar'])).toBeFalsy(); + expect( + await initializer.initializeMultipleIfNecessary({ + foo: async () => true, + bar: async () => false, + }), + ).toBeFalsy(); + + expect(initializer.isInitializedMultiple(['foo', 'bar'])).toBeFalsy(); + + expect( + await initializer.initializeMultipleIfNecessary({ + bar: async () => true, + }), + ).toBeTruthy(); + + expect(initializer.isInitializedMultiple(['foo', 'bar'])).toBeTruthy(); + }); + + it('should uninitialize', async () => { + const initializer = new Initializer(); + await initializer.initializeIfNecessary('foo'); + expect(initializer.isInitialized('foo')).toBeTruthy(); + + initializer.uninitialize('foo'); + + expect(initializer.isInitialized('foo')).toBeFalsy(); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index bbc3c8e5..f5ddd5f1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -27,6 +27,7 @@ const FULL_COVERAGE_FILES_RELATIVE = [ 'utils/endpoint.ts', 'utils/ha/entity-registry/types.ts', 'utils/ha/types.ts', + 'utils/initializer.ts', 'utils/interaction-mode.ts', 'utils/media-info.ts', 'utils/media-layout.ts',