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.
302 lines
9.6 KiB
TypeScript
302 lines
9.6 KiB
TypeScript
import {
|
|
STATE_RUNNING,
|
|
type Connection,
|
|
type HassConfig,
|
|
type HassEntities,
|
|
type HassEntity,
|
|
type MessageBase,
|
|
} from 'home-assistant-js-websocket';
|
|
import { mock } from 'vitest-mock-extended';
|
|
|
|
import type { Entity } from '../../src/ha/registry/entity/types';
|
|
import type { HomeAssistant } from '../../src/ha/types';
|
|
import { createRegistryEntity, createStateEntity } from '../test-utils';
|
|
|
|
// A WebSocket command handler. Returning a rejected promise models a command
|
|
// Home Assistant refuses; throwing models a malformed request.
|
|
export type WSCommandHandler = (message: MessageBase) => Promise<unknown> | unknown;
|
|
|
|
export interface FakeEntityOptions {
|
|
state?: string;
|
|
attributes?: Record<string, unknown>;
|
|
lastChanged?: Date;
|
|
lastUpdated?: Date;
|
|
}
|
|
|
|
export interface FakeHASSOptions {
|
|
// Entities present from the start, as entity ID to state options (or a
|
|
// bare state string).
|
|
entities?: Record<string, FakeEntityOptions | string>;
|
|
|
|
// Entity registry entries, as entity ID to a partial registry entry.
|
|
registry?: Record<string, Partial<Entity>>;
|
|
|
|
language?: string;
|
|
isAdmin?: boolean;
|
|
}
|
|
|
|
// Home Assistant sends real timestamps, and the card measures a state's age
|
|
// against them: must use a dynamic (vs static) date.
|
|
const createDefaultTime = (): Date => new Date();
|
|
|
|
// `Auth` is a class built from real credentials, and `locale` is typed with
|
|
// ambient enums that have no runtime value, so neither can be constructed here.
|
|
// The card reads neither.
|
|
const { auth: INERT_AUTH, locale: INERT_LOCALE } = mock<HomeAssistant>();
|
|
|
|
const createConfig = (state: HassConfig['state']): HassConfig => ({
|
|
latitude: 0,
|
|
longitude: 0,
|
|
elevation: 0,
|
|
radius: 0,
|
|
unit_system: {
|
|
length: 'km',
|
|
mass: 'kg',
|
|
volume: 'L',
|
|
temperature: '°C',
|
|
pressure: 'Pa',
|
|
wind_speed: 'km/h',
|
|
accumulated_precipitation: 'mm',
|
|
},
|
|
location_name: 'Fake',
|
|
time_zone: 'UTC',
|
|
components: [],
|
|
config_dir: '/config',
|
|
allowlist_external_dirs: [],
|
|
allowlist_external_urls: [],
|
|
version: '2026.7.0',
|
|
config_source: 'storage',
|
|
recovery_mode: false,
|
|
safe_mode: false,
|
|
state,
|
|
external_url: null,
|
|
internal_url: null,
|
|
currency: 'USD',
|
|
country: null,
|
|
language: 'en',
|
|
});
|
|
|
|
const createRegistryEntry = (entityID: string, overrides?: Partial<Entity>): Entity =>
|
|
createRegistryEntity({ entity_id: entityID, unique_id: entityID, ...overrides });
|
|
|
|
/**
|
|
* A driveable stand-in for the `hass` object the card is handed by Home
|
|
* Assistant.
|
|
*
|
|
* Home Assistant updates immutably, and that is the behaviour reproduced here:
|
|
* a change hands consumers a new top-level object and a new `states` map
|
|
* holding a new state object for the entity that changed, while every other
|
|
* entity keeps the object it already had. Anything the card can observe about
|
|
* a real `hass` it must be able to observe about this one, so a divergence here
|
|
* is a bug in the fake rather than a shortcut worth taking.
|
|
*/
|
|
export class FakeHASS {
|
|
private _hass: HomeAssistant;
|
|
private _states: HassEntities = {};
|
|
private _registry = new Map<string, Entity>();
|
|
private _config: HassConfig = createConfig(STATE_RUNNING);
|
|
private _connected = true;
|
|
private _connection: Connection;
|
|
private _language: string;
|
|
private _isAdmin: boolean;
|
|
private _handlers = new Map<string, WSCommandHandler>();
|
|
private _commandLog: MessageBase[] = [];
|
|
|
|
constructor(options?: FakeHASSOptions) {
|
|
this._language = options?.language ?? 'en';
|
|
this._isAdmin = options?.isAdmin ?? true;
|
|
|
|
for (const [entityID, entityOptions] of Object.entries(options?.entities ?? {})) {
|
|
this._states[entityID] = this._createEntity(entityID, entityOptions);
|
|
}
|
|
for (const [entityID, entry] of Object.entries(options?.registry ?? {})) {
|
|
this._registry.set(entityID, createRegistryEntry(entityID, entry));
|
|
}
|
|
|
|
this._connection = this._createConnection();
|
|
this._registerDefaultHandlers();
|
|
this._hass = this._createHASS();
|
|
}
|
|
|
|
public getHASS(): HomeAssistant {
|
|
return this._hass;
|
|
}
|
|
|
|
/**
|
|
* Register a handler for a WebSocket command, replacing any existing one.
|
|
*/
|
|
public registerCommand(type: string, handler: WSCommandHandler): void {
|
|
this._handlers.set(type, handler);
|
|
}
|
|
|
|
/**
|
|
* Every WebSocket command the card has issued, in order.
|
|
*/
|
|
public getCommandLog(): readonly MessageBase[] {
|
|
return this._commandLog;
|
|
}
|
|
|
|
/**
|
|
* Change one entity, then renew. The changed entity gets a new state object;
|
|
* every other entity keeps the one it already had.
|
|
*/
|
|
public setState(entityID: string, options: FakeEntityOptions | string): void {
|
|
this._states = {
|
|
...this._states,
|
|
[entityID]: this._createEntity(entityID, options),
|
|
};
|
|
this._renew();
|
|
}
|
|
|
|
/**
|
|
* Remove an entity from the state map, as happens when its integration is
|
|
* unloaded.
|
|
*/
|
|
public removeState(entityID: string): void {
|
|
const states = { ...this._states };
|
|
delete states[entityID];
|
|
this._states = states;
|
|
this._renew();
|
|
}
|
|
|
|
/**
|
|
* Replace the `hass` object without changing any entity, as Home Assistant
|
|
* does constantly. Every entity keeps its identity, so a card that re-does
|
|
* work after this is reacting to the object rather than to what is in it.
|
|
*/
|
|
public renew(): void {
|
|
this._renew();
|
|
}
|
|
|
|
/**
|
|
* Drop or restore the WebSocket, as a Home Assistant restart does. Home
|
|
* Assistant keeps handing the card a `hass` while it is disconnected, which
|
|
* is why this renews rather than going silent.
|
|
*/
|
|
public setConnected(connected: boolean): void {
|
|
this._connected = connected;
|
|
this._renew();
|
|
}
|
|
|
|
private _createEntity(
|
|
entityID: string,
|
|
options: FakeEntityOptions | string,
|
|
): HassEntity {
|
|
const resolved: FakeEntityOptions =
|
|
typeof options === 'string' ? { state: options } : options;
|
|
|
|
return createStateEntity({
|
|
entity_id: entityID,
|
|
state: resolved.state ?? 'unknown',
|
|
attributes: resolved.attributes ?? {},
|
|
last_changed: (resolved.lastChanged ?? createDefaultTime()).toISOString(),
|
|
last_updated: (resolved.lastUpdated ?? createDefaultTime()).toISOString(),
|
|
context: { id: entityID, user_id: null, parent_id: null },
|
|
});
|
|
}
|
|
|
|
private _renew(): void {
|
|
this._hass = this._createHASS();
|
|
}
|
|
|
|
private _createConnection(): Connection {
|
|
const connection = mock<Connection>();
|
|
connection.subscribeMessage.mockResolvedValue(() => Promise.resolve());
|
|
connection.subscribeEvents.mockResolvedValue(() => Promise.resolve());
|
|
|
|
// `callWS` and `sendMessagePromise` are the same request/response channel,
|
|
// so both go through one handler table. Given two tables, a command
|
|
// registered by a test would be silently ignored for any caller that
|
|
// reached the connection directly.
|
|
connection.sendMessagePromise.mockImplementation((message) => this._callWS(message));
|
|
return connection;
|
|
}
|
|
|
|
private _registerDefaultHandlers(): void {
|
|
this.registerCommand('config/entity_registry/get', (message) => {
|
|
const entry = this._registry.get(String(message.entity_id));
|
|
if (!entry) {
|
|
return Promise.reject(
|
|
new Error(`Entity not found in the fake registry: ${message.entity_id}`),
|
|
);
|
|
}
|
|
return entry;
|
|
});
|
|
this.registerCommand('config/entity_registry/list', () => [
|
|
...this._registry.values(),
|
|
]);
|
|
this.registerCommand('lovelace/resources', () => []);
|
|
|
|
// Not a card command: `ha-nunjucks` fetches the label registry once when a
|
|
// template first renders.
|
|
this.registerCommand('config/label_registry/list', () => []);
|
|
}
|
|
|
|
private async _callWS<T>(message: MessageBase): Promise<T> {
|
|
this._commandLog.push(message);
|
|
const handler = this._handlers.get(message.type);
|
|
if (!handler) {
|
|
// Loudly, because a card that starts issuing a new command must not
|
|
// appear to work against a fake that knows nothing about it.
|
|
throw new Error(`FakeHASS received an unregistered WS command: ${message.type}`);
|
|
}
|
|
|
|
// A WebSocket response is untyped on the wire, and the caller's generic is
|
|
// its claim about the shape. Callers parse what they get with Zod, so a
|
|
// handler returning the wrong shape surfaces there.
|
|
return (await handler(message)) as unknown as T;
|
|
}
|
|
|
|
private _unsupported(name: string): () => never {
|
|
return () => {
|
|
throw new Error(`FakeHASS does not implement ${name}`);
|
|
};
|
|
}
|
|
|
|
private _createHASS(): HomeAssistant {
|
|
return {
|
|
auth: INERT_AUTH,
|
|
locale: INERT_LOCALE,
|
|
|
|
states: this._states,
|
|
config: this._config,
|
|
connected: this._connected,
|
|
connection: this._connection,
|
|
language: this._language,
|
|
selectedLanguage: null,
|
|
selectedTheme: null,
|
|
resources: {},
|
|
services: {},
|
|
themes: {
|
|
default_theme: 'default',
|
|
themes: {},
|
|
},
|
|
panels: {},
|
|
panelUrl: 'lovelace',
|
|
translationMetadata: { fragments: [], translations: {} },
|
|
dockedSidebar: false,
|
|
moreInfoEntityId: '',
|
|
user: {
|
|
id: 'fake-user',
|
|
is_owner: this._isAdmin,
|
|
is_admin: this._isAdmin,
|
|
name: 'Fake User',
|
|
credentials: [],
|
|
mfa_modules: [],
|
|
},
|
|
|
|
callWS: (message) => this._callWS(message),
|
|
hassUrl: (path?: string) => new URL(path ?? '/', window.location.href).href,
|
|
localize: (key: string) => key,
|
|
|
|
// Everything the card can call has to either work or fail loudly. A
|
|
// method that quietly returns nothing would let a card start depending on
|
|
// it without any test noticing.
|
|
callService: this._unsupported('callService'),
|
|
callApi: this._unsupported('callApi'),
|
|
fetchWithAuth: this._unsupported('fetchWithAuth'),
|
|
sendWS: this._unsupported('sendWS'),
|
|
};
|
|
}
|
|
}
|