fix: Prevent premature reinitialization when stale HA reconnects (#2717)

- Closes: #2714
This commit is contained in:
Dermot Duffy
2026-08-29 13:47:38 -07:00
committed by GitHub
parent fb53681978
commit 4b101545cc
15 changed files with 333 additions and 131 deletions
+4 -1
View File
@@ -166,7 +166,10 @@ export class CardController
// camera-trigger handler that writes back to ConditionStateManager, fanning
// out to automations that still read a stale `hass`.
this._hassManager.addListener((hass) =>
this._conditionStateManager.setState({ hass }),
this._conditionStateManager.setState({
hass,
hassReadiness: this._hassManager.getReadiness(),
}),
);
host.addController(this);
+42 -7
View File
@@ -1,4 +1,5 @@
import { isHassReady } from '../../ha/is-hass-ready';
import { STATE_RUNNING, type HassConfig } from 'home-assistant-js-websocket';
import type { HASSListener } from '../../ha/source';
import type { HomeAssistant } from '../../ha/types';
import type { UnsubscribeCallback } from '../../types';
@@ -7,12 +8,17 @@ 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';
import type { HASSManagerReadonlyInterface } from './types';
import type { HASSManagerReadonlyInterface, HASSReadiness } from './types';
export class HASSManager implements HASSManagerReadonlyInterface {
private _hass: HomeAssistant | null = null;
private _api: CardHASSAPI;
// The Home Assistant frontend restores `connected` the moment the socket
// returns, but keeps reporting this same configuration until its own
// `get_config` answers.
private _disconnectedConfig: HassConfig | null = null;
private _hassListeners = new Set<HASSListener>();
private _stateWatcher: StateWatcherSubscriptionInterface;
@@ -32,6 +38,28 @@ export class HASSManager implements HASSManagerReadonlyInterface {
return !!this._hass;
}
public getReadiness(): HASSReadiness {
if (!this._hass?.connected) {
return 'disconnected';
}
return this._isReady(this._hass) ? 'ready' : 'starting';
}
public isReady(): boolean {
return this.getReadiness() === 'ready';
}
// The frontend reuses the pre-disconnect `hass.config` after reconnecting, so
// `STATE_RUNNING` can be stale. `_disconnectedConfig` rejects it until a
// new config object appears.
private _isReady(hass: HomeAssistant | null): boolean {
return (
!!hass?.connected &&
hass.config !== this._disconnectedConfig &&
hass.config?.state === STATE_RUNNING
);
}
public getStateWatcher(): StateWatcherSubscriptionInterface {
return this._stateWatcher;
}
@@ -54,8 +82,15 @@ export class HASSManager implements HASSManagerReadonlyInterface {
return;
}
const wasReady = !!this._hass && isHassReady(this._hass);
const isReady = isHassReady(hass);
const wasReady = this._isReady(this._hass);
if (!hass.connected) {
this._disconnectedConfig = hass.config;
} else if (hass.config !== this._disconnectedConfig) {
this._disconnectedConfig = null;
}
const isReady = this._isReady(hass);
// A card cannot be started without Home Assistant, so losing it ends the
// card's initialization session. The aspects initialized during that
@@ -65,9 +100,9 @@ export class HASSManager implements HASSManagerReadonlyInterface {
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.
// When HA goes from "not ready" to "ready" (reconnected, fresh
// `hass.config`, and `STATE_RUNNING`), rebuild cameras and view from
// scratch: available entities may have changed while it was down.
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.
+6
View File
@@ -2,6 +2,12 @@ import type { HASSSource } from '../../ha/source';
import type { EventWatcherSubscriptionInterface } from './event-watcher';
import type { StateWatcherSubscriptionInterface } from './state-watcher';
// The card's view of HA's readiness:
// - 'disconnected' : WebSocket is down
// - 'starting' : connected, but HA has not finished loading integrations
// - 'ready' : connected and fully running
export type HASSReadiness = 'disconnected' | 'starting' | 'ready';
export interface HASSManagerReadonlyInterface extends HASSSource {
getStateWatcher(): StateWatcherSubscriptionInterface;
getEventWatcher(): EventWatcherSubscriptionInterface;
@@ -1,6 +1,5 @@
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';
@@ -104,7 +103,7 @@ export class InitializationManager {
return (
this._api.getConfigManager().hasConfig() &&
this._api.getCardElementManager().isConnected() &&
isHassReady(this._api.getHASSManager().getHASS()) &&
this._api.getHASSManager().isReady() &&
// Start when aspects remain to be initialized, or when the session is
// idle even though aspects are otherwise initialized. A run that
// initialized every aspect and then declined (e.g. because something
@@ -138,11 +137,10 @@ export class InitializationManager {
// 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()) {
// Readiness itself is asked of the HASS manager, whose RUNNING requirement
// waits out a Home Assistant that is still loading integrations, against
// which integration-specific WS calls fail with "Unknown command".
if (!hass || !this._shouldInitializeMandatory()) {
return;
}
@@ -1,41 +1,21 @@
import { STATE_RUNNING } from 'home-assistant-js-websocket';
import type { ConditionState } from '../../../condition-trigger/conditions/types.js';
import { localize } from '../../../localize/localize.js';
import type { HASSReadiness } from '../../hass/types.js';
import type { Issue, IssueDescription } from '../types.js';
// Tracks "is HA fully ready to talk to". Active in two sub-states:
// - 'lost' : the WebSocket is disconnected
// - 'starting' : the WebSocket is connected but HA hasn't finished loading
// integrations yet (hass.config.state !== STATE_RUNNING)
// Both sub-states render as a full-card notification with a spinner. The card
// only attempts re-initialization when the issue clears (i.e. HA is fully
// ready), so integration-specific WS calls (e.g. Frigate event subscriptions)
// don't fail with "Unknown command" against a half-loaded HA.
type ConnectionState = 'ready' | 'lost' | 'starting';
export class ConnectionIssue implements Issue {
public readonly key = 'connection' as const;
private _state: ConnectionState = 'ready';
private _readiness: HASSReadiness = 'ready';
public detectDynamic(state: ConditionState): void {
// Before HASS is ever provided, leave state untouched -- undefined hass is
// not a disconnection, just "not yet initialized".
if (state.hass === undefined) {
return;
}
if (!state.hass.connected) {
this._state = 'lost';
} else if (state.hass.config?.state !== STATE_RUNNING) {
this._state = 'starting';
} else {
this._state = 'ready';
if (state.hassReadiness) {
this._readiness = state.hassReadiness;
}
}
public hasIssue(): boolean {
return this._state !== 'ready';
return this._readiness !== 'ready';
}
public isFullCardIssue(): boolean {
@@ -43,7 +23,7 @@ export class ConnectionIssue implements Issue {
}
public getIssue(): IssueDescription | null {
return this._state === 'lost'
return this._readiness === 'disconnected'
? {
icon: 'mdi:lan-disconnect',
severity: 'high',
@@ -57,7 +37,7 @@ export class ConnectionIssue implements Issue {
in_progress: true,
},
}
: this._state === 'starting'
: this._readiness === 'starting'
? {
icon: 'mdi:home-assistant',
severity: 'medium',
@@ -75,6 +55,6 @@ export class ConnectionIssue implements Issue {
}
public reset(): void {
this._state = 'ready';
this._readiness = 'ready';
}
}
@@ -1,3 +1,4 @@
import type { HASSReadiness } from '../../card-controller/hass/types';
import type { KeysState, MicrophoneState } from '../../card-controller/types';
import type { AdvancedCameraCardView } from '../../config/schema/common/const';
import type { ViewDisplayMode } from '../../config/schema/common/display';
@@ -31,7 +32,12 @@ export interface ConditionState {
mediaLoadedInfo?: MediaLoadedInfo | null;
microphone?: MicrophoneState;
panel?: boolean;
// Home Assistant:
// - The main HA object itself.
hass?: HomeAssistant;
// - The card's view of HA's readiness.
hassReadiness?: HASSReadiness;
// Initialization:
// - Currently initialized.
+1 -2
View File
@@ -6,7 +6,6 @@ import {
type GetKeyCallback,
} from '../../utils/concurrency/keyed-subscription-manager';
import { RetryTimer } from '../../utils/retry-timer';
import { isHassReady } from '../is-hass-ready';
import type { HASSSource } from '../source';
import type { HomeAssistant } from '../types';
import type { HASSWebSocketLiveness, HASSWebSocketOpenCallback } from './types';
@@ -226,7 +225,7 @@ export class HASSConnectionSubscriptionManager<K, R> {
}
private _handleHASSChange(hass: HomeAssistant | null): void {
if (!hass || !isHassReady(hass)) {
if (!hass || !this._source.isReady()) {
if (this._connectionEra !== null) {
this._endEra();
// Surface the era end to consumers so they can update any UI that was
-15
View File
@@ -1,15 +0,0 @@
import { STATE_RUNNING } from 'home-assistant-js-websocket';
import type { HomeAssistant } from './types';
// HA is "ready" when the WebSocket is connected AND integrations have finished
// loading (`config.state === STATE_RUNNING`). HA exposes the WebSocket before
// integrations load, so `connected` alone is insufficient for
// integration-specific calls (e.g. Frigate WS subscriptions, which fail with
// "Unknown command" against a half-loaded HA).
//
// Typed as a predicate so callers can use the readiness check to narrow a
// nullable `hass?` reference to a non-null `HomeAssistant` for follow-up
// `hass.connection`-style access.
export const isHassReady = (hass?: HomeAssistant | null): hass is HomeAssistant =>
!!hass?.connected && hass.config?.state === STATE_RUNNING;
+5
View File
@@ -11,5 +11,10 @@ export type HASSListener = (hass: HomeAssistant, oldHass: HomeAssistant | null)
export interface HASSSource {
getHASS(): HomeAssistant | null;
// Whether the current HASS is ready to be talked to. Consumers must ask here
// rather than testing the HASS themselves.
isReady(): boolean;
addListener(listener: HASSListener): UnsubscribeCallback;
}