fix: Fix initialization race condition that occurs in certain circumstances (#1545)
* fix: Fix initialization race condition in certain circumstances * Minor improvements and camera iris logo
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
await this._api.getDefaultManager().initializeIfNecessary(previousConfig);
|
||||
await this._api.getMediaPlayerManager().initializeIfNecessary(previousConfig);
|
||||
|
||||
this._api.getCardElementManager().update();
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<boolean> {
|
||||
public async initializeMandatory(): Promise<void> {
|
||||
await this._initializationQueue.add(() => this._initializeMandatory());
|
||||
}
|
||||
|
||||
protected async _initializeMandatory(): Promise<void> {
|
||||
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<boolean> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
if (
|
||||
previousConfig?.menu.buttons.media_player.enabled !==
|
||||
this._api.getConfigManager().getConfig()?.menu.buttons.media_player.enabled
|
||||
) {
|
||||
await this.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
public async initialize(): Promise<boolean> {
|
||||
const hass = this._api.getHASSManager().getHASS();
|
||||
if (!hass) {
|
||||
if (
|
||||
!hass ||
|
||||
!this._api.getConfigManager().getConfig()?.menu.buttons.media_player.enabled
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,18 +23,18 @@ export class QueryStringManager {
|
||||
return !!this._calculateIntent().view;
|
||||
}
|
||||
|
||||
public executeNonViewRelated = (): void => {
|
||||
this._executeNonViewRelated(this._calculateIntent());
|
||||
public executeNonViewRelated = async (): Promise<void> => {
|
||||
await this._executeNonViewRelated(this._calculateIntent());
|
||||
};
|
||||
|
||||
public executeViewRelated = (): void => {
|
||||
this._executeViewRelated(this._calculateIntent());
|
||||
public executeViewRelated = async (): Promise<void> => {
|
||||
await this._executeViewRelated(this._calculateIntent());
|
||||
};
|
||||
|
||||
public executeAll = (): void => {
|
||||
public executeAll = async (): Promise<void> => {
|
||||
const intent = this._calculateIntent();
|
||||
this._executeViewRelated(intent);
|
||||
this._executeNonViewRelated(intent);
|
||||
await this._executeViewRelated(intent);
|
||||
await this._executeNonViewRelated(intent);
|
||||
};
|
||||
|
||||
protected async _executeViewRelated(intent: QueryStringViewIntent): Promise<void> {
|
||||
@@ -62,7 +62,7 @@ export class QueryStringManager {
|
||||
}
|
||||
}
|
||||
|
||||
protected _executeNonViewRelated(intent: QueryStringViewIntent): void {
|
||||
protected async _executeNonViewRelated(intent: QueryStringViewIntent): Promise<void> {
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -133,6 +133,24 @@ export class ViewManager implements ViewManagerInterface {
|
||||
);
|
||||
}
|
||||
|
||||
public initialize = async (): Promise<boolean> => {
|
||||
// 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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user