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; export interface FakeEntityOptions { state?: string; attributes?: Record; 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; // Entity registry entries, as entity ID to a partial registry entry. registry?: Record>; 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(); 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 => 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(); private _config: HassConfig = createConfig(STATE_RUNNING); private _connected = true; private _connection: Connection; private _language: string; private _isAdmin: boolean; private _handlers = new Map(); 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(); } 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.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(message: MessageBase): Promise { 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'), }; } }