fix: Clear the initialized condition state when the card is not usable (#2646)
The `initialized` state (used in conditions/triggers) was written once and never cleared, so it meant "has this card ever been initialized" while everything reading it took it as "is this card usable now". Home Assistant takes a card off the page and puts it back whenever its dashboard tab is left and returned to, so the card initialized again while the state claimed it was initialized throughout: `trigger: initialized` fired once per card rather than once per startup, and automations were dropped in between. The card lifecycle is now an explicit state machine (`SessionManager`), the only writer of `initialized`, which separates a card that is starting up from one initializing part of itself again while it runs. A new `ever` parameter (conditions/triggers) selects the old latched behaviour. `remote_control` uses that parameter to keep its two camera priorities correct under repeated starts. With `camera_priority: entity` the card now re-reads the entity every time it starts, so a camera selected while the card was away is picked up on return. With `camera_priority: card` the card writes the entity on its first start only, unchanged, since repeating that write would overwrite a camera the user had selected. Closes: #2642 BREAKING CHANGE: `condition: initialized` is now `false` whenever the card is not usable, and `trigger: initialized` fires each time the card starts up rather than only the first time. Set `ever: true` on either to keep the previous behaviour.
This commit is contained in:
@@ -56,7 +56,7 @@ export class AutomationsManager {
|
||||
// automation once initialization completes so that the trigger evaluators
|
||||
// baseline their initial pre-trigger value against a card whose template
|
||||
// renderer has loaded.
|
||||
if (this._api.getInitializationManager().isInitializedMandatory()) {
|
||||
if (this._api.getInitializationManager().areMandatoryAspectsInitialized()) {
|
||||
triggers.subscribe();
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ export class AutomationsManager {
|
||||
// Never execute automations if the card hasn't finished initializing, as
|
||||
// it could cause a view change when camera loads are not finished.
|
||||
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1407
|
||||
!this._api.getInitializationManager().isInitializedMandatory() ||
|
||||
!this._api.getInitializationManager().areMandatoryAspectsInitialized() ||
|
||||
// Never execute automations if there's an error (as our automation loop
|
||||
// avoidance -- which shows as an error -- would not work!).
|
||||
this._api.getIssueManager().getStateManager().hasFullCardIssue()
|
||||
|
||||
@@ -8,7 +8,7 @@ import { isAncestorInEventPath } from '../utils/event-ancestor';
|
||||
import type { CardMediaReviewEventTarget } from '../utils/review';
|
||||
import type { ViewItem } from '../view/item';
|
||||
import type { ActionExecutionRequestEventTarget } from './actions/utils/execution-request';
|
||||
import { InitializationAspect } from './initialization-manager';
|
||||
import { InitializationAspect } from './initialization/initialization-manager';
|
||||
import type { CardElementAPI } from './types';
|
||||
|
||||
export type ScrollCallback = () => void;
|
||||
@@ -201,10 +201,14 @@ export class CardElementManager {
|
||||
this._api.getCameraTriggersManager().reset();
|
||||
|
||||
this._api.getCallManager().uninitialize();
|
||||
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
|
||||
this._api
|
||||
.getInitializationManager()
|
||||
.uninitialize(InitializationAspect.INITIAL_TRIGGER);
|
||||
|
||||
// The view is deliberately left initialized, so the user returns to what
|
||||
// they left. Leaving the page ends the card's initialization session.
|
||||
const initializationManager = this._api.getInitializationManager();
|
||||
initializationManager.invalidateAspect(InitializationAspect.CAMERAS);
|
||||
initializationManager.invalidateAspect(InitializationAspect.INITIAL_TRIGGER);
|
||||
initializationManager.getSessionManager().end();
|
||||
|
||||
void this._api.getCameraManager().destroy();
|
||||
|
||||
this._element.removeEventListener(
|
||||
|
||||
@@ -15,7 +15,7 @@ import { computeDomain } from '../../ha/compute-domain.js';
|
||||
import type { HomeAssistant } from '../../ha/types.js';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import { getParseError } from '../../utils/zod/parse-errors.js';
|
||||
import { InitializationAspect } from '../initialization-manager.js';
|
||||
import { InitializationAspect } from '../initialization/initialization-manager.js';
|
||||
import { TemplateManager } from '../templates';
|
||||
import type { CardConfigAPI } from '../types.js';
|
||||
import { ConfigParseError } from './error.js';
|
||||
@@ -152,7 +152,7 @@ export class ConfigManager {
|
||||
});
|
||||
this._api.getMediaLoadedInfoManager().clear();
|
||||
|
||||
this._api.getInitializationManager().uninitialize(InitializationAspect.VIEW);
|
||||
this._api.getInitializationManager().invalidateAspect(InitializationAspect.VIEW);
|
||||
this._api.getViewManager().reset();
|
||||
|
||||
this._api.getStatusBarItemManager().removeAllDynamicStatusBarItems();
|
||||
@@ -234,7 +234,9 @@ export class ConfigManager {
|
||||
runIfChanged(
|
||||
(config) => [config.cameras, config.cameras_global],
|
||||
() => {
|
||||
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
|
||||
this._api
|
||||
.getInitializationManager()
|
||||
.invalidateAspect(InitializationAspect.CAMERAS);
|
||||
void this._api.getCameraManager().destroy();
|
||||
},
|
||||
true,
|
||||
@@ -244,7 +246,7 @@ export class ConfigManager {
|
||||
() => {
|
||||
this._api
|
||||
.getInitializationManager()
|
||||
.uninitialize(InitializationAspect.MICROPHONE_CONNECT);
|
||||
.invalidateAspect(InitializationAspect.MICROPHONE_CONNECT);
|
||||
},
|
||||
true,
|
||||
);
|
||||
@@ -267,7 +269,7 @@ export class ConfigManager {
|
||||
// InitializationManager.
|
||||
if (
|
||||
this._overriddenConfig &&
|
||||
this._api.getInitializationManager().isInitializedMandatory()
|
||||
this._api.getInitializationManager().areMandatoryAspectsInitialized()
|
||||
) {
|
||||
this._api.getConditionStateManager().setState({
|
||||
config: this._overriddenConfig,
|
||||
|
||||
@@ -71,6 +71,21 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
|
||||
triggers: [
|
||||
{
|
||||
trigger: 'initialized' as const,
|
||||
|
||||
// A card initializes more than once -- on return to a dashboard tab,
|
||||
// and after Home Assistant restarts -- and the two priorities want
|
||||
// different answers to whether this should run again each time.
|
||||
//
|
||||
// `entity` reads the entity and changes only what the card displays.
|
||||
// Repeating it is safe (it does nothing when the two already agree)
|
||||
// and is the only way to pick up an entity change made while the card
|
||||
// was unable to act on it.
|
||||
//
|
||||
// `card` writes the entity. Repeating it is not safe: a card that has
|
||||
// just re-initialized is showing a freshly-defaulted view, not
|
||||
// anything the user chose, so a second run would overwrite a
|
||||
// selection the card never authored.
|
||||
ever: cameraPriority === 'card',
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
|
||||
@@ -26,7 +26,7 @@ import { ExpandManager } from './expand-manager';
|
||||
import { FoldersManager } from './folders/manager';
|
||||
import { FullscreenManager } from './fullscreen/fullscreen-manager';
|
||||
import { HASSManager } from './hass/hass-manager';
|
||||
import { InitializationManager } from './initialization-manager';
|
||||
import { InitializationManager } from './initialization/initialization-manager';
|
||||
import { InteractionManager } from './interaction-manager';
|
||||
import { createIssueManager } from './issues/factory';
|
||||
import type { IssueManager } from './issues/issue-manager';
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { HASSListener } from '../../ha/source';
|
||||
import type { HomeAssistant } from '../../ha/types';
|
||||
import type { UnsubscribeCallback } from '../../types';
|
||||
import { log } from '../../utils/debug';
|
||||
import { InitializationAspect } from '../initialization-manager';
|
||||
import { InitializationAspect } from '../initialization/initialization-manager';
|
||||
import type { CardHASSAPI } from '../types';
|
||||
import { EventWatcher, type EventWatcherSubscriptionInterface } from './event-watcher';
|
||||
import { StateWatcher, type StateWatcherSubscriptionInterface } from './state-watcher';
|
||||
@@ -48,12 +48,27 @@ export class HASSManager implements HASSManagerReadonlyInterface {
|
||||
}
|
||||
|
||||
public setHASS(hass?: HomeAssistant | null): void {
|
||||
// No hass at all is an absence of news rather than a change, so nothing
|
||||
// below it runs and `_hass` keeps whatever it last held.
|
||||
if (!hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasReady = !!this._hass && isHassReady(this._hass);
|
||||
const isReady = isHassReady(hass);
|
||||
|
||||
// A card cannot be started without Home Assistant, so losing it ends the
|
||||
// card's initialization session. The aspects initialized during that
|
||||
// session are left in place until it returns, when they are initialized
|
||||
// again against whatever entities it comes back with.
|
||||
if (wasReady && !isReady) {
|
||||
this._api.getInitializationManager().getSessionManager().end();
|
||||
}
|
||||
|
||||
// When HA goes from "not ready" to "ready" (WebSocket reconnected AND all
|
||||
// integrations finished loading), rebuild cameras and the view from
|
||||
// scratch: the available entities may have changed while it was down.
|
||||
const becameReady = !!this._hass && !isHassReady(this._hass) && isHassReady(hass);
|
||||
|
||||
if (becameReady) {
|
||||
if (!!this._hass && !wasReady && isReady) {
|
||||
// Tear cameras down before the listeners below see the new hass,
|
||||
// otherwise they would briefly rebuild against the old entities.
|
||||
log(
|
||||
@@ -61,16 +76,14 @@ export class HASSManager implements HASSManagerReadonlyInterface {
|
||||
'Advanced Camera Card: HA fully ready, reinitializing...',
|
||||
);
|
||||
|
||||
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
|
||||
void this._api.getCameraManager().destroy();
|
||||
this._api.getInitializationManager().uninitialize(InitializationAspect.VIEW);
|
||||
this._api
|
||||
.getInitializationManager()
|
||||
.uninitialize(InitializationAspect.INITIAL_TRIGGER);
|
||||
}
|
||||
// The entities may differ from those the cameras and the view were
|
||||
// initialized against, so both are initialized again.
|
||||
const initializationManager = this._api.getInitializationManager();
|
||||
initializationManager.invalidateAspect(InitializationAspect.CAMERAS);
|
||||
initializationManager.invalidateAspect(InitializationAspect.VIEW);
|
||||
initializationManager.invalidateAspect(InitializationAspect.INITIAL_TRIGGER);
|
||||
|
||||
if (!hass) {
|
||||
return;
|
||||
void this._api.getCameraManager().destroy();
|
||||
}
|
||||
|
||||
const oldHass = this._hass;
|
||||
|
||||
+92
-54
@@ -1,12 +1,12 @@
|
||||
import { STATE_RUNNING } from 'home-assistant-js-websocket';
|
||||
import PQueue from 'p-queue';
|
||||
|
||||
import { isHassReady } from '../ha/is-hass-ready';
|
||||
import { sideLoadHomeAssistantElements } from '../ha/side-load-ha-elements';
|
||||
import { loadLanguages } from '../localize/localize';
|
||||
import { errorToConsole } from '../utils/basic';
|
||||
import { Initializer } from '../utils/initializer/initializer';
|
||||
import type { CardInitializerAPI } from './types';
|
||||
import { isHassReady } from '../../ha/is-hass-ready';
|
||||
import { sideLoadHomeAssistantElements } from '../../ha/side-load-ha-elements';
|
||||
import { loadLanguages } from '../../localize/localize';
|
||||
import { errorToConsole } from '../../utils/basic';
|
||||
import { Initializer } from '../../utils/initializer/initializer';
|
||||
import type { CardInitializerAPI } from '../types';
|
||||
import { SessionManager } from './session-manager';
|
||||
|
||||
export enum InitializationAspect {
|
||||
LANGUAGES = 'languages',
|
||||
@@ -40,22 +40,30 @@ export class InitializationManager {
|
||||
// initialization" (above) are followed.
|
||||
private _initializationQueue = new PQueue({ concurrency: 1 });
|
||||
private _initializer: Initializer;
|
||||
private _everInitialized = false;
|
||||
|
||||
constructor(api: CardInitializerAPI, initializer?: Initializer) {
|
||||
// Tracks an "initialization session" (the "useful" card time between full
|
||||
// readiness -> disconnection of various kinds).
|
||||
private _sessionManager: SessionManager;
|
||||
|
||||
constructor(
|
||||
api: CardInitializerAPI,
|
||||
initializer?: Initializer,
|
||||
sessionManager?: SessionManager,
|
||||
) {
|
||||
this._api = api;
|
||||
this._initializer = initializer ?? new Initializer();
|
||||
this._sessionManager = sessionManager ?? new SessionManager(api);
|
||||
}
|
||||
|
||||
public wasEverInitialized(): boolean {
|
||||
return this._everInitialized;
|
||||
public getSessionManager(): SessionManager {
|
||||
return this._sessionManager;
|
||||
}
|
||||
|
||||
public isInitialized(aspect: InitializationAspect): boolean {
|
||||
return this._initializer.isInitialized(aspect);
|
||||
}
|
||||
|
||||
public isInitializedMandatory(): boolean {
|
||||
public areMandatoryAspectsInitialized(): boolean {
|
||||
const config = this._api.getConfigManager().getConfig();
|
||||
if (!config) {
|
||||
return false;
|
||||
@@ -77,10 +85,16 @@ export class InitializationManager {
|
||||
}
|
||||
|
||||
// The one place that decides whether to (re)start mandatory initialization,
|
||||
// so callers don't re-check the conditions themselves. Called on every render
|
||||
// so callers don't check the conditions themselves. Called on every render
|
||||
// (from the card's shouldUpdate) and whenever hass changes (from
|
||||
// HASSManager); a reconnect or a cleared issue reaches it by causing a
|
||||
// render.
|
||||
//
|
||||
// The check here is only to keep cost down: a card that has finished
|
||||
// initializing re-renders often, and without it each of those renders would
|
||||
// queue an attempt that does nothing. `_initializeMandatory()` checks the
|
||||
// same conditions again when it actually runs, and that is the one that
|
||||
// matters for correctness.
|
||||
public triggerInitialization(): void {
|
||||
if (!this._shouldInitializeMandatory()) {
|
||||
return;
|
||||
@@ -93,7 +107,7 @@ export class InitializationManager {
|
||||
this._api.getConfigManager().hasConfig() &&
|
||||
this._api.getCardElementManager().isConnected() &&
|
||||
isHassReady(this._api.getHASSManager().getHASS()) &&
|
||||
!this.isInitializedMandatory() &&
|
||||
!this.areMandatoryAspectsInitialized() &&
|
||||
// Don't start while a full-card issue (e.g. the "Home Assistant is
|
||||
// starting" notice) is shown: each initialization step aborts as soon as
|
||||
// it sees one, so an attempt now would be wasted. The card tries again
|
||||
@@ -112,21 +126,24 @@ export class InitializationManager {
|
||||
|
||||
private async _initializeMandatory(): Promise<void> {
|
||||
const hass = this._api.getHASSManager().getHASS();
|
||||
if (!hass || this.isInitializedMandatory()) {
|
||||
|
||||
// The authoritative check, made when the attempt actually runs rather than
|
||||
// when it was queued: an attempt can sit in the queue behind another one,
|
||||
// and the card may be detached, lose Home Assistant, or finish initializing
|
||||
// while it waits. This is what stops a stale attempt running.
|
||||
//
|
||||
// The `isHassReady` call also narrows `hass` for the steps below. Its
|
||||
// RUNNING requirement waits out a Home Assistant that is still loading
|
||||
// integrations, against which integration-specific WS calls fail with
|
||||
// "Unknown command".
|
||||
if (!isHassReady(hass) || !this._shouldInitializeMandatory()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait until HA has finished loading integrations before attempting init.
|
||||
// Otherwise integration-specific WS calls (e.g. Frigate event
|
||||
// subscriptions) fail with "Unknown command" against a half-loaded HA. The
|
||||
// HASSManager will trigger another init attempt as soon as
|
||||
// hass.config.state transitions to RUNNING.
|
||||
if (hass.config?.state !== STATE_RUNNING) {
|
||||
return;
|
||||
}
|
||||
const token = this._sessionManager.startInitialization();
|
||||
|
||||
if (
|
||||
!(await this._tryInitialize(() =>
|
||||
!(await this._runStep(token, () =>
|
||||
this._initializer.initializeMultipleIfNecessary({
|
||||
// Caution: Ensure nothing in this set of initializers requires
|
||||
// config or languages since they will not yet have been initialized.
|
||||
@@ -142,13 +159,17 @@ export class InitializationManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = this._api.getConfigManager().getConfig();
|
||||
if (!config) {
|
||||
// The configuration may have vanished during the await above. The CAMERAS
|
||||
// initializer returns void and quietly does nothing without a
|
||||
// configuration, which would mark the aspect initialized against nothing
|
||||
// -- so stop before it runs.
|
||||
if (!this._api.getConfigManager().hasConfig()) {
|
||||
this._sessionManager.reportInitializationDeclined(token);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!(await this._tryInitialize(() =>
|
||||
!(await this._runStep(token, () =>
|
||||
this._initializer.initializeMultipleIfNecessary({
|
||||
[InitializationAspect.CAMERAS]: async () => {
|
||||
// Recreate the camera manager to guarantee an immediate re-render.
|
||||
@@ -185,7 +206,7 @@ export class InitializationManager {
|
||||
}
|
||||
|
||||
if (
|
||||
!(await this._tryInitialize(() =>
|
||||
!(await this._runStep(token, () =>
|
||||
this._initializer.initializeIfNecessary(
|
||||
InitializationAspect.VIEW,
|
||||
this._api.getViewManager().initialize,
|
||||
@@ -196,7 +217,7 @@ export class InitializationManager {
|
||||
}
|
||||
|
||||
if (
|
||||
!(await this._tryInitialize(() =>
|
||||
!(await this._runStep(token, () =>
|
||||
this._initializer.initializeIfNecessary(
|
||||
InitializationAspect.INITIAL_TRIGGER,
|
||||
async () => {
|
||||
@@ -211,57 +232,74 @@ export class InitializationManager {
|
||||
return;
|
||||
}
|
||||
|
||||
this._everInitialized = true;
|
||||
// The config is read here, at the last moment, so the card is never
|
||||
// reported as started against a configuration that a change during the
|
||||
// awaits above has already replaced. It is written to condition state here,
|
||||
// rather than by the ConfigManager, to ensure actions (that trigger on
|
||||
// config change) are not run before hass is available and the card is
|
||||
// initialized (the first config is set in the card *before* hass is set in
|
||||
// the card).
|
||||
const config = this._api.getConfigManager().getConfig();
|
||||
if (
|
||||
!config ||
|
||||
!this._sessionManager.isCurrentInitialization(token) ||
|
||||
!this.areMandatoryAspectsInitialized()
|
||||
) {
|
||||
this._sessionManager.reportInitializationDeclined(token);
|
||||
return;
|
||||
}
|
||||
|
||||
// Subscribe any automations now: the template renderer (a mandatory
|
||||
// automation trigger evaluators can baseline pre-trigger (which potentially
|
||||
// involves rendering templates). This must run before the `setState` below
|
||||
// so that triggers watching `config`/`initialized` are attached in time to
|
||||
// involves rendering templates). This must run before the report below so
|
||||
// that triggers watching `config`/`initialized` are attached in time to
|
||||
// fire on *that* very change.
|
||||
this._api.getAutomationsManager().subscribe();
|
||||
|
||||
// When the card is initialized, both the initialization state (will never
|
||||
// change again), and the config are set in the condition state. The
|
||||
// config is set here, rather than in the ConfigManager, in order to
|
||||
// ensure actions (that trigger on config change) are not run before hass
|
||||
// is available and the card is initialzied (the first config is set in
|
||||
// the card *before* hass is set in the card).
|
||||
this._api.getConditionStateManager().setState({
|
||||
config: config,
|
||||
initialized: this._everInitialized,
|
||||
});
|
||||
this._sessionManager.reportInitializationSucceeded(token, config);
|
||||
|
||||
this._api.getCardElementManager().update();
|
||||
}
|
||||
|
||||
private async _tryInitialize(fn: () => Promise<boolean>): Promise<boolean> {
|
||||
// Run one step of initialization, telling the session manager the outcome and
|
||||
// returning whether the remaining steps should run.
|
||||
//
|
||||
// A step "declines" when it raises no error of its own but yet the
|
||||
// initialization process should not continue.
|
||||
private async _runStep(token: number, fn: () => Promise<boolean>): Promise<boolean> {
|
||||
let initialized = false;
|
||||
try {
|
||||
initialized = await fn();
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorToConsole(e);
|
||||
if (this._sessionManager.isCurrentInitialization(token)) {
|
||||
if (e instanceof Error) {
|
||||
errorToConsole(e);
|
||||
}
|
||||
this._api.getIssueManager().trigger('initialization', { error: e });
|
||||
this._sessionManager.reportInitializationFailed(token);
|
||||
}
|
||||
this._setInitializationIssue(e);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._api.getIssueManager().getStateManager().hasFullCardIssue()) {
|
||||
if (
|
||||
!initialized ||
|
||||
this._api.getIssueManager().getStateManager().hasFullCardIssue()
|
||||
) {
|
||||
this._sessionManager.reportInitializationDeclined(token);
|
||||
return false;
|
||||
}
|
||||
|
||||
return initialized;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _setInitializationIssue(error: unknown): void {
|
||||
this._api.getIssueManager().trigger('initialization', { error });
|
||||
}
|
||||
|
||||
public uninitialize(aspect: InitializationAspect): void {
|
||||
public invalidateAspect(aspect: InitializationAspect): void {
|
||||
this._initializer.uninitialize(aspect);
|
||||
}
|
||||
|
||||
public uninitializeMandatory(): void {
|
||||
// Mark every mandatory aspect uninitialized, so a fresh initialization starts
|
||||
// from nothing. Which aspects those are is this class's own question -- see
|
||||
// `areMandatoryAspectsInitialized()`.
|
||||
public invalidateMandatoryAspects(): void {
|
||||
for (const aspect of [
|
||||
InitializationAspect.CAMERAS,
|
||||
InitializationAspect.MICROPHONE_CONNECT,
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { AdvancedCameraCardConfig } from '../../config/schema/types';
|
||||
import { Generation } from '../../utils/concurrency/generation';
|
||||
import type { CardSessionAPI } from '../types';
|
||||
|
||||
// ============================================================================
|
||||
// The card runs in "initialization sessions". A session begins when the card is
|
||||
// attached to the page with a ready Home Assistant, and ends when the card is
|
||||
// detached, when Home Assistant stops being ready, or when initialization
|
||||
// fails. Initializing an individual aspect again (e.g. because the
|
||||
// configuration changed) happens *within* a session and does not end it, akin
|
||||
// to a running application staying started while it reconnects one subsystem.
|
||||
//
|
||||
// A session therefore involves one or more *initialization runs*: the first
|
||||
// starts the card, and each later one initializes whichever aspects were
|
||||
// invalidated while the session carried on. A run ends in one of three ways:
|
||||
//
|
||||
// - it *succeeds*, and the card is started;
|
||||
// - it *fails*, meaning a step threw an error, and the "Issue" manager takes
|
||||
// over to display and handle the issue.
|
||||
// - it *declines*, meaning it stopped early without an error of its own -- a
|
||||
// step could not complete yet, or something else had already gone wrong.
|
||||
//
|
||||
// Whichever way a run ends is its outcome, reported back here with the number
|
||||
// the run was given when it started.
|
||||
//
|
||||
// This class is the session lifecycle as a state machine, and is the only
|
||||
// writer of the `initialized` and `everInitialized` condition state -- what
|
||||
// users write conditions and triggers against. Whether every mandatory aspect
|
||||
// is initialized right now is a separate question owned by the
|
||||
// InitializationManager, and the two deliberately disagree while an aspect is
|
||||
// being initialized again mid-session.
|
||||
//
|
||||
// There is no "failed" state: a card blocked after a failed run is IDLE with a
|
||||
// full-card issue showing, and the issue system is the authority on that.
|
||||
// ============================================================================
|
||||
|
||||
export enum SessionState {
|
||||
// No initialized session: the card is detached, Home Assistant is not ready,
|
||||
// or a run declined or failed and nothing has started another yet.
|
||||
IDLE = 'idle',
|
||||
|
||||
// A session has started and its first run is under way; nothing written to
|
||||
// the condition state yet.
|
||||
INITIALIZING = 'initializing',
|
||||
|
||||
// The card has started: `initialized` is true. Later runs that initialize an
|
||||
// aspect again may happen without leaving this state.
|
||||
RUNNING = 'running',
|
||||
}
|
||||
|
||||
export class SessionManager {
|
||||
private _api: CardSessionAPI;
|
||||
private _state = SessionState.IDLE;
|
||||
private _everInitialized = false;
|
||||
|
||||
// Numbers the "initialization run" currently in progress (not the session,
|
||||
// which may contain several runs). The number changes when a session ends and
|
||||
// when a run reports its outcome, so an outcome from a run that something has
|
||||
// since replaced is ignored, and so is a second outcome from the same run.
|
||||
private _generation = new Generation();
|
||||
|
||||
constructor(api: CardSessionAPI) {
|
||||
this._api = api;
|
||||
}
|
||||
|
||||
public getState(): SessionState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
public wasEverInitialized(): boolean {
|
||||
return this._everInitialized;
|
||||
}
|
||||
|
||||
// Start an "initialization run" and return the number identifying it, which
|
||||
// must be handed back with its outcome. From IDLE this is the session's first
|
||||
// run; from RUNNING it initializes an aspect again mid-session, and the state
|
||||
// (with the published `initialized`) stays as it is.
|
||||
public startInitialization(): number {
|
||||
if (this._state === SessionState.IDLE) {
|
||||
this._state = SessionState.INITIALIZING;
|
||||
}
|
||||
return this._generation.next();
|
||||
}
|
||||
|
||||
public isCurrentInitialization(token: number): boolean {
|
||||
return this._generation.isCurrent(token);
|
||||
}
|
||||
|
||||
// The run completed, so the card is now started. The config is written in the
|
||||
// same change as the session state, so a trigger watching either sees one
|
||||
// consistent state. When an aspect was initialized again mid-session
|
||||
// `initialized` and
|
||||
// `everInitialized` already hold these values, so the change carries only the
|
||||
// config -- which is what stops an `initialized` trigger firing again in the
|
||||
// middle of a session.
|
||||
public reportInitializationSucceeded(
|
||||
token: number,
|
||||
config: AdvancedCameraCardConfig,
|
||||
): void {
|
||||
if (!this._acceptOutcome(token)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set before the condition state is written, so anything that reads this
|
||||
// class while handling that change sees the new state.
|
||||
this._state = SessionState.RUNNING;
|
||||
this._everInitialized = true;
|
||||
|
||||
this._api.getConditionStateManager().setState({
|
||||
config,
|
||||
initialized: true,
|
||||
everInitialized: true,
|
||||
});
|
||||
}
|
||||
|
||||
// The run stopped early without producing an error of its own: either a step
|
||||
// could not complete yet (e.g. the view declining while the cameras are being
|
||||
// initialized), or a full-card issue raised elsewhere makes continuing
|
||||
// pointless.
|
||||
//
|
||||
// Nothing is written -- a card that had not started has nothing to take back,
|
||||
// and one that had is still started.
|
||||
public reportInitializationDeclined(token: number): void {
|
||||
if (!this._acceptOutcome(token)) {
|
||||
return;
|
||||
}
|
||||
if (this._state === SessionState.INITIALIZING) {
|
||||
this._state = SessionState.IDLE;
|
||||
}
|
||||
}
|
||||
|
||||
// The run threw an error which ends the session: a card missing a mandatory
|
||||
// aspect is not started. The initialization issue was already raised (by the
|
||||
// Issue Manager).
|
||||
public reportInitializationFailed(token: number): void {
|
||||
if (!this._acceptOutcome(token)) {
|
||||
return;
|
||||
}
|
||||
this._toIdle();
|
||||
}
|
||||
|
||||
// The session is over -- the card left the page or Home Assistant went away.
|
||||
// Aspects are left as they are: a caller that knows which of them the next
|
||||
// session must initialize again invalidates those itself.
|
||||
public end(): void {
|
||||
this._generation.invalidate();
|
||||
this._toIdle();
|
||||
}
|
||||
|
||||
private _toIdle(): void {
|
||||
const wasRunning = this._state === SessionState.RUNNING;
|
||||
this._state = SessionState.IDLE;
|
||||
|
||||
// `initialized: false` is written only on a card that had previously
|
||||
// started.
|
||||
if (wasRunning) {
|
||||
this._api.getConditionStateManager().setState({ initialized: false });
|
||||
}
|
||||
}
|
||||
|
||||
// Whether an outcome should be acted on: it must come from the run currently
|
||||
// in progress, and only the first outcome from that run counts. Invalidating
|
||||
// the generation number here is what makes a second run of the same
|
||||
// initialization do nothing.
|
||||
private _acceptOutcome(token: number): boolean {
|
||||
if (!this._generation.isCurrent(token)) {
|
||||
return false;
|
||||
}
|
||||
this._generation.invalidate();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export const RETRY_EXPONENTIAL_MAX_SECONDS = 600;
|
||||
|
||||
// Wraps the passive IssueStateManager with reaction logic. A single
|
||||
// condition-state listener drives everything: it runs one-shot static detection
|
||||
// when mandatory-init completes (`initialized` transitions to true), then
|
||||
// when mandatory-init first completes (`everInitialized` becomes true), then
|
||||
// evaluates dynamic issues on every subsequent state change, schedules retries,
|
||||
// and updates the card. Full-card issues are rendered by card.ts via
|
||||
// getStateManager().getFullCardIssue(). Non-full-card issue notifications are
|
||||
@@ -183,14 +183,14 @@ export class IssueManager {
|
||||
// Drives both one-shot static detection (on mandatory-init completion) and
|
||||
// normal re-evaluation (on any condition-state change).
|
||||
//
|
||||
// `initialized: true` in the change payload means mandatory initialization
|
||||
// just finished -- see InitializationManager._initializeMandatory. That's
|
||||
// also the earliest point at which the full HASS object is guaranteed
|
||||
// `everInitialized` is set when mandatory initialization first completes --
|
||||
// see InitializationManager._initializeMandatory -- and is never cleared, so
|
||||
// this block runs exactly once however many times the card initializes. That
|
||||
// is also the earliest point at which the full HASS object is guaranteed
|
||||
// ready for websocket calls (e.g. LegacyResourceIssue's lovelace/resources
|
||||
// fetch). Because `initialized` is latched (its comment notes it never
|
||||
// changes again), this block fires exactly once per IssueManager life.
|
||||
// fetch).
|
||||
private _onStateChange(change: ConditionStateChange): void {
|
||||
if (change.change.initialized === true && change.new.hass) {
|
||||
if (change.change.everInitialized === true && change.new.hass) {
|
||||
void this._stateManager.detectStatic(change.new.hass).then(() => this.evaluate());
|
||||
}
|
||||
this.evaluate();
|
||||
|
||||
@@ -47,7 +47,7 @@ export class InitializationIssue extends AbstractErrorIssue {
|
||||
}
|
||||
|
||||
public detectDynamic(): void {
|
||||
if (!this._api.getInitializationManager().isInitializedMandatory()) {
|
||||
if (!this._api.getInitializationManager().areMandatoryAspectsInitialized()) {
|
||||
return;
|
||||
}
|
||||
// The success settle: mandatory init completed, so there is no error to show
|
||||
@@ -88,7 +88,8 @@ export class InitializationIssue extends AbstractErrorIssue {
|
||||
// render cycle. destroy() releases the existing CameraManager's held
|
||||
// resources (WebSocket subscriptions, listeners) before the CAMERAS
|
||||
// init aspect replaces the instance via createCameraManager().
|
||||
this._api.getInitializationManager().uninitializeMandatory();
|
||||
this._api.getInitializationManager().invalidateMandatoryAspects();
|
||||
this._api.getInitializationManager().getSessionManager().end();
|
||||
void this._api.getCameraManager().destroy();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { ExpandManager } from './expand-manager';
|
||||
import type { FoldersManager } from './folders/manager';
|
||||
import type { FullscreenManager } from './fullscreen/fullscreen-manager';
|
||||
import type { HASSManager } from './hass/hass-manager';
|
||||
import type { InitializationManager } from './initialization-manager';
|
||||
import type { InitializationManager } from './initialization/initialization-manager';
|
||||
import type { InteractionManager } from './interaction-manager';
|
||||
import type { IssueManager } from './issues/issue-manager';
|
||||
import type { KeyboardStateManager } from './keyboard-state-manager';
|
||||
@@ -307,6 +307,10 @@ export interface CardQueryStringAPI {
|
||||
getViewManager(): ViewManager;
|
||||
}
|
||||
|
||||
export interface CardSessionAPI {
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
}
|
||||
|
||||
export interface CardStatusBarAPI {
|
||||
getCardElementManager(): CardElementManager;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { log } from '../../utils/debug';
|
||||
import { getStreamCameraID } from '../../view/substream';
|
||||
import { getViewTargetID } from '../../view/target-id';
|
||||
import type { View } from '../../view/view';
|
||||
import { InitializationAspect } from '../initialization-manager';
|
||||
import { InitializationAspect } from '../initialization/initialization-manager';
|
||||
import type { CardViewAPI } from '../types';
|
||||
import { ViewFactory } from './factory';
|
||||
import { applyViewModifiers } from './modifiers';
|
||||
|
||||
+3
-2
@@ -360,7 +360,7 @@ export class AdvancedCameraCard extends LitElement {
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
if (this._controller.getInitializationManager().isInitializedMandatory()) {
|
||||
if (this._controller.getInitializationManager().areMandatoryAspectsInitialized()) {
|
||||
void this._controller.getQueryStringManager().executeIfNecessary();
|
||||
}
|
||||
}
|
||||
@@ -461,6 +461,7 @@ export class AdvancedCameraCard extends LitElement {
|
||||
? html`<advanced-camera-card-loading
|
||||
.loaded=${this._controller
|
||||
.getInitializationManager()
|
||||
.getSessionManager()
|
||||
.wasEverInitialized()}
|
||||
.effectsManager=${this._config?.performance?.features
|
||||
.card_loading_effects !== false
|
||||
@@ -503,7 +504,7 @@ export class AdvancedCameraCard extends LitElement {
|
||||
</div>
|
||||
${this._renderMenuStatusContainer('bottom')}
|
||||
${this._config?.elements &&
|
||||
this._controller.getInitializationManager().isInitializedMandatory()
|
||||
this._controller.getInitializationManager().areMandatoryAspectsInitialized()
|
||||
? // Elements need to render after the main views so it can render 'on
|
||||
// top'. They are held until the card is initialized: the template
|
||||
// renderer loads lazily as a mandatory init aspect (when the
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import type { InitializedBase } from '../../../config/schema/condition-trigger/common/initialized';
|
||||
import type { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import type { ConditionEvaluator } from './types';
|
||||
|
||||
export class InitializedConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: InitializedBase;
|
||||
|
||||
constructor(condition: InitializedBase) {
|
||||
this._condition = condition;
|
||||
}
|
||||
|
||||
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
||||
return { result: !!newState?.initialized };
|
||||
return {
|
||||
result: !!(this._condition.ever
|
||||
? newState?.everInitialized
|
||||
: newState?.initialized),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export const createConditionEvaluator = (
|
||||
case 'user_agent':
|
||||
return new UserAgentConditionEvaluator(condition);
|
||||
case 'initialized':
|
||||
return new InitializedConditionEvaluator();
|
||||
return new InitializedConditionEvaluator(condition);
|
||||
case 'template':
|
||||
return new TemplateConditionEvaluator(condition, context);
|
||||
case 'or':
|
||||
@@ -123,6 +123,8 @@ export const createConditionEvaluatorForTrigger = (
|
||||
return new ExpandConditionEvaluator(trigger);
|
||||
case 'fullscreen':
|
||||
return new FullscreenConditionEvaluator(trigger);
|
||||
case 'initialized':
|
||||
return new InitializedConditionEvaluator(trigger);
|
||||
case 'interaction':
|
||||
return new InteractionConditionEvaluator(trigger);
|
||||
case 'key':
|
||||
|
||||
@@ -26,7 +26,6 @@ export interface ConditionState {
|
||||
displayMode?: ViewDisplayMode;
|
||||
expand?: boolean;
|
||||
fullscreen?: boolean;
|
||||
initialized?: boolean;
|
||||
interaction?: boolean;
|
||||
keys?: KeysState;
|
||||
mediaLoadedInfo?: MediaLoadedInfo | null;
|
||||
@@ -34,6 +33,12 @@ export interface ConditionState {
|
||||
panel?: boolean;
|
||||
hass?: HomeAssistant;
|
||||
|
||||
// Initialization:
|
||||
// - Currently initialized.
|
||||
initialized?: boolean;
|
||||
// - Ever initialized.
|
||||
everInitialized?: boolean;
|
||||
|
||||
// Generic media target identifier. See @view/target-id for details.
|
||||
targetID?: string;
|
||||
triggered?: Set<string>;
|
||||
|
||||
@@ -6,6 +6,6 @@ export class InitializedTrigger extends ConditionStateTriggerBase<
|
||||
TriggerOfType<'initialized'>
|
||||
> {
|
||||
protected _getValue(state: ConditionState): unknown {
|
||||
return state.initialized;
|
||||
return this._trigger.ever ? state.everInitialized : state.initialized;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const initializedBaseSchema = z.object({});
|
||||
export const initializedBaseSchema = z.object({
|
||||
// Matches the card having ever been initialized, rather than it being
|
||||
// initialized right now.
|
||||
//
|
||||
// Defaulted rather than optional so the trigger always carries a value. A
|
||||
// valueless trigger fires on any change of what it watches, which here would
|
||||
// include the card becoming uninitialized -- and an action fired then would
|
||||
// run against a card that has just been torn down.
|
||||
ever: z.boolean().default(false),
|
||||
});
|
||||
export type InitializedBase = z.infer<typeof initializedBaseSchema>;
|
||||
|
||||
Reference in New Issue
Block a user