mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
562 lines
22 KiB
TypeScript
562 lines
22 KiB
TypeScript
/**
|
|
* Home Assistant registry authority for plan bindings.
|
|
*
|
|
* `hass.entities` is not a reliable source for disabled entries on every HA
|
|
* frontend/version/account. This module owns one full registry fetch and one
|
|
* pair of update subscriptions per HA connection, shared by every House Plan
|
|
* card on the page. Consumers receive an explicit status; missing data is
|
|
* never guessed to mean disabled.
|
|
*/
|
|
|
|
export type HaDisabledReason = 'device' | 'entity' | 'all_entities';
|
|
|
|
export type HaBindingStatus =
|
|
| { kind: 'active'; enabledEntityIds: string[]; allEntityIds: string[] }
|
|
| { kind: 'ha_disabled'; reason: HaDisabledReason; enabledEntityIds: []; allEntityIds: string[] }
|
|
| { kind: 'orphaned'; reason: 'device_missing' | 'entity_missing'; enabledEntityIds: []; allEntityIds: string[] }
|
|
| { kind: 'unverified'; reason: 'registry_unavailable'; enabledEntityIds: []; allEntityIds: string[] };
|
|
|
|
export interface HaRegistrySnapshot {
|
|
/** Increases only when the shared registry picture/access status changes. */
|
|
revision: number;
|
|
/** Both full registry commands succeeded. */
|
|
authoritative: boolean;
|
|
access: 'pending' | 'full' | 'limited';
|
|
devices: Record<string, any>;
|
|
entities: Record<string, any>;
|
|
lastSuccess: number;
|
|
error?: string;
|
|
}
|
|
|
|
interface RegistryCacheEntry {
|
|
revision: number;
|
|
authoritative: boolean;
|
|
access: HaRegistrySnapshot['access'];
|
|
devices: Record<string, any>;
|
|
entities: Record<string, any>;
|
|
lastSuccess: number;
|
|
error?: string;
|
|
loading?: Promise<void>;
|
|
listeners: Set<() => void>;
|
|
refs: number;
|
|
reloadTimer?: number;
|
|
unsubDevice?: () => void;
|
|
unsubEntity?: () => void;
|
|
subscribing?: Promise<void>;
|
|
/** Last live frontend registry projection observed beside a full snapshot. */
|
|
liveDevices?: Record<string, any>;
|
|
liveEntities?: Record<string, any>;
|
|
/** Authoritative rows plus newly observed live rows, memoized per identity. */
|
|
projectedRevision?: number;
|
|
projectedDevices?: Record<string, any>;
|
|
projectedEntities?: Record<string, any>;
|
|
}
|
|
|
|
const registryCaches = new WeakMap<object, RegistryCacheEntry>();
|
|
const registryObjectIds = new WeakMap<object, number>();
|
|
let nextRegistryObjectId = 1;
|
|
const BINDING_STATUS_LS = 'houseplan.ha-binding-status.v1';
|
|
const BINDING_STATUS_MAX = 1500;
|
|
const BINDING_STATUS_TTL_MS = 90 * 24 * 60 * 60 * 1000;
|
|
|
|
interface CachedBindingStatus {
|
|
kind: 'active' | 'ha_disabled';
|
|
reason?: HaDisabledReason;
|
|
ts: number;
|
|
}
|
|
|
|
let bindingStatusMemo: Record<string, CachedBindingStatus> | null = null;
|
|
|
|
function connectionKey(hass: any): object | null {
|
|
const key = hass?.connection || hass;
|
|
return key && (typeof key === 'object' || typeof key === 'function') ? key : null;
|
|
}
|
|
|
|
function newEntry(): RegistryCacheEntry {
|
|
return {
|
|
revision: 0,
|
|
authoritative: false,
|
|
access: 'pending',
|
|
devices: {},
|
|
entities: {},
|
|
lastSuccess: 0,
|
|
listeners: new Set(),
|
|
refs: 0,
|
|
};
|
|
}
|
|
|
|
function entryOf(hass: any): RegistryCacheEntry | null {
|
|
const key = connectionKey(hass);
|
|
if (!key) return null;
|
|
let entry = registryCaches.get(key);
|
|
if (!entry) {
|
|
entry = newEntry();
|
|
registryCaches.set(key, entry);
|
|
}
|
|
return entry;
|
|
}
|
|
|
|
function recordsById(rows: unknown, idKey: 'id' | 'entity_id'): Record<string, any> | null {
|
|
const list = Array.isArray(rows)
|
|
? rows
|
|
: rows && typeof rows === 'object' && Array.isArray((rows as any).entries)
|
|
? (rows as any).entries
|
|
: null;
|
|
if (!list) return null;
|
|
const out: Record<string, any> = {};
|
|
for (const row of list) {
|
|
const id = row?.[idKey];
|
|
if (typeof id === 'string' && id) out[id] = row;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function errorText(error: unknown): string {
|
|
if (error && typeof error === 'object') {
|
|
const e = error as any;
|
|
return String(e.message || e.code || e.error || 'registry_unavailable');
|
|
}
|
|
return String(error || 'registry_unavailable');
|
|
}
|
|
|
|
function notify(entry: RegistryCacheEntry): void {
|
|
for (const listener of [...entry.listeners]) {
|
|
try { listener(); } catch { /* one card cannot break the shared cache */ }
|
|
}
|
|
}
|
|
|
|
async function loadFullRegistries(hass: any, entry: RegistryCacheEntry): Promise<void> {
|
|
if (entry.loading || !hass?.callWS) return entry.loading;
|
|
entry.loading = (async () => {
|
|
try {
|
|
const [deviceRows, entityRows] = await Promise.all([
|
|
hass.callWS({ type: 'config/device_registry/list' }),
|
|
hass.callWS({ type: 'config/entity_registry/list' }),
|
|
]);
|
|
const devices = recordsById(deviceRows, 'id');
|
|
const entities = recordsById(entityRows, 'entity_id');
|
|
if (!devices || !entities) throw new Error('invalid_registry_response');
|
|
entry.devices = devices;
|
|
entry.entities = entities;
|
|
entry.authoritative = true;
|
|
entry.access = 'full';
|
|
entry.lastSuccess = Date.now();
|
|
entry.error = undefined;
|
|
} catch (error) {
|
|
// Keep the last successful full picture. Access becomes limited so new
|
|
// decisions use current frontend/state evidence, while cached disabled
|
|
// bindings remain safely hidden until a successful refresh.
|
|
entry.authoritative = false;
|
|
entry.access = 'limited';
|
|
entry.error = errorText(error);
|
|
} finally {
|
|
entry.revision++;
|
|
entry.loading = undefined;
|
|
notify(entry);
|
|
}
|
|
})();
|
|
return entry.loading;
|
|
}
|
|
|
|
function scheduleReload(hass: any, entry: RegistryCacheEntry): void {
|
|
if (entry.reloadTimer !== undefined) return;
|
|
entry.reloadTimer = globalThis.setTimeout(() => {
|
|
entry.reloadTimer = undefined;
|
|
void loadFullRegistries(hass, entry);
|
|
}, 80);
|
|
}
|
|
|
|
async function ensureSubscriptions(hass: any, entry: RegistryCacheEntry): Promise<void> {
|
|
if (entry.subscribing || (entry.unsubDevice && entry.unsubEntity)) return entry.subscribing;
|
|
const subscribe = hass?.connection?.subscribeEvents;
|
|
if (typeof subscribe !== 'function') return;
|
|
entry.subscribing = (async () => {
|
|
try {
|
|
if (!entry.unsubDevice) {
|
|
entry.unsubDevice = await subscribe.call(
|
|
hass.connection,
|
|
() => scheduleReload(hass, entry),
|
|
'device_registry_updated',
|
|
);
|
|
}
|
|
if (!entry.unsubEntity) {
|
|
entry.unsubEntity = await subscribe.call(
|
|
hass.connection,
|
|
() => scheduleReload(hass, entry),
|
|
'entity_registry_updated',
|
|
);
|
|
}
|
|
} catch {
|
|
// Some limited accounts cannot subscribe to registry events. Their hass
|
|
// projection remains reactive; a connection-ready refresh retries full
|
|
// access without turning this into a per-state-tick fetch.
|
|
} finally {
|
|
if (entry.refs === 0) {
|
|
entry.unsubDevice?.();
|
|
entry.unsubEntity?.();
|
|
entry.unsubDevice = undefined;
|
|
entry.unsubEntity = undefined;
|
|
}
|
|
entry.subscribing = undefined;
|
|
}
|
|
})();
|
|
return entry.subscribing;
|
|
}
|
|
|
|
/** Start sharing the page-level registry authority. Returns a release hook. */
|
|
export function acquireHaRegistries(hass: any, listener: () => void): () => void {
|
|
const entry = entryOf(hass);
|
|
if (!entry) return () => undefined;
|
|
const wasUnused = entry.refs === 0;
|
|
entry.refs++;
|
|
entry.listeners.add(listener);
|
|
// The first card owns the page fetch. Additional full/static cards only
|
|
// attach listeners; a remount after the last release refreshes stale data.
|
|
if (entry.access === 'pending' || wasUnused) void loadFullRegistries(hass, entry);
|
|
void ensureSubscriptions(hass, entry);
|
|
let released = false;
|
|
return () => {
|
|
if (released) return;
|
|
released = true;
|
|
entry.listeners.delete(listener);
|
|
entry.refs = Math.max(0, entry.refs - 1);
|
|
if (entry.refs > 0) return;
|
|
entry.unsubDevice?.();
|
|
entry.unsubEntity?.();
|
|
entry.unsubDevice = undefined;
|
|
entry.unsubEntity = undefined;
|
|
if (entry.reloadTimer !== undefined) globalThis.clearTimeout(entry.reloadTimer);
|
|
entry.reloadTimer = undefined;
|
|
};
|
|
}
|
|
|
|
/** Retry after a websocket reconnect or an account/permission change. */
|
|
export function refreshHaRegistries(hass: any): void {
|
|
const entry = entryOf(hass);
|
|
if (!entry) return;
|
|
scheduleReload(hass, entry);
|
|
void ensureSubscriptions(hass, entry);
|
|
}
|
|
|
|
/** Current shared picture; limited mode follows the live hass projection. */
|
|
export function haRegistrySnapshot(hass: any): HaRegistrySnapshot {
|
|
const entry = entryOf(hass);
|
|
if (!entry) {
|
|
return {
|
|
revision: 0,
|
|
authoritative: false,
|
|
access: 'limited',
|
|
devices: hass?.devices || {},
|
|
entities: hass?.entities || {},
|
|
lastSuccess: 0,
|
|
error: 'registry_unavailable',
|
|
};
|
|
}
|
|
let devices = hass?.devices || {};
|
|
let entities = hass?.entities || {};
|
|
if (entry.authoritative) {
|
|
const projectionChanged = entry.liveDevices !== devices || entry.liveEntities !== entities;
|
|
const hadProjection = entry.liveDevices !== undefined || entry.liveEntities !== undefined;
|
|
if (projectionChanged) {
|
|
entry.liveDevices = devices;
|
|
entry.liveEntities = entities;
|
|
entry.projectedRevision = undefined;
|
|
// Registry subscriptions are not guaranteed for every HA account/version.
|
|
// A changed live projection is therefore also a cheap retry signal. The
|
|
// additive projection below makes newly discovered rows usable at once;
|
|
// the debounced full reload then reconciles disabled_by flips/removals.
|
|
if (hadProjection) scheduleReload(hass, entry);
|
|
}
|
|
if (entry.projectedRevision !== entry.revision
|
|
|| !entry.projectedDevices || !entry.projectedEntities) {
|
|
const projectedDevices = { ...entry.devices };
|
|
const projectedEntities = { ...entry.entities };
|
|
for (const [id, row] of Object.entries<any>(devices)) {
|
|
if (!Object.prototype.hasOwnProperty.call(projectedDevices, id)) projectedDevices[id] = row;
|
|
}
|
|
for (const [id, row] of Object.entries<any>(entities)) {
|
|
if (!Object.prototype.hasOwnProperty.call(projectedEntities, id)) projectedEntities[id] = row;
|
|
}
|
|
entry.projectedDevices = projectedDevices;
|
|
entry.projectedEntities = projectedEntities;
|
|
entry.projectedRevision = entry.revision;
|
|
}
|
|
devices = entry.projectedDevices;
|
|
entities = entry.projectedEntities;
|
|
}
|
|
return {
|
|
revision: entry.revision,
|
|
authoritative: entry.authoritative,
|
|
access: entry.access,
|
|
devices,
|
|
entities,
|
|
lastSuccess: entry.lastSuccess,
|
|
error: entry.error,
|
|
};
|
|
}
|
|
|
|
function objectIdentity(value: any): number {
|
|
if (!value || (typeof value !== 'object' && typeof value !== 'function')) return 0;
|
|
let id = registryObjectIds.get(value);
|
|
if (!id) {
|
|
id = nextRegistryObjectId++;
|
|
registryObjectIds.set(value, id);
|
|
}
|
|
return id;
|
|
}
|
|
|
|
/** O(1) rebuild key on normal state ticks; registry updates replace identity/revision. */
|
|
export function haRegistryBuildSignature(hass: any, snapshot = haRegistrySnapshot(hass)): string {
|
|
return [
|
|
snapshot.revision,
|
|
snapshot.access,
|
|
objectIdentity(snapshot.devices),
|
|
objectIdentity(snapshot.entities),
|
|
].join(':');
|
|
}
|
|
|
|
export function isRegistryEntryEnabled(entry: any): boolean {
|
|
return !!entry && entry.disabled_by == null;
|
|
}
|
|
|
|
/** Hass-shaped projection containing registry metadata but only active rows. */
|
|
export function activeRegistryHass(hass: any, snapshot = haRegistrySnapshot(hass)): any {
|
|
const devices: Record<string, any> = {};
|
|
const entities: Record<string, any> = {};
|
|
const states: Record<string, any> = {};
|
|
for (const [id, device] of Object.entries<any>(snapshot.devices || {})) {
|
|
if (isRegistryEntryEnabled(device)) devices[id] = device;
|
|
}
|
|
for (const [eid, entity] of Object.entries<any>(snapshot.entities || {})) {
|
|
if (!isRegistryEntryEnabled(entity)) continue;
|
|
// In limited mode an absent parent row is not evidence that the parent is
|
|
// disabled: HA may expose the entity/state but omit the device registry.
|
|
// Only an explicitly present disabled parent may suppress the entity.
|
|
const parent = entity.device_id ? snapshot.devices?.[entity.device_id] : null;
|
|
if (snapshot.authoritative && entity.device_id && !parent) continue;
|
|
if (parent && !isRegistryEntryEnabled(parent)) continue;
|
|
entities[eid] = entity;
|
|
}
|
|
for (const [eid, state] of Object.entries<any>(hass?.states || {})) {
|
|
const entity = snapshot.entities?.[eid];
|
|
// A complete Entity Registry is not a complete list of HA states: YAML
|
|
// entities without unique_id and core runtime entities such as sun.sun can
|
|
// legitimately have a live state without any registry row. Absence is
|
|
// therefore not evidence of disablement. Explicit disabled entity/device
|
|
// rows below remain authoritative and are still removed.
|
|
if (entity && !isRegistryEntryEnabled(entity)) continue;
|
|
const parent = entity?.device_id ? snapshot.devices?.[entity.device_id] : null;
|
|
if (snapshot.authoritative && entity?.device_id && !parent) continue;
|
|
if (parent && !isRegistryEntryEnabled(parent)) continue;
|
|
states[eid] = state;
|
|
}
|
|
return { ...hass, devices, entities, states };
|
|
}
|
|
|
|
/** Hass-shaped projection with the full rows when they are authoritative. */
|
|
export function fullRegistryHass(hass: any, snapshot = haRegistrySnapshot(hass)): any {
|
|
return { ...hass, devices: snapshot.devices || {}, entities: snapshot.entities || {} };
|
|
}
|
|
|
|
function entityIdsForDevice(entities: Record<string, any>, deviceId: string): string[] {
|
|
const out: string[] = [];
|
|
for (const [eid, entity] of Object.entries<any>(entities || {})) {
|
|
if (entity?.device_id === deviceId) out.push(eid);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function readStatusCache(): Record<string, CachedBindingStatus> {
|
|
if (bindingStatusMemo) return bindingStatusMemo;
|
|
if (typeof localStorage === 'undefined') return {};
|
|
try {
|
|
const parsed = JSON.parse(localStorage.getItem(BINDING_STATUS_LS) || '{}');
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
const now = Date.now();
|
|
const out: Record<string, CachedBindingStatus> = {};
|
|
for (const [binding, value] of Object.entries<any>(parsed)) {
|
|
if (!value || (value.kind !== 'active' && value.kind !== 'ha_disabled')) continue;
|
|
if (!Number.isFinite(value.ts) || now - value.ts > BINDING_STATUS_TTL_MS) continue;
|
|
out[binding] = value;
|
|
}
|
|
bindingStatusMemo = out;
|
|
return bindingStatusMemo;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
export function cachedHaBindingStatus(binding: string): CachedBindingStatus | null {
|
|
return readStatusCache()[binding] || null;
|
|
}
|
|
|
|
/** Persist only authoritative statuses of saved bindings; never marker data/state. */
|
|
export function cacheHaBindingStatuses(statuses: Map<string, HaBindingStatus>): void {
|
|
if (typeof localStorage === 'undefined' || !statuses.size) return;
|
|
const cache = readStatusCache();
|
|
const now = Date.now();
|
|
for (const [binding, status] of statuses) {
|
|
if (status.kind === 'active') cache[binding] = { kind: 'active', ts: now };
|
|
else if (status.kind === 'ha_disabled') {
|
|
cache[binding] = { kind: 'ha_disabled', reason: status.reason, ts: now };
|
|
}
|
|
}
|
|
const trimmed = Object.fromEntries(
|
|
Object.entries(cache)
|
|
.sort((a, b) => b[1].ts - a[1].ts)
|
|
.slice(0, BINDING_STATUS_MAX),
|
|
);
|
|
bindingStatusMemo = trimmed;
|
|
try { localStorage.setItem(BINDING_STATUS_LS, JSON.stringify(trimmed)); } catch { /* quota/private mode */ }
|
|
}
|
|
|
|
/** One source of truth for device/entity binding availability. */
|
|
export function resolveHaBindingStatus(
|
|
hass: any,
|
|
binding: string,
|
|
snapshot = haRegistrySnapshot(hass),
|
|
): HaBindingStatus {
|
|
if (!binding || binding === 'virtual') {
|
|
return { kind: 'active', enabledEntityIds: [], allEntityIds: [] };
|
|
}
|
|
const split = binding.indexOf(':');
|
|
if (split < 1) return { kind: 'unverified', reason: 'registry_unavailable', enabledEntityIds: [], allEntityIds: [] };
|
|
const kind = binding.slice(0, split);
|
|
const ref = binding.slice(split + 1);
|
|
if ((kind !== 'device' && kind !== 'entity') || !ref) {
|
|
return { kind: 'unverified', reason: 'registry_unavailable', enabledEntityIds: [], allEntityIds: [] };
|
|
}
|
|
|
|
const devices = snapshot.devices || {};
|
|
const entities = snapshot.entities || {};
|
|
if (snapshot.authoritative) {
|
|
if (kind === 'device') {
|
|
const device = devices[ref];
|
|
if (!device) return { kind: 'orphaned', reason: 'device_missing', enabledEntityIds: [], allEntityIds: [] };
|
|
const allEntityIds = entityIdsForDevice(entities, ref);
|
|
if (!isRegistryEntryEnabled(device)) {
|
|
return { kind: 'ha_disabled', reason: 'device', enabledEntityIds: [], allEntityIds };
|
|
}
|
|
const enabledEntityIds = allEntityIds.filter((eid) => isRegistryEntryEnabled(entities[eid]));
|
|
if (allEntityIds.length && !enabledEntityIds.length) {
|
|
return { kind: 'ha_disabled', reason: 'all_entities', enabledEntityIds: [], allEntityIds };
|
|
}
|
|
return { kind: 'active', enabledEntityIds, allEntityIds };
|
|
}
|
|
const entity = entities[ref];
|
|
// YAML platforms without a unique_id (notably Xiaomi Cloud Map
|
|
// Extractor cameras) legitimately have a live state but no registry row.
|
|
// A disabled row still wins, otherwise that exact live state is positive
|
|
// evidence of existence even under an authoritative registry snapshot.
|
|
if (entity && !isRegistryEntryEnabled(entity)) {
|
|
return { kind: 'ha_disabled', reason: 'entity', enabledEntityIds: [], allEntityIds: [ref] };
|
|
}
|
|
if (entity?.device_id && !devices[entity.device_id]) {
|
|
return { kind: 'orphaned', reason: 'device_missing', enabledEntityIds: [], allEntityIds: [ref] };
|
|
}
|
|
if (entity?.device_id && !isRegistryEntryEnabled(devices[entity.device_id])) {
|
|
return { kind: 'ha_disabled', reason: 'device', enabledEntityIds: [], allEntityIds: [ref] };
|
|
}
|
|
if (!entity && !hass?.states?.[ref]) {
|
|
return { kind: 'orphaned', reason: 'entity_missing', enabledEntityIds: [], allEntityIds: [] };
|
|
}
|
|
return { kind: 'active', enabledEntityIds: [ref], allEntityIds: [ref] };
|
|
}
|
|
|
|
// Limited fallback: only positive evidence is authoritative. A missing row
|
|
// is not orphaned/disabled; it becomes unverified unless a live exact state
|
|
// or the last authoritative disabled status resolves it safely.
|
|
const cached = cachedHaBindingStatus(binding);
|
|
if (kind === 'device') {
|
|
const device = devices[ref];
|
|
const allEntityIds = entityIdsForDevice(entities, ref);
|
|
if (device?.disabled_by != null) {
|
|
return { kind: 'ha_disabled', reason: 'device', enabledEntityIds: [], allEntityIds };
|
|
}
|
|
const enabledEntityIds = allEntityIds.filter((eid) => {
|
|
const entity = entities[eid];
|
|
return entity?.disabled_by == null && (!entity.device_id || devices[entity.device_id]?.disabled_by == null);
|
|
});
|
|
if (allEntityIds.length && !enabledEntityIds.length
|
|
&& allEntityIds.every((eid) => entities[eid]?.disabled_by != null)) {
|
|
return { kind: 'ha_disabled', reason: 'all_entities', enabledEntityIds: [], allEntityIds };
|
|
}
|
|
// A last authoritative disabled result remains the conservative answer
|
|
// until another authoritative fetch clears it. A shortened live registry
|
|
// row cannot prove that every formerly disabled child was re-enabled.
|
|
if (cached?.kind === 'ha_disabled') {
|
|
return {
|
|
kind: 'ha_disabled', reason: cached.reason || 'device',
|
|
enabledEntityIds: [], allEntityIds,
|
|
};
|
|
}
|
|
if (device || enabledEntityIds.some((eid) => !!hass?.states?.[eid])) {
|
|
return { kind: 'active', enabledEntityIds, allEntityIds };
|
|
}
|
|
} else {
|
|
const entity = entities[ref];
|
|
if (entity?.disabled_by != null) {
|
|
return { kind: 'ha_disabled', reason: 'entity', enabledEntityIds: [], allEntityIds: [ref] };
|
|
}
|
|
if (entity?.device_id && devices[entity.device_id]?.disabled_by != null) {
|
|
return { kind: 'ha_disabled', reason: 'device', enabledEntityIds: [], allEntityIds: [ref] };
|
|
}
|
|
if (cached?.kind === 'ha_disabled') {
|
|
return {
|
|
kind: 'ha_disabled', reason: cached.reason || 'entity',
|
|
enabledEntityIds: [], allEntityIds: [ref],
|
|
};
|
|
}
|
|
if (entity || hass?.states?.[ref]) {
|
|
return { kind: 'active', enabledEntityIds: [ref], allEntityIds: [ref] };
|
|
}
|
|
}
|
|
|
|
return { kind: 'unverified', reason: 'registry_unavailable', enabledEntityIds: [], allEntityIds: [] };
|
|
}
|
|
|
|
/**
|
|
* Availability of an exact entity explicitly assigned to an architectural
|
|
* opening. This is deliberately independent of plan-marker tombstones: the
|
|
* opening is its own configured consumer of the HA entity. Registry status
|
|
* still rejects disabled, orphaned and unverified references.
|
|
*/
|
|
export function openingEntityAvailable(
|
|
hass: any,
|
|
entityId: string | null | undefined,
|
|
snapshot = haRegistrySnapshot(hass),
|
|
): boolean {
|
|
return !!entityId
|
|
&& resolveHaBindingStatus(hass, `entity:${entityId}`, snapshot).kind === 'active';
|
|
}
|
|
|
|
/**
|
|
* Frame-local counterpart of `openingEntityAvailable`. The caller supplies an
|
|
* immutable active-registry projection. State presence in that projection is
|
|
* sufficient evidence for an exact opening reference: YAML entities without
|
|
* unique_id have no registry row, while explicit disabled/orphan rows have
|
|
* already had their states removed by `activeRegistryHass`. Never call this
|
|
* helper with raw live hass; every read must belong to one painted frame.
|
|
*/
|
|
export function renderOpeningEntityAvailable(
|
|
projectedHass: any,
|
|
entityId: string | null | undefined,
|
|
): boolean {
|
|
return !!entityId
|
|
&& !!projectedHass?.states?.[entityId];
|
|
}
|
|
|
|
export function haRegistryDiagnostics(hass: any): {
|
|
access: HaRegistrySnapshot['access']; authoritative: boolean; revision: number;
|
|
lastSuccess: number; error?: string;
|
|
} {
|
|
const snapshot = haRegistrySnapshot(hass);
|
|
return {
|
|
access: snapshot.access,
|
|
authoritative: snapshot.authoritative,
|
|
revision: snapshot.revision,
|
|
lastSuccess: snapshot.lastSuccess,
|
|
error: snapshot.error,
|
|
};
|
|
}
|