fix: Prevent premature reinitialization when stale HA reconnects (#2717)
- Closes: #2714
This commit is contained in:
@@ -166,7 +166,10 @@ export class CardController
|
|||||||
// camera-trigger handler that writes back to ConditionStateManager, fanning
|
// camera-trigger handler that writes back to ConditionStateManager, fanning
|
||||||
// out to automations that still read a stale `hass`.
|
// out to automations that still read a stale `hass`.
|
||||||
this._hassManager.addListener((hass) =>
|
this._hassManager.addListener((hass) =>
|
||||||
this._conditionStateManager.setState({ hass }),
|
this._conditionStateManager.setState({
|
||||||
|
hass,
|
||||||
|
hassReadiness: this._hassManager.getReadiness(),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
host.addController(this);
|
host.addController(this);
|
||||||
|
|||||||
@@ -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 { HASSListener } from '../../ha/source';
|
||||||
import type { HomeAssistant } from '../../ha/types';
|
import type { HomeAssistant } from '../../ha/types';
|
||||||
import type { UnsubscribeCallback } from '../../types';
|
import type { UnsubscribeCallback } from '../../types';
|
||||||
@@ -7,12 +8,17 @@ import { InitializationAspect } from '../initialization/initialization-manager';
|
|||||||
import type { CardHASSAPI } from '../types';
|
import type { CardHASSAPI } from '../types';
|
||||||
import { EventWatcher, type EventWatcherSubscriptionInterface } from './event-watcher';
|
import { EventWatcher, type EventWatcherSubscriptionInterface } from './event-watcher';
|
||||||
import { StateWatcher, type StateWatcherSubscriptionInterface } from './state-watcher';
|
import { StateWatcher, type StateWatcherSubscriptionInterface } from './state-watcher';
|
||||||
import type { HASSManagerReadonlyInterface } from './types';
|
import type { HASSManagerReadonlyInterface, HASSReadiness } from './types';
|
||||||
|
|
||||||
export class HASSManager implements HASSManagerReadonlyInterface {
|
export class HASSManager implements HASSManagerReadonlyInterface {
|
||||||
private _hass: HomeAssistant | null = null;
|
private _hass: HomeAssistant | null = null;
|
||||||
private _api: CardHASSAPI;
|
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 _hassListeners = new Set<HASSListener>();
|
||||||
|
|
||||||
private _stateWatcher: StateWatcherSubscriptionInterface;
|
private _stateWatcher: StateWatcherSubscriptionInterface;
|
||||||
@@ -32,6 +38,28 @@ export class HASSManager implements HASSManagerReadonlyInterface {
|
|||||||
return !!this._hass;
|
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 {
|
public getStateWatcher(): StateWatcherSubscriptionInterface {
|
||||||
return this._stateWatcher;
|
return this._stateWatcher;
|
||||||
}
|
}
|
||||||
@@ -54,8 +82,15 @@ export class HASSManager implements HASSManagerReadonlyInterface {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const wasReady = !!this._hass && isHassReady(this._hass);
|
const wasReady = this._isReady(this._hass);
|
||||||
const isReady = isHassReady(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
|
// A card cannot be started without Home Assistant, so losing it ends the
|
||||||
// card's initialization session. The aspects initialized during that
|
// card's initialization session. The aspects initialized during that
|
||||||
@@ -65,9 +100,9 @@ export class HASSManager implements HASSManagerReadonlyInterface {
|
|||||||
this._api.getInitializationManager().getSessionManager().end();
|
this._api.getInitializationManager().getSessionManager().end();
|
||||||
}
|
}
|
||||||
|
|
||||||
// When HA goes from "not ready" to "ready" (WebSocket reconnected AND all
|
// When HA goes from "not ready" to "ready" (reconnected, fresh
|
||||||
// integrations finished loading), rebuild cameras and the view from
|
// `hass.config`, and `STATE_RUNNING`), rebuild cameras and view from
|
||||||
// scratch: the available entities may have changed while it was down.
|
// scratch: available entities may have changed while it was down.
|
||||||
if (!!this._hass && !wasReady && isReady) {
|
if (!!this._hass && !wasReady && isReady) {
|
||||||
// Tear cameras down before the listeners below see the new hass,
|
// Tear cameras down before the listeners below see the new hass,
|
||||||
// otherwise they would briefly rebuild against the old entities.
|
// otherwise they would briefly rebuild against the old entities.
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import type { HASSSource } from '../../ha/source';
|
|||||||
import type { EventWatcherSubscriptionInterface } from './event-watcher';
|
import type { EventWatcherSubscriptionInterface } from './event-watcher';
|
||||||
import type { StateWatcherSubscriptionInterface } from './state-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 {
|
export interface HASSManagerReadonlyInterface extends HASSSource {
|
||||||
getStateWatcher(): StateWatcherSubscriptionInterface;
|
getStateWatcher(): StateWatcherSubscriptionInterface;
|
||||||
getEventWatcher(): EventWatcherSubscriptionInterface;
|
getEventWatcher(): EventWatcherSubscriptionInterface;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import PQueue from 'p-queue';
|
import PQueue from 'p-queue';
|
||||||
|
|
||||||
import { isHassReady } from '../../ha/is-hass-ready';
|
|
||||||
import { sideLoadHomeAssistantElements } from '../../ha/side-load-ha-elements';
|
import { sideLoadHomeAssistantElements } from '../../ha/side-load-ha-elements';
|
||||||
import { loadLanguages } from '../../localize/localize';
|
import { loadLanguages } from '../../localize/localize';
|
||||||
import { errorToConsole } from '../../utils/basic';
|
import { errorToConsole } from '../../utils/basic';
|
||||||
@@ -104,7 +103,7 @@ export class InitializationManager {
|
|||||||
return (
|
return (
|
||||||
this._api.getConfigManager().hasConfig() &&
|
this._api.getConfigManager().hasConfig() &&
|
||||||
this._api.getCardElementManager().isConnected() &&
|
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
|
// Start when aspects remain to be initialized, or when the session is
|
||||||
// idle even though aspects are otherwise initialized. A run that
|
// idle even though aspects are otherwise initialized. A run that
|
||||||
// initialized every aspect and then declined (e.g. because something
|
// 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
|
// and the card may be detached, lose Home Assistant, or finish initializing
|
||||||
// while it waits. This is what stops a stale attempt running.
|
// while it waits. This is what stops a stale attempt running.
|
||||||
//
|
//
|
||||||
// The `isHassReady` call also narrows `hass` for the steps below. Its
|
// Readiness itself is asked of the HASS manager, whose RUNNING requirement
|
||||||
// RUNNING requirement waits out a Home Assistant that is still loading
|
// waits out a Home Assistant that is still loading integrations, against
|
||||||
// integrations, against which integration-specific WS calls fail with
|
// which integration-specific WS calls fail with "Unknown command".
|
||||||
// "Unknown command".
|
if (!hass || !this._shouldInitializeMandatory()) {
|
||||||
if (!isHassReady(hass) || !this._shouldInitializeMandatory()) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +1,21 @@
|
|||||||
import { STATE_RUNNING } from 'home-assistant-js-websocket';
|
|
||||||
|
|
||||||
import type { ConditionState } from '../../../condition-trigger/conditions/types.js';
|
import type { ConditionState } from '../../../condition-trigger/conditions/types.js';
|
||||||
import { localize } from '../../../localize/localize.js';
|
import { localize } from '../../../localize/localize.js';
|
||||||
|
import type { HASSReadiness } from '../../hass/types.js';
|
||||||
import type { Issue, IssueDescription } from '../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 {
|
export class ConnectionIssue implements Issue {
|
||||||
public readonly key = 'connection' as const;
|
public readonly key = 'connection' as const;
|
||||||
|
|
||||||
private _state: ConnectionState = 'ready';
|
private _readiness: HASSReadiness = 'ready';
|
||||||
|
|
||||||
public detectDynamic(state: ConditionState): void {
|
public detectDynamic(state: ConditionState): void {
|
||||||
// Before HASS is ever provided, leave state untouched -- undefined hass is
|
if (state.hassReadiness) {
|
||||||
// not a disconnection, just "not yet initialized".
|
this._readiness = state.hassReadiness;
|
||||||
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';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public hasIssue(): boolean {
|
public hasIssue(): boolean {
|
||||||
return this._state !== 'ready';
|
return this._readiness !== 'ready';
|
||||||
}
|
}
|
||||||
|
|
||||||
public isFullCardIssue(): boolean {
|
public isFullCardIssue(): boolean {
|
||||||
@@ -43,7 +23,7 @@ export class ConnectionIssue implements Issue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getIssue(): IssueDescription | null {
|
public getIssue(): IssueDescription | null {
|
||||||
return this._state === 'lost'
|
return this._readiness === 'disconnected'
|
||||||
? {
|
? {
|
||||||
icon: 'mdi:lan-disconnect',
|
icon: 'mdi:lan-disconnect',
|
||||||
severity: 'high',
|
severity: 'high',
|
||||||
@@ -57,7 +37,7 @@ export class ConnectionIssue implements Issue {
|
|||||||
in_progress: true,
|
in_progress: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: this._state === 'starting'
|
: this._readiness === 'starting'
|
||||||
? {
|
? {
|
||||||
icon: 'mdi:home-assistant',
|
icon: 'mdi:home-assistant',
|
||||||
severity: 'medium',
|
severity: 'medium',
|
||||||
@@ -75,6 +55,6 @@ export class ConnectionIssue implements Issue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public reset(): void {
|
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 { KeysState, MicrophoneState } from '../../card-controller/types';
|
||||||
import type { AdvancedCameraCardView } from '../../config/schema/common/const';
|
import type { AdvancedCameraCardView } from '../../config/schema/common/const';
|
||||||
import type { ViewDisplayMode } from '../../config/schema/common/display';
|
import type { ViewDisplayMode } from '../../config/schema/common/display';
|
||||||
@@ -31,7 +32,12 @@ export interface ConditionState {
|
|||||||
mediaLoadedInfo?: MediaLoadedInfo | null;
|
mediaLoadedInfo?: MediaLoadedInfo | null;
|
||||||
microphone?: MicrophoneState;
|
microphone?: MicrophoneState;
|
||||||
panel?: boolean;
|
panel?: boolean;
|
||||||
|
|
||||||
|
// Home Assistant:
|
||||||
|
// - The main HA object itself.
|
||||||
hass?: HomeAssistant;
|
hass?: HomeAssistant;
|
||||||
|
// - The card's view of HA's readiness.
|
||||||
|
hassReadiness?: HASSReadiness;
|
||||||
|
|
||||||
// Initialization:
|
// Initialization:
|
||||||
// - Currently initialized.
|
// - Currently initialized.
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
type GetKeyCallback,
|
type GetKeyCallback,
|
||||||
} from '../../utils/concurrency/keyed-subscription-manager';
|
} from '../../utils/concurrency/keyed-subscription-manager';
|
||||||
import { RetryTimer } from '../../utils/retry-timer';
|
import { RetryTimer } from '../../utils/retry-timer';
|
||||||
import { isHassReady } from '../is-hass-ready';
|
|
||||||
import type { HASSSource } from '../source';
|
import type { HASSSource } from '../source';
|
||||||
import type { HomeAssistant } from '../types';
|
import type { HomeAssistant } from '../types';
|
||||||
import type { HASSWebSocketLiveness, HASSWebSocketOpenCallback } from './types';
|
import type { HASSWebSocketLiveness, HASSWebSocketOpenCallback } from './types';
|
||||||
@@ -226,7 +225,7 @@ export class HASSConnectionSubscriptionManager<K, R> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _handleHASSChange(hass: HomeAssistant | null): void {
|
private _handleHASSChange(hass: HomeAssistant | null): void {
|
||||||
if (!hass || !isHassReady(hass)) {
|
if (!hass || !this._source.isReady()) {
|
||||||
if (this._connectionEra !== null) {
|
if (this._connectionEra !== null) {
|
||||||
this._endEra();
|
this._endEra();
|
||||||
// Surface the era end to consumers so they can update any UI that was
|
// Surface the era end to consumers so they can update any UI that was
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -11,5 +11,10 @@ export type HASSListener = (hass: HomeAssistant, oldHass: HomeAssistant | null)
|
|||||||
|
|
||||||
export interface HASSSource {
|
export interface HASSSource {
|
||||||
getHASS(): HomeAssistant | null;
|
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;
|
addListener(listener: HASSListener): UnsubscribeCallback;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,6 +210,14 @@ export class FakeHASS {
|
|||||||
*/
|
*/
|
||||||
public setConnected(connected: boolean): void {
|
public setConnected(connected: boolean): void {
|
||||||
this._connected = connected;
|
this._connected = connected;
|
||||||
|
|
||||||
|
// The real frontend keeps the pre-disconnect `hass.config` until its own
|
||||||
|
// `get_config` resolves after the socket returns. Mint a fresh object
|
||||||
|
// here to models that behavior.
|
||||||
|
if (connected) {
|
||||||
|
this._config = createConfig(this._config.state);
|
||||||
|
}
|
||||||
|
|
||||||
this._renew();
|
this._renew();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,25 @@ import { EventWatcher } from '../../../src/card-controller/hass/event-watcher';
|
|||||||
import { HASSManager } from '../../../src/card-controller/hass/hass-manager';
|
import { HASSManager } from '../../../src/card-controller/hass/hass-manager';
|
||||||
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
|
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
|
||||||
import { InitializationAspect } from '../../../src/card-controller/initialization/initialization-manager';
|
import { InitializationAspect } from '../../../src/card-controller/initialization/initialization-manager';
|
||||||
|
import type { HomeAssistant } from '../../../src/ha/types';
|
||||||
import { createCameraManager, createStore } from '../../camera-manager/test-utils';
|
import { createCameraManager, createStore } from '../../camera-manager/test-utils';
|
||||||
import { createCameraConfig, createConfig } from '../../config/test-utils';
|
import { createCameraConfig, createConfig } from '../../config/test-utils';
|
||||||
import { createCardAPI, createHASS, createStateEntity } from '../../test-utils';
|
import { createCardAPI, createHASS, createStateEntity } from '../../test-utils';
|
||||||
import { createView } from '../../view/test-utils';
|
import { createView } from '../../view/test-utils';
|
||||||
|
|
||||||
|
// The Home Assistant frontend merges each update into the previous `hass`, so
|
||||||
|
// until it has read the configuration again the card keeps being handed the
|
||||||
|
// exact object it already had.
|
||||||
|
const createHASSWithConfigOf = (
|
||||||
|
source: HomeAssistant,
|
||||||
|
options: { connected: boolean },
|
||||||
|
): HomeAssistant => {
|
||||||
|
const hass = createHASS();
|
||||||
|
hass.connected = options.connected;
|
||||||
|
hass.config = source.config;
|
||||||
|
return hass;
|
||||||
|
};
|
||||||
|
|
||||||
describe('HASSManager', () => {
|
describe('HASSManager', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
@@ -98,6 +112,106 @@ describe('HASSManager', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('should report readiness when', () => {
|
||||||
|
it('should report not ready before any hass is set', () => {
|
||||||
|
const manager = new HASSManager(createCardAPI());
|
||||||
|
|
||||||
|
expect(manager.isReady()).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report not ready when disconnected', () => {
|
||||||
|
const manager = new HASSManager(createCardAPI());
|
||||||
|
const hass = createHASS();
|
||||||
|
hass.connected = false;
|
||||||
|
hass.config.state = STATE_RUNNING;
|
||||||
|
manager.setHASS(hass);
|
||||||
|
|
||||||
|
expect(manager.isReady()).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report not ready while integrations are still loading', () => {
|
||||||
|
const manager = new HASSManager(createCardAPI());
|
||||||
|
const hass = createHASS();
|
||||||
|
hass.connected = true;
|
||||||
|
hass.config.state = STATE_STARTING;
|
||||||
|
manager.setHASS(hass);
|
||||||
|
|
||||||
|
expect(manager.isReady()).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report not ready when reconnected with the pre-disconnect config', () => {
|
||||||
|
const manager = new HASSManager(createCardAPI());
|
||||||
|
|
||||||
|
const hass = createHASS();
|
||||||
|
hass.connected = true;
|
||||||
|
hass.config.state = STATE_RUNNING;
|
||||||
|
manager.setHASS(hass);
|
||||||
|
expect(manager.isReady()).toBeTruthy();
|
||||||
|
|
||||||
|
manager.setHASS(createHASSWithConfigOf(hass, { connected: false }));
|
||||||
|
manager.setHASS(createHASSWithConfigOf(hass, { connected: true }));
|
||||||
|
|
||||||
|
expect(manager.isReady()).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report ready when connected and running', () => {
|
||||||
|
const manager = new HASSManager(createCardAPI());
|
||||||
|
const hass = createHASS();
|
||||||
|
hass.connected = true;
|
||||||
|
hass.config.state = STATE_RUNNING;
|
||||||
|
manager.setHASS(hass);
|
||||||
|
|
||||||
|
expect(manager.isReady()).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report disconnected readiness before any hass is set', () => {
|
||||||
|
const manager = new HASSManager(createCardAPI());
|
||||||
|
expect(manager.getReadiness()).toBe('disconnected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report disconnected readiness when disconnected', () => {
|
||||||
|
const manager = new HASSManager(createCardAPI());
|
||||||
|
const hass = createHASS();
|
||||||
|
hass.connected = false;
|
||||||
|
manager.setHASS(hass);
|
||||||
|
|
||||||
|
expect(manager.getReadiness()).toBe('disconnected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report starting readiness while integrations are still loading', () => {
|
||||||
|
const manager = new HASSManager(createCardAPI());
|
||||||
|
const hass = createHASS();
|
||||||
|
hass.connected = true;
|
||||||
|
hass.config.state = STATE_STARTING;
|
||||||
|
manager.setHASS(hass);
|
||||||
|
|
||||||
|
expect(manager.getReadiness()).toBe('starting');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report starting readiness when reconnected with stale config', () => {
|
||||||
|
const manager = new HASSManager(createCardAPI());
|
||||||
|
const hass = createHASS();
|
||||||
|
hass.connected = true;
|
||||||
|
hass.config.state = STATE_RUNNING;
|
||||||
|
manager.setHASS(hass);
|
||||||
|
|
||||||
|
manager.setHASS(createHASSWithConfigOf(hass, { connected: false }));
|
||||||
|
manager.setHASS(createHASSWithConfigOf(hass, { connected: true }));
|
||||||
|
|
||||||
|
expect(manager.getReadiness()).toBe('starting');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report ready readiness when connected and running', () => {
|
||||||
|
const manager = new HASSManager(createCardAPI());
|
||||||
|
const hass = createHASS();
|
||||||
|
hass.connected = true;
|
||||||
|
hass.config.state = STATE_RUNNING;
|
||||||
|
manager.setHASS(hass);
|
||||||
|
|
||||||
|
expect(manager.getReadiness()).toBe('ready');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('should handle connection state change when', () => {
|
describe('should handle connection state change when', () => {
|
||||||
it('should end the session on ready → lost transition', () => {
|
it('should end the session on ready → lost transition', () => {
|
||||||
const api = createCardAPI();
|
const api = createCardAPI();
|
||||||
@@ -267,6 +381,92 @@ describe('HASSManager', () => {
|
|||||||
manager.setHASS(null);
|
manager.setHASS(null);
|
||||||
manager.setHASS(connectedHASS);
|
manager.setHASS(connectedHASS);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should end the session when the connection is lost and the configuration is unchanged', () => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
const manager = new HASSManager(api);
|
||||||
|
|
||||||
|
const readyHASS = createHASS();
|
||||||
|
readyHASS.connected = true;
|
||||||
|
readyHASS.config.state = STATE_RUNNING;
|
||||||
|
manager.setHASS(readyHASS);
|
||||||
|
|
||||||
|
manager.setHASS(createHASSWithConfigOf(readyHASS, { connected: false }));
|
||||||
|
|
||||||
|
expect(api.getInitializationManager().getSessionManager().end).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not reinitialize while a reconnection still reports the pre-disconnection configuration', () => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
const manager = new HASSManager(api);
|
||||||
|
|
||||||
|
const readyHASS = createHASS();
|
||||||
|
readyHASS.connected = true;
|
||||||
|
readyHASS.config.state = STATE_RUNNING;
|
||||||
|
manager.setHASS(readyHASS);
|
||||||
|
|
||||||
|
manager.setHASS(createHASSWithConfigOf(readyHASS, { connected: false }));
|
||||||
|
|
||||||
|
// The socket is back, but Home Assistant has not answered the frontend's
|
||||||
|
// request for its configuration, so the RUNNING it reports is the one
|
||||||
|
// from before the restart.
|
||||||
|
manager.setHASS(createHASSWithConfigOf(readyHASS, { connected: true }));
|
||||||
|
|
||||||
|
expect(manager.isReady()).toBeFalsy();
|
||||||
|
expect(api.getInitializationManager().invalidateAspect).not.toHaveBeenCalled();
|
||||||
|
expect(api.getCameraManager().destroy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not reinitialize when a freshly read configuration reports Home Assistant still starting', () => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
const manager = new HASSManager(api);
|
||||||
|
|
||||||
|
const readyHASS = createHASS();
|
||||||
|
readyHASS.connected = true;
|
||||||
|
readyHASS.config.state = STATE_RUNNING;
|
||||||
|
manager.setHASS(readyHASS);
|
||||||
|
|
||||||
|
manager.setHASS(createHASSWithConfigOf(readyHASS, { connected: false }));
|
||||||
|
manager.setHASS(createHASSWithConfigOf(readyHASS, { connected: true }));
|
||||||
|
|
||||||
|
const startingHASS = createHASS();
|
||||||
|
startingHASS.connected = true;
|
||||||
|
startingHASS.config.state = STATE_STARTING;
|
||||||
|
manager.setHASS(startingHASS);
|
||||||
|
|
||||||
|
expect(manager.isReady()).toBeFalsy();
|
||||||
|
expect(api.getInitializationManager().invalidateAspect).not.toHaveBeenCalled();
|
||||||
|
expect(api.getCameraManager().destroy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reinitialize once after a freshly read configuration reports Home Assistant running', () => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
const manager = new HASSManager(api);
|
||||||
|
|
||||||
|
const readyHASS = createHASS();
|
||||||
|
readyHASS.connected = true;
|
||||||
|
readyHASS.config.state = STATE_RUNNING;
|
||||||
|
manager.setHASS(readyHASS);
|
||||||
|
|
||||||
|
manager.setHASS(createHASSWithConfigOf(readyHASS, { connected: false }));
|
||||||
|
manager.setHASS(createHASSWithConfigOf(readyHASS, { connected: true }));
|
||||||
|
|
||||||
|
const startingHASS = createHASS();
|
||||||
|
startingHASS.connected = true;
|
||||||
|
startingHASS.config.state = STATE_STARTING;
|
||||||
|
manager.setHASS(startingHASS);
|
||||||
|
|
||||||
|
const recoveredHASS = createHASS();
|
||||||
|
recoveredHASS.connected = true;
|
||||||
|
recoveredHASS.config.state = STATE_RUNNING;
|
||||||
|
manager.setHASS(recoveredHASS);
|
||||||
|
|
||||||
|
expect(manager.isReady()).toBeTruthy();
|
||||||
|
expect(api.getCameraManager().destroy).toHaveBeenCalledOnce();
|
||||||
|
expect(api.getInitializationManager().invalidateAspect).toHaveBeenCalledWith(
|
||||||
|
InitializationAspect.CAMERAS,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('should not set default view when', () => {
|
describe('should not set default view when', () => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '../../../src/card-controller/initialization/initialization-manager';
|
} from '../../../src/card-controller/initialization/initialization-manager';
|
||||||
import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager';
|
import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager';
|
||||||
import { sideLoadHomeAssistantElements } from '../../../src/ha/side-load-ha-elements.js';
|
import { sideLoadHomeAssistantElements } from '../../../src/ha/side-load-ha-elements.js';
|
||||||
|
import type { HomeAssistant } from '../../../src/ha/types';
|
||||||
import { loadLanguages } from '../../../src/localize/localize';
|
import { loadLanguages } from '../../../src/localize/localize';
|
||||||
import type { Initializer } from '../../../src/utils/initializer/initializer';
|
import type { Initializer } from '../../../src/utils/initializer/initializer';
|
||||||
import { createConfig } from '../../config/test-utils';
|
import { createConfig } from '../../config/test-utils';
|
||||||
@@ -16,6 +17,15 @@ import { createCardAPI, createHASS } from '../../test-utils';
|
|||||||
vi.mock('../../../src/localize/localize.js');
|
vi.mock('../../../src/localize/localize.js');
|
||||||
vi.mock('../../../src/ha/side-load-ha-elements.js');
|
vi.mock('../../../src/ha/side-load-ha-elements.js');
|
||||||
|
|
||||||
|
const setupHASSMocks = (
|
||||||
|
api: ReturnType<typeof createCardAPI>,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
ready = true,
|
||||||
|
): void => {
|
||||||
|
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
|
||||||
|
vi.mocked(api.getHASSManager().isReady).mockReturnValue(ready);
|
||||||
|
};
|
||||||
|
|
||||||
// An API that passes the whole start predicate, checked both when an attempt is
|
// An API that passes the whole start predicate, checked both when an attempt is
|
||||||
// queued and again when it runs.
|
// queued and again when it runs.
|
||||||
const createReadyAPI = (): ReturnType<typeof createCardAPI> => {
|
const createReadyAPI = (): ReturnType<typeof createCardAPI> => {
|
||||||
@@ -26,7 +36,7 @@ const createReadyAPI = (): ReturnType<typeof createCardAPI> => {
|
|||||||
const hass = createHASS();
|
const hass = createHASS();
|
||||||
hass.connected = true;
|
hass.connected = true;
|
||||||
hass.config.state = STATE_RUNNING;
|
hass.config.state = STATE_RUNNING;
|
||||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
|
setupHASSMocks(api, hass);
|
||||||
vi.mocked(api.getIssueManager().getStateManager().hasFullCardIssue).mockReturnValue(
|
vi.mocked(api.getIssueManager().getStateManager().hasFullCardIssue).mockReturnValue(
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
@@ -78,7 +88,7 @@ describe('InitializationManager', () => {
|
|||||||
it('should handle without config', async () => {
|
it('should handle without config', async () => {
|
||||||
const api = createCardAPI();
|
const api = createCardAPI();
|
||||||
const manager = new InitializationManager(api);
|
const manager = new InitializationManager(api);
|
||||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
|
setupHASSMocks(api, createHASS());
|
||||||
|
|
||||||
await manager.initializeMandatory();
|
await manager.initializeMandatory();
|
||||||
expect(manager.getSessionManager().wasEverInitialized()).toBeFalsy();
|
expect(manager.getSessionManager().wasEverInitialized()).toBeFalsy();
|
||||||
@@ -88,7 +98,7 @@ describe('InitializationManager', () => {
|
|||||||
const api = createReadyAPI();
|
const api = createReadyAPI();
|
||||||
const hass = createHASS();
|
const hass = createHASS();
|
||||||
hass.config.state = STATE_STARTING;
|
hass.config.state = STATE_STARTING;
|
||||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
|
setupHASSMocks(api, hass, false);
|
||||||
|
|
||||||
const initializer = mock<Initializer>();
|
const initializer = mock<Initializer>();
|
||||||
const manager = new InitializationManager(api, initializer);
|
const manager = new InitializationManager(api, initializer);
|
||||||
@@ -709,7 +719,7 @@ describe('InitializationManager', () => {
|
|||||||
const hass = createHASS();
|
const hass = createHASS();
|
||||||
hass.connected = true;
|
hass.connected = true;
|
||||||
hass.config.state = STATE_STARTING;
|
hass.config.state = STATE_STARTING;
|
||||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
|
setupHASSMocks(api, hass, false);
|
||||||
const initializer = mock<Initializer>();
|
const initializer = mock<Initializer>();
|
||||||
const manager = new InitializationManager(api, initializer);
|
const manager = new InitializationManager(api, initializer);
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { STATE_RUNNING, STATE_STARTING } from 'home-assistant-js-websocket';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { ConnectionIssue } from '../../../../src/card-controller/issues/issues/connection';
|
import { ConnectionIssue } from '../../../../src/card-controller/issues/issues/connection';
|
||||||
import { createHASS } from '../../../test-utils';
|
|
||||||
|
|
||||||
describe('ConnectionIssue', () => {
|
describe('ConnectionIssue', () => {
|
||||||
it('should have correct key', () => {
|
it('should have correct key', () => {
|
||||||
@@ -10,7 +8,7 @@ describe('ConnectionIssue', () => {
|
|||||||
expect(issue.key).toBe('connection');
|
expect(issue.key).toBe('connection');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should report no issue when hass has never been set', () => {
|
it('should report no issue when hassReadiness has never been set', () => {
|
||||||
const issue = new ConnectionIssue();
|
const issue = new ConnectionIssue();
|
||||||
|
|
||||||
issue.detectDynamic({});
|
issue.detectDynamic({});
|
||||||
@@ -19,12 +17,10 @@ describe('ConnectionIssue', () => {
|
|||||||
expect(issue.getIssue()).toBeNull();
|
expect(issue.getIssue()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should report a lost issue when hass is disconnected', () => {
|
it('should report a lost issue when disconnected', () => {
|
||||||
const issue = new ConnectionIssue();
|
const issue = new ConnectionIssue();
|
||||||
const hass = createHASS();
|
|
||||||
hass.connected = false;
|
|
||||||
|
|
||||||
issue.detectDynamic({ hass });
|
issue.detectDynamic({ hassReadiness: 'disconnected' });
|
||||||
|
|
||||||
expect(issue.hasIssue()).toBe(true);
|
expect(issue.hasIssue()).toBe(true);
|
||||||
expect(issue.getIssue()).toEqual(
|
expect(issue.getIssue()).toEqual(
|
||||||
@@ -46,13 +42,10 @@ describe('ConnectionIssue', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should report a starting issue when hass is connected but not running', () => {
|
it('should report a starting issue when starting', () => {
|
||||||
const issue = new ConnectionIssue();
|
const issue = new ConnectionIssue();
|
||||||
const hass = createHASS();
|
|
||||||
hass.connected = true;
|
|
||||||
hass.config.state = STATE_STARTING;
|
|
||||||
|
|
||||||
issue.detectDynamic({ hass });
|
issue.detectDynamic({ hassReadiness: 'starting' });
|
||||||
|
|
||||||
expect(issue.hasIssue()).toBe(true);
|
expect(issue.hasIssue()).toBe(true);
|
||||||
expect(issue.getIssue()).toEqual(
|
expect(issue.getIssue()).toEqual(
|
||||||
@@ -74,40 +67,50 @@ describe('ConnectionIssue', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not report an issue when hass is connected and running', () => {
|
it('should not report an issue when ready', () => {
|
||||||
const issue = new ConnectionIssue();
|
const issue = new ConnectionIssue();
|
||||||
const hass = createHASS();
|
|
||||||
hass.connected = true;
|
|
||||||
hass.config.state = STATE_RUNNING;
|
|
||||||
|
|
||||||
issue.detectDynamic({ hass });
|
issue.detectDynamic({ hassReadiness: 'ready' });
|
||||||
|
|
||||||
expect(issue.hasIssue()).toBe(false);
|
expect(issue.hasIssue()).toBe(false);
|
||||||
expect(issue.getIssue()).toBeNull();
|
expect(issue.getIssue()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should clear when hass transitions lost → starting → ready', () => {
|
it('should clear when hass transitions disconnected to starting to ready', () => {
|
||||||
const issue = new ConnectionIssue();
|
const issue = new ConnectionIssue();
|
||||||
const hass = createHASS();
|
|
||||||
|
|
||||||
hass.connected = false;
|
issue.detectDynamic({ hassReadiness: 'disconnected' });
|
||||||
issue.detectDynamic({ hass });
|
|
||||||
expect(issue.hasIssue()).toBe(true);
|
expect(issue.hasIssue()).toBe(true);
|
||||||
expect(issue.getIssue()?.notification.heading?.text).toBe('Connection lost');
|
expect(issue.getIssue()?.notification.heading?.text).toBe('Connection lost');
|
||||||
|
|
||||||
hass.connected = true;
|
issue.detectDynamic({ hassReadiness: 'starting' });
|
||||||
hass.config.state = STATE_STARTING;
|
|
||||||
issue.detectDynamic({ hass });
|
|
||||||
expect(issue.hasIssue()).toBe(true);
|
expect(issue.hasIssue()).toBe(true);
|
||||||
expect(issue.getIssue()?.notification.heading?.text).toBe(
|
expect(issue.getIssue()?.notification.heading?.text).toBe(
|
||||||
'Home Assistant is starting',
|
'Home Assistant is starting',
|
||||||
);
|
);
|
||||||
|
|
||||||
hass.config.state = STATE_RUNNING;
|
issue.detectDynamic({ hassReadiness: 'ready' });
|
||||||
issue.detectDynamic({ hass });
|
|
||||||
expect(issue.hasIssue()).toBe(false);
|
expect(issue.hasIssue()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should report starting when reconnected with stale config', () => {
|
||||||
|
const issue = new ConnectionIssue();
|
||||||
|
|
||||||
|
issue.detectDynamic({ hassReadiness: 'ready' });
|
||||||
|
expect(issue.hasIssue()).toBe(false);
|
||||||
|
|
||||||
|
issue.detectDynamic({ hassReadiness: 'disconnected' });
|
||||||
|
expect(issue.hasIssue()).toBe(true);
|
||||||
|
|
||||||
|
// HASSManager reports 'starting' when the reconnected hass still carries
|
||||||
|
// the pre-disconnect config object.
|
||||||
|
issue.detectDynamic({ hassReadiness: 'starting' });
|
||||||
|
expect(issue.hasIssue()).toBe(true);
|
||||||
|
expect(issue.getIssue()?.notification.heading?.text).toBe(
|
||||||
|
'Home Assistant is starting',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should return true for isFullCardIssue', () => {
|
it('should return true for isFullCardIssue', () => {
|
||||||
const issue = new ConnectionIssue();
|
const issue = new ConnectionIssue();
|
||||||
expect(issue.isFullCardIssue()).toBe(true);
|
expect(issue.isFullCardIssue()).toBe(true);
|
||||||
@@ -115,9 +118,7 @@ describe('ConnectionIssue', () => {
|
|||||||
|
|
||||||
it('should clear the issue after reset', () => {
|
it('should clear the issue after reset', () => {
|
||||||
const issue = new ConnectionIssue();
|
const issue = new ConnectionIssue();
|
||||||
const hass = createHASS();
|
issue.detectDynamic({ hassReadiness: 'disconnected' });
|
||||||
hass.connected = false;
|
|
||||||
issue.detectDynamic({ hass });
|
|
||||||
expect(issue.hasIssue()).toBe(true);
|
expect(issue.hasIssue()).toBe(true);
|
||||||
|
|
||||||
issue.reset();
|
issue.reset();
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { STATE_RUNNING } from 'home-assistant-js-websocket';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { isHassReady } from '../../src/ha/is-hass-ready';
|
|
||||||
import { createHASS } from '../test-utils';
|
|
||||||
|
|
||||||
describe('isHassReady', () => {
|
|
||||||
it('should return false for null', () => {
|
|
||||||
expect(isHassReady(null)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for undefined', () => {
|
|
||||||
expect(isHassReady(undefined)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when disconnected', () => {
|
|
||||||
const hass = createHASS();
|
|
||||||
hass.connected = false;
|
|
||||||
expect(isHassReady(hass)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when integrations are still loading', () => {
|
|
||||||
const hass = createHASS();
|
|
||||||
hass.connected = true;
|
|
||||||
hass.config.state = 'NOT_RUNNING';
|
|
||||||
expect(isHassReady(hass)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true when connected and running', () => {
|
|
||||||
const hass = createHASS();
|
|
||||||
hass.connected = true;
|
|
||||||
hass.config.state = STATE_RUNNING;
|
|
||||||
expect(isHassReady(hass)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+4
-3
@@ -109,6 +109,7 @@ export const createHASSSource = (
|
|||||||
const listeners = new Set<HASSListener>();
|
const listeners = new Set<HASSListener>();
|
||||||
const source: HASSSource = {
|
const source: HASSSource = {
|
||||||
getHASS: () => current,
|
getHASS: () => current,
|
||||||
|
isReady: () => !!current?.connected && current.config?.state === STATE_RUNNING,
|
||||||
addListener: (listener) => {
|
addListener: (listener) => {
|
||||||
listeners.add(listener);
|
listeners.add(listener);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -135,9 +136,9 @@ export const createHASSManager = (options?: {
|
|||||||
eventWatcher?: EventWatcherSubscriptionInterface;
|
eventWatcher?: EventWatcherSubscriptionInterface;
|
||||||
}): HASSManagerReadonlyInterface => {
|
}): HASSManagerReadonlyInterface => {
|
||||||
const hassManager = mock<HASSManagerReadonlyInterface>();
|
const hassManager = mock<HASSManagerReadonlyInterface>();
|
||||||
hassManager.getHASS.mockReturnValue(
|
const hass = options?.hass === undefined ? createHASS() : options.hass;
|
||||||
options?.hass === undefined ? createHASS() : options.hass,
|
hassManager.getHASS.mockReturnValue(hass);
|
||||||
);
|
hassManager.isReady.mockReturnValue(!!hass);
|
||||||
hassManager.getStateWatcher.mockReturnValue(
|
hassManager.getStateWatcher.mockReturnValue(
|
||||||
options?.stateWatcher ?? mock<StateWatcherSubscriptionInterface>(),
|
options?.stateWatcher ?? mock<StateWatcherSubscriptionInterface>(),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user