import type { ResolvedDevicePresentation } from './device-presentation'; import type { DevItem } from './types'; /** Immutable render-only projection of one HA tick. */ export interface RenderDeviceSnapshot { readonly sourceSequence: number; readonly capturedAt: number; /** Minimal function-free HA-compatible projection used by pure resolvers. */ readonly hass: Readonly<{ states: Readonly>; entities: Readonly>; devices: Readonly>; config?: any; locale?: any; themes?: any; }>; readonly devices: readonly DevItem[]; readonly positions: ReadonlyMap>; /** Two variants per device: `${id}:1` with LQI and `${id}:0` without it. */ readonly presentations: ReadonlyMap; /** Extra resolved render facts (for example a vacuum puck/trail frame). */ readonly facts: ReadonlyMap; } /** * Resolve device positions only when a plan model exists to own them. * * A snapshot without a renderable plan still carries its immutable HA facts, * but there is no geometry against which a position can be resolved. Keeping * the map empty also preserves the stricter contract of the geometry helpers: * they continue to require a real SpaceModel. */ export function renderDeviceSnapshotPositions( hasRenderablePlan: boolean, devices: readonly DevItem[], resolve: (device: DevItem) => { x: number; y: number }, ): ReadonlyMap { if (!hasRenderablePlan) return new Map(); return new Map(devices.map((device) => [device.id, resolve(device)])); } function cloneFact(value: T, stack = new WeakMap()): T { if (value === null || typeof value !== 'object') return value; const object = value as unknown as object; const found = stack.get(object); if (found) return found as T; if (Array.isArray(value)) { const out: unknown[] = []; stack.set(object, out); for (const item of value) out.push(cloneFact(item, stack)); return Object.freeze(out) as T; } const out: Record = {}; stack.set(object, out); for (const [key, item] of Object.entries(value as Record)) { // Registry/state facts are data. Functions belong to live HA and never // cross the render-snapshot boundary. if (typeof item !== 'function') out[key] = cloneFact(item, stack); } return Object.freeze(out) as T; } /** Runtime-immutable ReadonlyMap facade: Object.freeze(new Map()) still allows set(). */ function readonlyMap(entries: Iterable): ReadonlyMap { const source = new Map(entries); let view: ReadonlyMap; view = Object.freeze({ get size() { return source.size; }, get: (key: K) => source.get(key), has: (key: K) => source.has(key), entries: () => source.entries(), keys: () => source.keys(), values: () => source.values(), forEach: (callback: (value: V, key: K, map: ReadonlyMap) => void, thisArg?: unknown) => source.forEach((value, key) => callback.call(thisArg, value, key, view)), [Symbol.iterator]: () => source[Symbol.iterator](), }); return view; } export function createRenderDeviceSnapshot(options: { sourceSequence: number; hass: any; devices: readonly DevItem[]; presentations: ReadonlyMap; positions?: ReadonlyMap; facts?: ReadonlyMap; entityIds?: Iterable; deviceIds?: Iterable; areaIds?: Iterable; capturedAt?: number; }): RenderDeviceSnapshot { // Copy only the registry/state rows that can contribute to this plan. This // is a render-data projection, not a frozen clone of Home Assistant runtime. const entityIds = new Set(options.entityIds || []); const deviceIds = new Set(options.deviceIds || []); const areaIds = new Set(options.areaIds || []); for (const device of options.devices) { for (const entityId of device.entities || []) entityIds.add(entityId); if (device.primary) entityIds.add(device.primary); for (const entityId of device.controls || []) entityIds.add(entityId); if (device.marker?.vacuum?.source) entityIds.add(device.marker.vacuum.source); if (device.bindingKind === 'device' && device.bindingRef) deviceIds.add(device.bindingRef); if (device.bindingKind === 'entity' && device.bindingRef) entityIds.add(device.bindingRef); } const projectedEntities: Record = {}; const projectedDevices: Record = {}; for (const [entityId, registry] of Object.entries(options.hass?.entities || {})) { const device = registry?.device_id ? options.hass?.devices?.[registry.device_id] : null; if (entityIds.has(entityId) || (registry?.device_id && deviceIds.has(registry.device_id)) || areaIds.has(registry?.area_id) || areaIds.has(device?.area_id)) { entityIds.add(entityId); projectedEntities[entityId] = registry; if (registry?.device_id) deviceIds.add(registry.device_id); } } for (const deviceId of deviceIds) { const device = options.hass?.devices?.[deviceId]; if (device) projectedDevices[deviceId] = device; } const projectedStates: Record = {}; for (const entityId of entityIds) { const state = options.hass?.states?.[entityId]; if (state) projectedStates[entityId] = state; } const hass = Object.freeze({ states: cloneFact(projectedStates), entities: cloneFact(projectedEntities), devices: cloneFact(projectedDevices), config: cloneFact(options.hass?.config), locale: cloneFact(options.hass?.locale), themes: cloneFact(options.hass?.themes), }); return Object.freeze({ sourceSequence: options.sourceSequence, capturedAt: options.capturedAt ?? Date.now(), hass, devices: cloneFact([...options.devices]), positions: readonlyMap([...(options.positions || [])].map(([id, point]) => [id, Object.freeze({ x: point.x, y: point.y })] as const)), presentations: readonlyMap([...options.presentations].map(([key, presentation]) => [key, cloneFact(presentation)] as const)), facts: readonlyMap([...(options.facts || [])].map(([key, fact]) => [key, cloneFact(fact)] as const)), }); } export const presentationSnapshotKey = (deviceId: string, showLqi: boolean): string => `${deviceId}:${showLqi ? 1 : 0}`;