/** * Building the device list from HA registries: filtering, light groups, * markers (overrides/virtual). No Lit/DOM — only the hass object. */ import { iconFor, iconFromDeviceClasses, DOMAIN_PRIORITY, FALLBACK_ICON, type CompiledIconRule, EXCLUDED_DOMAINS } from './rules'; import { averageLqi, isControllable } from './logic'; import { applianceLifecycleRoleRank, isDevicePowerSwitch, isSemanticBinaryEntity, } from './device-visual'; import type { DevItem, Marker, ServerConfig } from './types'; import { isManualVirtualLightMarker, virtualLightFingerprint, virtualLightIsOn, type VirtualLightSnapshot, } from './virtual-light-state'; import { activeRegistryHass, fullRegistryHass, haRegistrySnapshot, isRegistryEntryEnabled, resolveHaBindingStatus, type HaRegistrySnapshot, } from './ha-binding-status'; /** Build context: a slice of hass + config resolution. */ export interface BuildCtx { hass: any; /** Shared page-level full registry authority; omitted callers use its current snapshot. */ registry?: HaRegistrySnapshot; /** area_id → space_id (only zones bound to rooms). */ areaToSpace: Record; markers: Marker[]; settings: ServerConfig['settings']; excluded: ReadonlySet; /** LEGACY only: honoured while the config has no settings.filter_seeded. * Seeded configs hide by explicit marker flags (docs/FILTERING.md). */ showAll: boolean; firstSpaceId: string; /** Localized display strings for generated device names. */ loc: (key: 'device.unnamed' | 'device.light_group' | 'device.fallback' | 'device.virtual') => string; /** Compiled icon rules (instance overrides or built-in defaults). */ iconRules?: CompiledIconRule[]; } export function entitiesByDevice(hass: any): Record { const map: Record = {}; for (const [eid, ent] of Object.entries(hass.entities)) { if (!ent?.device_id || !isRegistryEntryEnabled(ent)) continue; const parent = hass.devices?.[ent.device_id]; if (parent && !isRegistryEntryEnabled(parent)) continue; (map[ent.device_id] = map[ent.device_id] || []).push(eid); } return map; } function allEntitiesByDevice(hass: any): Record { const map: Record = {}; for (const [eid, ent] of Object.entries(hass.entities || {})) { if (ent?.device_id) (map[ent.device_id] = map[ent.device_id] || []).push(eid); } return map; } interface EntityMarkerOwnership { byDevice: ReadonlyMap>; } /** * Live entity markers own their exact entity inside an auto-discovered parent. * Tombstones deliberately do not: removing a standalone binding must return * that entity to the still-live HA device (docs/FILTERING.md). */ function entityMarkerOwnership(markers: readonly Marker[], fullHass: any): EntityMarkerOwnership { const mutableByDevice = new Map>(); for (const marker of markers) { if (marker?.removed === true) continue; const binding = marker?.binding || ''; if (!binding.startsWith('entity:')) continue; const entityId = binding.slice('entity:'.length); if (!entityId) continue; const deviceId = fullHass?.entities?.[entityId]?.device_id; if (!deviceId) continue; const owned = mutableByDevice.get(deviceId) || new Set(); owned.add(entityId); mutableByDevice.set(deviceId, owned); } return { byDevice: mutableByDevice }; } /** * An untouched automatic device keeps its complete functional resolver, * including integration-hidden cover entities (#94). Once one of its entities * is placed explicitly, the residual auto marker contains only active, * HA-visible, unclaimed siblings. */ function residualAutoDeviceEntities( hass: any, deviceId: string, entityIds: readonly string[], ownership: EntityMarkerOwnership, ): { partial: boolean; entityIds: string[] } { const owned = ownership.byDevice.get(deviceId); if (!owned?.size) return { partial: false, entityIds: [...entityIds] }; return { partial: true, entityIds: entityIds.filter((entityId) => !owned.has(entityId) && !hass?.entities?.[entityId]?.hidden), }; } export function domainOfDevice(hass: any, dev: any, entIds: string[]): string { if (dev.identifiers?.[0]?.[0]) return dev.identifiers[0][0]; for (const eid of entIds) { const p = hass.entities[eid]?.platform; if (p) return p; } return ''; } export function isTempEntity(hass: any, eid: string): boolean { // Температура чипа самого устройства — это диагностика, не комнатная температура. if (/_device_temperature$/.test(eid)) return false; // Диагностические/конфигурационные сущности (чип, батарея, калибровки) — не комната. if (hass.entities?.[eid]?.entity_category) return false; const st = hass.states[eid]; if (!st) return /_temperature$/.test(eid); const a = st.attributes || {}; return ( a.device_class === 'temperature' || /°C|°F/.test(a.unit_of_measurement || '') || /_temperature$/.test(eid) ); } /** * Domains which expose the state machine of a whole functional device. This * is a role order, not an entity-list order: an auxiliary switch may never * outrank the vacuum/climate/cover/etc. entity it configures. */ const DEVICE_STATE_DOMAINS = [ 'vacuum', 'lawn_mower', 'climate', 'media_player', 'light', 'cover', 'lock', 'valve', 'alarm_control_panel', 'water_heater', 'fan', 'humidifier', 'siren', 'camera', 'remote', ]; interface DeviceEntityCandidate { eid: string; reg: any; } const visibleFirst = (items: DeviceEntityCandidate[]): DeviceEntityCandidate[] => [ ...items.filter((item) => !item.reg?.hidden), ...items.filter((item) => !!item.reg?.hidden), ]; /** * Effective entities which jointly describe one device's status. * * Home Assistant has no device state: it has entity states, while * entity_category marks config/diagnostic entities as non-primary. House Plan * therefore resolves a ROLE, not a first list item: * 1) uncategorised HA entities (all entities only as a fallback); * 2) a whole-device state domain, if present; * 3) semantic binary signals (presence/contact/motion/safety/running); * 4) for a recognised composite Power topology, a strict appliance * lifecycle entity plus the Power availability gate; * 5) one representative switch when the device has no stronger role; * 6) passive entities together, so one unavailable sensor does not make the * whole marker unavailable while another reading is alive. */ export function resolvedDeviceStateEntities(hass: any, entIds: readonly string[]): string[] { const all: DeviceEntityCandidate[] = entIds .map((eid) => ({ eid, reg: hass?.entities?.[eid] })) .filter((item) => !!item.reg); if (!all.length) return []; const haPrimary = all.filter((item) => !item.reg.entity_category); const pool = haPrimary.length ? haPrimary : all; for (const domain of DEVICE_STATE_DOMAINS) { const role = pool.filter((item) => item.eid.startsWith(domain + '.')); if (role.length) return visibleFirst(role).map((item) => item.eid); } const semanticBinary = pool.filter((item) => isSemanticBinaryEntity(hass, item.eid)); if (semanticBinary.length) return visibleFirst(semanticBinary).map((item) => item.eid); const switches = pool.filter((item) => item.eid.startsWith('switch.')); if (switches.length) { // A switch-only integration commonly exposes one power relay plus a set // of feature toggles (night mode, voice enhancement, child lock, etc.). // HA gives us no device-level state and some third-party integrations do // not mark those feature entities as `entity_category: config`. Combining // every switch would therefore mean "any option enabled = device working". // Prefer a dedicated HA Power entity when metadata identifies one, then // use the same single representative that primaryEntity/actions use. // Entity-bound markers remain exact because their list has one member. const ordered = visibleFirst(switches); const power = ordered.find((item) => isDevicePowerSwitch(hass, item.eid)); if (switches.length > 1 && power) { // Lifecycle metadata is allowed to take over only inside the same // composite-Power topology that already neutralises Power=on. This keeps // a lone relay authoritative even when it has a diagnostic Status peer. const eligible = all.filter((item) => item.eid !== power.eid && item.reg?.entity_category !== 'config' && applianceLifecycleRoleRank(hass, item.eid) != null, ); const uncategorised = eligible.filter((item) => !item.reg?.entity_category); const lifecyclePool = uncategorised.length ? uncategorised : eligible; for (const rank of [0, 1, 2]) { const role = visibleFirst(lifecyclePool.filter( (item) => applianceLifecycleRoleRank(hass, item.eid) === rank, )); if (role.length) return [role[0].eid, power.eid]; } } return [(power || ordered[0]).eid]; } // Passive/fallback entities are all useful for aggregate availability. Keep // the historical domain order only as a stable ordering for primaryEntity. const ordered: DeviceEntityCandidate[] = []; for (const domain of DOMAIN_PRIORITY) ordered.push(...visibleFirst(pool.filter((item) => item.eid.startsWith(domain + '.')))); ordered.push(...visibleFirst(pool.filter( (item) => !DOMAIN_PRIORITY.includes(item.eid.split('.')[0]), ))); return ordered.map((item) => item.eid); } export function primaryEntity(hass: any, entIds: string[], icon: string): string | undefined { const all = entIds .map((eid) => ({ eid, reg: hass.entities[eid], st: hass.states[eid] })) .filter((e) => e.reg); // Temperature/air-monitor labels deliberately choose their measurement; // every other device delegates to the shared role resolver below. const tiers = [ all.filter((e) => !e.reg.hidden && !e.reg.entity_category), all.filter((e) => !e.reg.entity_category), all.filter((e) => !e.reg.hidden), all, ]; if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') { for (const tier of tiers) { const t = tier.find((e) => isTempEntity(hass, e.eid)); if (t) return t.eid; } } const role = resolvedDeviceStateEntities(hass, entIds); // A composite appliance exposes [lifecycle, Power gate]. Keep the // controllable Power entity as the historical primary/action/value target; // presentation still consumes the complete resolved role. return role.find(isControllable) || role[0]; } /** Minimal device shape accepted by the shared light-source resolver. */ export interface LightSourceDevice { id?: string; area: string; hidden?: boolean; entities: string[]; primary?: string; /** Runtime-effective controls; marker.controls remains the persisted list. */ controls?: string[]; marker?: { id?: string; binding?: string; room_id?: string | null; is_light?: boolean | null; light_entity?: string | null; toggle_entity?: string | null; controls?: string[] | null; glow_radius_cm?: number | null; glow_color?: { c: string; bri?: number | null } | null; } | null; } export type LightSourceRoom = string | { id?: string | null; area?: string | null }; export interface ResolvedLightSource { /** Stable identity. A passive source deliberately has no HA entity id. */ key: `entity:${string}` | `marker:${string}`; /** Compatibility projection for old consumers; empty for a passive source. */ eid: string; /** Entities whose state determines this source. */ stateEids: string[]; /** Real HA entities which may be sent to callService. Never marker:* refs. */ serviceEids: string[]; /** Device/marker which declares or physically places this source on the plan. */ device: D; /** Why this entity belongs to the light-source set. */ via: 'controls' | 'forced' | 'light'; /** False for remote controls: they describe room light but have no lamp position. */ castsGlow: boolean; /** Always + no own controllable entity. */ passive: boolean; on: boolean; } /** One controller's real HA drivers for an incoming `marker:*` link. */ export interface IncomingLightController { device: D; /** Real entity ids only. The linked marker id is never a service target. */ driverEids: string[]; } /** Plan-wide reverse edge for one forced source marker. */ export interface IncomingLightControl { markerId: string; controllers: IncomingLightController[]; /** Deterministic union of every controller's driver entities. */ driverEids: string[]; } /** * Controls are OTHER light targets operated by this marker. A marker already * operates its own bound entity/device through the normal tap action; storing * one of those same entities in controls made a plain fan/socket look like an * explicitly configured light source. `is_light` is the sole explicit way to * say that the bound switch itself drives a real fixture; `false` explicitly * suppresses the marker's own light role without touching external controls. */ export function effectiveMarkerControls( binding: string | null | undefined, controls: readonly string[] | null | undefined, ownEntities: readonly string[] = [], ): string[] { return [...new Set(persistedExternalControls(binding, controls, ownEntities))] .filter((eid) => isControllable(eid)); } /** * Lossless controls list for marker editing/persistence. * * Runtime consumers deliberately discard unknown domains and duplicates, but * opening and saving the dialog must not rewrite YAML-only targets or collapse * repeated entries. Only the marker's own target is removed: it belongs to the * normal tap action. Classifying that relay as a lamp is a separate, explicit * tri-state `is_light` decision. */ export function persistedExternalControls( binding: string | null | undefined, controls: readonly string[] | null | undefined, ownEntities: readonly string[] = [], ): string[] { // An entity marker owns only its exact binding. Registry rebuilds may attach // sibling entities from the parent device to DevItem; treating every sibling // as self would silently drop an explicitly configured external target. const own = binding?.startsWith('entity:') ? new Set([binding.slice('entity:'.length)]) : new Set(ownEntities); return (controls || []).filter((eid) => typeof eid === 'string' && !own.has(eid)); } function lightSourceBelongsToRoom(d: LightSourceDevice, room: LightSourceRoom): boolean { if (typeof room === 'string') return d.area === room; // An explicit marker-to-room binding is more precise than the HA area and // therefore must not leak the source into every room sharing that area. const roomId = d.marker?.room_id; if (roomId) return !!room.id && roomId === room.id; return !!room.area && d.area === room.area; } interface LightEntityCandidate { eid: string | null; via: ResolvedLightSource['via']; } /** Controllable own entities in deterministic compatibility order. */ export function ownControllableEntities(d: LightSourceDevice): string[] { const bound = d.marker?.binding?.startsWith('entity:') ? d.marker.binding.slice('entity:'.length) : null; const controllable = d.entities.filter(isControllable); const primary = d.primary && isControllable(d.primary) ? d.primary : null; return [...new Set([bound, primary, ...controllable].filter( (eid): eid is string => !!eid && isControllable(eid), ))]; } /** Effective leading entity. A stale explicit choice is retained in config but * falls back until that entity returns to the marker's own active candidates. */ export function forcedLightEntityOf(d: LightSourceDevice): string | null { const candidates = ownControllableEntities(d); const selected = d.marker?.light_entity; if (selected && candidates.includes(selected)) return selected; // An explicitly forced entity marker names the physical relay exactly. Do // not let a different registry-derived primary entity steal that decision. return candidates[0] || null; } function ownLightCandidatesOf(hass: any, d: LightSourceDevice): LightEntityCandidate[] { if (d.marker?.is_light === false) return []; if (d.marker?.is_light === true) { const forced = forcedLightEntityOf(d); // Capability is registry/config based, never inferred from a transient HA // state snapshot. No candidate is a valid passive forced source. return [{ eid: forced, via: 'forced' }]; } // Automatic discovery describes the DEVICE, not every auxiliary entity it // happens to expose. The resolved functional role prevents status LEDs of a // TV/soundbar/etc. from turning the whole device into a plan light. const role = d.primary || resolvedDeviceStateEntities(hass, d.entities)[0]; if (role && !role.startsWith('light.')) return []; return d.entities // Auto describes a currently available HA light. Unlike the explicit // Always role, a registry entry whose state has disappeared must not turn // the room from “no sources” into a phantom “0 of 1” source. .filter((eid) => eid.startsWith('light.') && !!hass.states?.[eid]) .map((eid) => ({ eid, via: 'light' as const })); } interface CachedLightGraph { fingerprint: string; visible: D[]; markerById: Map; persistedByDevice: Map; incomingByMarker: Map>; } const LIGHT_GRAPH_CACHE = new WeakMap>(); interface CachedResolvedLightSources { graphFingerprint: string; stateFingerprint: string; virtualLightFingerprint: string; registry: unknown; sources: ResolvedLightSource[]; } const RESOLVED_LIGHT_CACHE = new WeakMap>(); function lightGraphFingerprint(devices: readonly LightSourceDevice[]): string { return devices.map((device) => [ device.id || '', device.hidden === true ? 1 : 0, device.primary || '', [...device.entities].join(','), device.controls === undefined ? '' : [...device.controls].join(','), device.marker?.id || '', device.marker?.binding || '', device.marker?.is_light, (device.marker as any)?.tap_action || '', (device.marker as any)?.removed === true ? 1 : 0, device.marker?.light_entity || '', device.marker?.controls == null ? '' : [...device.marker.controls].join(','), ].join('\u001f')).join('\u001e'); } /** Cheap state projection used to reuse the plan-wide graph for every room in * one frame. It deliberately includes persisted entity refs that are not own * entities: a controller outside a room may drive a source inside it. */ function lightStateFingerprint(hass: any, devices: readonly LightSourceDevice[]): string { const ids = new Set(); for (const device of devices) { for (const id of device.entities) ids.add(id); for (const ref of device.controls || device.marker?.controls || []) { if (typeof ref === 'string' && !ref.startsWith('marker:')) ids.add(ref); } } return [...ids].sort().map((id) => `${id}:${hass?.states?.[id]?.state ?? ''}`).join('|'); } function lightGraphOf(devices: readonly D[]): CachedLightGraph { const fingerprint = lightGraphFingerprint(devices); const previous = LIGHT_GRAPH_CACHE.get(devices as object) as CachedLightGraph | undefined; if (previous?.fingerprint === fingerprint) return previous; const visible = devices.filter((device) => !device.hidden); const markerById = new Map(); const persistedByDevice = new Map(); for (const device of devices) { persistedByDevice.set(device, persistedExternalControls( device.marker?.binding, device.marker?.controls ?? device.controls, device.entities, )); const id = !device.hidden && device.marker?.is_light === true ? device.marker?.id || device.id : null; if (id) markerById.set(String(id), device); } const incomingByMarker = new Map>(); for (const controller of devices) { const persisted = persistedByDevice.get(controller) || []; const activeEntityTargets = new Set( (controller.controls === undefined ? persisted.filter(isControllable) : controller.controls.filter(isControllable)), ); const ownDriver = forcedLightEntityOf(controller); const driverEids = activeEntityTargets.size ? [...activeEntityTargets] : ownDriver ? [ownDriver] : []; for (const ref of persisted) { if (!ref.startsWith('marker:')) continue; const markerId = ref.slice('marker:'.length); if (!markerId || markerId === String(controller.marker?.id || controller.id || '')) continue; if (!markerById.has(markerId)) continue; const previous = incomingByMarker.get(markerId) || { markerId, controllers: [], driverEids: [], }; previous.controllers.push({ device: controller, driverEids: [...driverEids] }); previous.driverEids = [...new Set([...previous.driverEids, ...driverEids])]; incomingByMarker.set(markerId, previous); } } const value = { fingerprint, visible, markerById, persistedByDevice, incomingByMarker }; LIGHT_GRAPH_CACHE.set(devices as object, value); return value; } /** * Canonical reverse projection for #84/#174. State resolution and Toggle must * consume this same cached graph so a linked lamp cannot display one relay * while an action operates another. */ export function incomingLightControls( devices: readonly D[], ): ReadonlyMap> { return lightGraphOf(devices).incomingByMarker; } /** Whether the marker currently has a real, position-bearing source of its own. */ export function hasOwnSpatialSource(hass: any, d: LightSourceDevice): boolean { const controls = effectiveMarkerControls( d.marker?.binding, d.controls ?? d.marker?.controls, d.entities, ); const persisted = persistedExternalControls( d.marker?.binding, d.marker?.controls ?? d.controls, d.entities, ); if (d.marker?.is_light == null && (controls.length || persisted.some((ref) => ref.startsWith('marker:')))) return false; return ownLightCandidatesOf(hass, d).length > 0; } /** Whether the source owns a real state/service entity (as opposed to passive). */ export function hasOwnStatefulLightSource(hass: any, d: LightSourceDevice): boolean { return ownLightCandidatesOf(hass, d).some((candidate) => !!candidate.eid); } export type LightRole = 'auto' | 'always' | 'never'; export type GlowMode = 'auto' | 'color' | 'fixed'; /** Normative UI matrix shared by the dialog and exhaustive tests. */ export function resolveDeviceLightSettings( role: LightRole, autoHasSource: boolean, statefulCapability: boolean, storedMode: GlowMode, ): { sourceExists: boolean; fromSourceEnabled: boolean; manualEnabled: boolean; radiusEnabled: boolean; passive: boolean; effectiveMode: GlowMode; } { const sourceExists = role === 'always' || (role === 'auto' && autoHasSource); const passive = sourceExists && !statefulCapability; return { sourceExists, fromSourceEnabled: sourceExists && statefulCapability, manualEnabled: sourceExists, radiusEnabled: sourceExists, passive, effectiveMode: passive && storedMode === 'auto' ? 'fixed' : storedMode, }; } function lightEntitiesOf(hass: any, d: LightSourceDevice): LightEntityCandidate[] { const controls = effectiveMarkerControls( d.marker?.binding, d.controls ?? d.marker?.controls, d.entities, ); const out: LightEntityCandidate[] = controls.map((eid) => ({ eid, via: 'controls' })); // In Auto mode explicit controls retain the historic priority and suppress // own-source discovery. Always is deliberately additive: controls continue // to describe the room while the marker itself owns a spatial Glow source. const own = hasOwnSpatialSource(hass, d) ? ownLightCandidatesOf(hass, d) : []; for (const candidate of own) { if (!candidate.eid || !out.some((existing) => existing.eid === candidate.eid)) out.push(candidate); } return out; } /** * One source of truth for every light-related room feature. Within a marker, * external controls stay independent; Always adds a forced own source, Never * removes it, and Auto keeps the historical controls-first/light-role fallback. * Across markers the real/forced source owns spatial Glow over a controller * naming the same entity. Passing a room scopes sources by room_id/area; * omitting it resolves the supplied devices (used by Glow and controls). */ export function resolvedLightSources( hass: any, devices: readonly D[], room?: LightSourceRoom | null, virtualLights?: VirtualLightSnapshot | null, ): ResolvedLightSource[] { // Resolve the graph once per frame and scope by the source owner's precise // room binding. This preserves cross-room controllers while avoiding a full // O(devices + links) graph walk for every room consumer. if (room != null) { return resolvedLightSources(hass, devices, null, virtualLights).filter((source) => lightSourceBelongsToRoom(source.device, room)); } const graphFingerprint = lightGraphFingerprint(devices); const stateFingerprint = lightStateFingerprint(hass, devices); const manualFingerprint = virtualLightFingerprint(virtualLights); const cached = RESOLVED_LIGHT_CACHE.get(devices as object) as CachedResolvedLightSources | undefined; if (cached?.graphFingerprint === graphFingerprint && cached.stateFingerprint === stateFingerprint && cached.virtualLightFingerprint === manualFingerprint && cached.registry === hass?.entities) return cached.sources; type Candidate = ResolvedLightSource; const { visible, markerById, persistedByDevice, incomingByMarker } = lightGraphOf(devices); // Room consumers need sources owned by that room, not a full resolution of // every marker followed by a final filter. Controllers outside the room are // still scanned below because they may drive a source whose owner is inside. const sourceDevices = room == null ? visible : visible.filter((device) => lightSourceBelongsToRoom(device, room)); const ownByDevice = new Map(); const ownerByEntity = new Map(); for (const device of sourceDevices) { const own = lightEntitiesOf(hass, device).filter((candidate) => candidate.via !== 'controls'); // Auto may expose several light.* entities. Each remains an independent // source; forced Always deliberately has exactly one leading source. for (const candidate of own) { const markerId = device.marker?.is_light === true ? String(device.marker?.id || device.id || '') : ''; const key = markerId ? `marker:${markerId}` as const : `entity:${candidate.eid}` as const; const eids = candidate.eid ? [candidate.eid] : []; const source: Candidate = { key, eid: candidate.eid || '', stateEids: eids, serviceEids: eids, device, via: candidate.via, castsGlow: true, passive: !candidate.eid, on: candidate.eid ? hass.states?.[candidate.eid]?.state === 'on' : true, }; ownByDevice.set(device, [...(ownByDevice.get(device) || []), source]); if (candidate.eid) ownerByEntity.set(candidate.eid, source); } } const activeEntityTargetsByDevice = new Map>(); for (const controller of devices) { const persisted = persistedByDevice.get(controller) || []; // `hidden` is a visual setting. It must not sever a saved controller link // and make a physically active passive lamp look off. const activeEntityTargets = new Set( controller.controls === undefined ? persisted.filter(isControllable) : controller.controls, ); activeEntityTargetsByDevice.set(controller, activeEntityTargets); } for (const sources of ownByDevice.values()) { for (const source of sources) { if (!source.passive) continue; const markerId = source.key.slice('marker:'.length); const control = incomingByMarker.get(markerId); source.on = control ? control.driverEids.some((eid) => hass.states?.[eid]?.state === 'on') : true; // #174: real controller state is authoritative whenever a saved, // runtime-valid incoming link exists. The manual #107 state remains a // lossless fallback and becomes visible again after the last link goes. if (!control && isManualVirtualLightMarker(source.device.marker)) { source.on = virtualLightIsOn(source.device.marker, virtualLights); } } } const byKey = new Map(); const add = (source: Candidate): void => { const previous = byKey.get(source.key); if (!previous || (source.castsGlow && !previous.castsGlow)) byKey.set(source.key, source); }; for (const controller of visible) { const persisted = persistedByDevice.get(controller) || []; const controllerInRoom = room == null || lightSourceBelongsToRoom(controller, room); for (const ref of persisted) { if (ref.startsWith('marker:')) { const target = markerById.get(ref.slice('marker:'.length)); const owned = target ? ownByDevice.get(target) : null; if (owned) for (const source of owned) add(source); continue; } if (!isControllable(ref) || !activeEntityTargetsByDevice.get(controller)?.has(ref)) continue; const owned = ownerByEntity.get(ref); if (owned) add(owned); else if (controllerInRoom) add({ key: `entity:${ref}`, eid: ref, stateEids: [ref], serviceEids: [ref], device: controller, via: 'controls', castsGlow: false, passive: false, on: hass.states?.[ref]?.state === 'on', }); } for (const source of ownByDevice.get(controller) || []) add(source); } const sources = [...byKey.values()]; RESOLVED_LIGHT_CACHE.set(devices as object, { graphFingerprint, stateFingerprint, virtualLightFingerprint: manualFingerprint, registry: hass?.entities, sources, }); return sources; } /** Select the single source whose state and position drive a marker's Glow. */ export function selectSpatialGlowSource( sources: readonly ResolvedLightSource[], ): ResolvedLightSource | null { const spatial = sources.filter((source) => source.castsGlow); return spatial.find((source) => source.on) || spatial[0] || null; } /** Tri-state used by room fill. */ export function resolvedLightState(sources: readonly ResolvedLightSource[]): 'on' | 'off' | 'none' { if (!sources.length) return 'none'; return sources.some((source) => source.on) ? 'on' : 'off'; } /** Counts used by the room card/label. */ export function resolvedLightStats( sources: readonly ResolvedLightSource[], ): { on: number; total: number } | null { if (!sources.length) return null; return { on: sources.filter((source) => source.on).length, total: sources.length }; } /** @deprecated Compatibility helper for older tests/callers; use resolvedLightSources(). */ export function litLightEntity( hass: any, d: Omit & { area?: string }, ): string | null { return resolvedLightSources(hass, [{ ...d, area: d.area || '' }]) .find((source) => source.on && !!source.eid)?.eid || null; } /** Average zigbee LQI across the device's entities (*_linkquality/*_lqi sensors or an attribute). */ export function lqiFor(hass: any, entIds: string[]): number | null { const vals: number[] = []; for (const eid of entIds) { const st = hass.states[eid]; if (!st) continue; const unit = (st.attributes?.unit_of_measurement || '').toLowerCase(); // 1) dedicated signal sensor: Z2M *_linkquality, ZHA *_lqi, or “lqi” units if (/_(linkquality|lqi)$/.test(eid) || unit === 'lqi') { const v = parseFloat(st.state); if (!isNaN(v)) vals.push(v); continue; } // 2) signal as an ATTRIBUTE on any entity of the device (Z2M linkquality / ZHA lqi) — // covers devices whose dedicated signal sensor is disabled const av = st.attributes?.linkquality ?? st.attributes?.lqi; if (av != null) { const v = parseFloat(av); if (!isNaN(v)) vals.push(v); } } return averageLqi(vals); } export function tempFor(hass: any, entIds: string[]): number | null { for (const eid of entIds) { if (!isTempEntity(hass, eid)) continue; const st = hass.states[eid]; if (!st) continue; const v = parseFloat(st.state); if (!isNaN(v)) return Math.round(v * 10) / 10; } return null; } /** * Room temperature as a CLIMATE device reports it: the first climate.* entity * with a finite attributes.current_temperature (owner's spec: several climate * entities -> the first valid one wins). Unavailable/unknown entities and a * missing attribute yield null - no badge, no vote in the room average. */ export function climateTempFor(hass: any, entIds: string[]): number | null { for (const eid of entIds) { if (!eid.startsWith('climate.')) continue; const st = hass.states[eid]; if (!st || st.state === 'unavailable' || st.state === 'unknown') continue; const v = parseFloat(st.attributes?.current_temperature); if (Number.isFinite(v)) return Math.round(v * 10) / 10; } return null; } /** A humidity-measuring entity (device_class humidity or *_humidity), excluding diagnostics. */ export function isHumEntity(hass: any, eid: string): boolean { if (hass.entities?.[eid]?.entity_category) return false; const st = hass.states[eid]; if (!st) return /_humidity$/.test(eid); const a = st.attributes || {}; return a.device_class === 'humidity' || (a.unit_of_measurement === '%' && /_humidity$/.test(eid)) || /_humidity$/.test(eid); } /** First readable humidity value (integer %) among the entities, or null. */ export function humFor(hass: any, entIds: string[]): number | null { for (const eid of entIds) { if (!isHumEntity(hass, eid)) continue; const st = hass.states[eid]; if (!st) continue; const v = parseFloat(st.state); if (!isNaN(v)) return Math.round(v); } return null; } /** Group light entities: HA light-group (platform=group) and Z2M groups (device model=Group). */ export function lightGroups(hass: any, enabled: boolean): { eid: string; name: string; area: string }[] { if (!enabled) return []; const res: { eid: string; name: string; area: string }[] = []; for (const [eid, reg] of Object.entries(hass.entities)) { if (!eid.startsWith('light.') || reg.hidden || !isRegistryEntryEnabled(reg)) continue; let area: string | null = null; if (reg.platform === 'group') { area = reg.area_id || null; } else if (reg.device_id) { const dev = hass.devices[reg.device_id]; if (!isRegistryEntryEnabled(dev)) continue; if (dev?.model === 'Group') area = dev.area_id || reg.area_id || null; else continue; } else { continue; } if (!area) continue; const st = hass.states[eid]; res.push({ eid, name: reg.name || st?.attributes?.friendly_name || eid, area }); } return res; } /** Icon with the full fallback chain: name rules → entity device_class → chip. */ export function resolveIcon(hass: any, name: string, model: string | undefined, entIds: string[], rules?: CompiledIconRule[]): string { const byRules = iconFor(name, model, rules); if (byRules !== FALLBACK_ICON) return byRules; const classes: string[] = []; for (const eid of entIds) { const dc = hass.states[eid]?.attributes?.device_class; if (dc) classes.push(dc); } return iconFromDeviceClasses(classes) ?? FALLBACK_ICON; } export interface RemovedPlanBindings { devices: Set; entities: Set; /** Exact live entity markers that intentionally override an entity or * parent-device tombstone without restoring the rest of that device. */ liveEntities: Set; } export interface DeletePlanMarkerResult { markers: Marker[]; cleanupIds: Set; } /** Remove every link to deleted marker ids without touching entity/unknown refs. */ export function removeMarkerControlReferences( markers: readonly Marker[], removedIds: ReadonlySet, ): Marker[] { return markers.map((marker) => { if (!Array.isArray(marker.controls)) return marker; const controls = marker.controls.filter((ref) => !(typeof ref === 'string' && ref.startsWith('marker:') && removedIds.has(ref.slice('marker:'.length))), ); return controls.length === marker.controls.length ? marker : { ...marker, controls: controls.length ? controls : null }; }); } /** Keep stable plan-source links when a marker id changes during rebind. */ export function rewriteMarkerControlReferences( markers: readonly Marker[], oldId: string, newId: string, ): Marker[] { if (!oldId || !newId || oldId === newId) return [...markers]; const from = `marker:${oldId}`; const to = `marker:${newId}`; return markers.map((marker) => { const controls = Array.isArray(marker.controls) ? marker.controls.map((ref) => ref === from ? to : ref) : marker.controls; const valueBadge = marker.value_badge?.source?.kind === 'derived_marker_state' && marker.value_badge.source.ref === from ? { ...marker.value_badge, source: { ...marker.value_badge.source, ref: to as `marker:${string}` }, } : marker.value_badge; const valueSource = marker.value_source?.kind === 'derived_marker_state' && marker.value_source.ref === from ? { ...marker.value_source, ref: to as `marker:${string}` } : marker.value_source; return controls === marker.controls && valueBadge === marker.value_badge && valueSource === marker.value_source ? marker // #385(б): conditional spreads — a marker that never had the key must // not gain `value_badge: undefined` / `value_source: undefined` (JSON // drops them, but hasOwnProperty semantics of the draft would differ). : { ...marker, controls, ...(valueBadge !== undefined ? { value_badge: valueBadge } : {}), ...(valueSource !== undefined ? { value_source: valueSource } : {}), }; }); } /** Whether adding controller -> target would close a directed marker cycle. */ export function markerControlWouldCycle( markers: readonly Pick[], controllerId: string, targetId: string, ): boolean { if (!controllerId || controllerId === targetId) return true; const graph = new Map(); for (const marker of markers) { graph.set(marker.id, (marker.controls || []) .filter((ref): ref is string => typeof ref === 'string' && ref.startsWith('marker:')) .map((ref) => ref.slice('marker:'.length))); } const seen = new Set(); const stack = [targetId]; while (stack.length) { const id = stack.pop()!; if (id === controllerId) return true; if (seen.has(id)) continue; seen.add(id); stack.push(...(graph.get(id) || [])); } return false; } /** Remove one marker binding without letting the shared literal `virtual` * identify every manual marker. Real HA bindings are unique and therefore * deduplicated; virtual markers have identity only by id. Non-virtual * deletion leaves a hidden tombstone so an older cached card degrades to a * hidden marker instead of visibly resurrecting it. */ export function deletePlanMarkerRecords( markers: readonly Marker[], id: string, binding: string, virtual: boolean, ): DeletePlanMarkerResult { const cleanupIds = new Set([id]); const kept = markers.filter((marker) => { const drop = marker.id === id || (!virtual && marker.binding === binding); if (drop) cleanupIds.add(marker.id); return !drop; }); return { markers: virtual ? kept : [...kept, { id, binding, removed: true, hidden: true }], cleanupIds, }; } /** Bindings explicitly deleted from the plan, kept as minimal tombstones. */ export function removedPlanBindings(markers?: readonly Marker[] | null): RemovedPlanBindings { const devices = new Set(); const entities = new Set(); const liveEntities = new Set(); for (const m of markers || []) { const i = String(m.binding || '').indexOf(':'); if (i < 1) continue; const kind = m.binding.slice(0, i); const ref = m.binding.slice(i + 1); if (!ref) continue; if (m?.removed !== true) { if (kind === 'entity') liveEntities.add(ref); continue; } if (kind === 'device') devices.add(ref); else if (kind === 'entity') entities.add(ref); } return { devices, entities, liveEntities }; } /** Whether an HA entity is suppressed by an entity or whole-device tombstone. */ export function isRemovedPlanEntity( hass: any, eid: string, removed: RemovedPlanBindings, ): boolean { // A person may restore one exact child without restoring its deleted parent. // This exception must stay binding-scoped: siblings remain suppressed by the // device tombstone until they receive their own live markers (#262). if (removed.liveEntities.has(eid)) return false; if (removed.entities.has(eid)) return true; const deviceId = hass?.entities?.[eid]?.device_id; return !!deviceId && removed.devices.has(deviceId); } /** Whether a `device:*` / `entity:*` source is deleted from the plan. */ export function isRemovedPlanSource( hass: any, source: string | null | undefined, markers?: readonly Marker[] | null, ): boolean { if (!source) return false; const i = source.indexOf(':'); if (i < 1) return false; const kind = source.slice(0, i); const ref = source.slice(i + 1); const removed = removedPlanBindings(markers); if (kind === 'device') return removed.devices.has(ref); return kind === 'entity' && isRemovedPlanEntity(hass, ref, removed); } function applyMarker( item: DevItem, m: Marker, hass: any, removed: RemovedPlanBindings, registry: HaRegistrySnapshot, ): void { const controls = effectiveMarkerControls(m.binding, m.controls, item.entities) .filter((eid) => !isRemovedPlanEntity(hass, eid, removed)) .filter((eid) => resolveHaBindingStatus(hass, 'entity:' + eid, registry).kind === 'active'); // Keep persisted configuration lossless. Runtime consumers use the filtered // projection above; the dialog edits the original list and therefore cannot // silently write a temporarily suppressed control out of the marker. item.marker = m; item.controls = controls; item.userHidden = m.hidden === true; item.hidden = item.userHidden || item.bindingStatus?.kind === 'ha_disabled'; if (m.name) item.name = m.name; if (m.icon) item.icon = m.icon; if (m.model != null) item.model = m.model; item.link = m.link ?? null; item.description = m.description ?? null; item.pdfs = m.pdfs || []; item.tapAction = m.tap_action ?? null; } /** * The SEEDER (docs/FILTERING.md): bindings of non-physical devices in bound * areas that have no marker yet. The editing client turns each into a * `hidden: true` stub marker — after that the flag belongs to the user, and a * marker of ANY kind (even `hidden: false`) makes the device invisible to * this function forever. Idempotent by construction. */ export function seedHiddenBindings(ctx: Omit): string[] { const baseHass = ctx.hass; const registry = ctx.registry || haRegistrySnapshot(baseHass); const h = activeRegistryHass(baseHass, registry); const fullHass = fullRegistryHass(baseHass, registry); const { areaToSpace, markers, settings, excluded, iconRules } = ctx; const groupLights = settings.group_lights !== false; const removed = removedPlanBindings(markers); const groups = lightGroups(h, groupLights) .filter((g) => !isRemovedPlanEntity(h, g.eid, removed)); const groupedAreas = new Set(groups.map((g) => g.area)); const entsBy = entitiesByDevice(h); const ownership = entityMarkerOwnership(markers, fullHass); const marked = new Set(markers.map((m) => m.binding)); const out: string[] = []; for (const dev of Object.values(h.devices)) { const area = dev.area_id; if (!area || !areaToSpace[area]) continue; if (dev.entry_type === 'service') continue; if (marked.has('device:' + dev.id)) continue; if (resolveHaBindingStatus(baseHass, 'device:' + dev.id, registry).kind !== 'active') continue; // An entity tombstone suppresses that standalone binding, not the same // entity as data belonging to a still-live parent device. const residual = residualAutoDeviceEntities(h, dev.id, entsBy[dev.id] || [], ownership); if (residual.partial && !residual.entityIds.length) continue; const entIds = residual.entityIds; const dom = domainOfDevice(h, dev, entIds); let nonPhysical = excluded.has(dom) || dev.model === 'Group' || /scene/i.test(dev.model || '') || /bridge/i.test((dev.model || '') + (dev.name || '')) || (dom === 'myheat' && !!dev.via_device_id); if (!nonPhysical && groupLights && groupedAreas.has(area)) { const name = (dev.name_by_user || dev.name || '').trim(); if (resolveIcon(h, name, dev.model, entIds, iconRules) === 'mdi:lightbulb') nonPhysical = true; } if (nonPhysical) out.push('device:' + dev.id); } return out; } function resolveExplicitMarkerPlacement( marker: Marker, registryArea: string | null | undefined, areaToSpace: Record, firstSpaceId: string, ): { area: string; space: string } { // A room id plus an explicit null Area is the persisted contract for a // manual room which has no HA Area. The registry still describes the HA // device, but must not move that marker back to its source room. const manualRoomWithoutArea = typeof marker.room_id === 'string' && marker.room_id.length > 0 && marker.area === null; if (manualRoomWithoutArea) { return { area: '', space: marker.space || firstSpaceId }; } const area = marker.area || registryArea || ''; return { area, space: (area && areaToSpace[area]) || marker.space || firstSpaceId, }; } /** Filtering + light groups + markers (metadata/rebinding) + virtual ones. A hybrid. */ export function buildDevices(ctx: BuildCtx): DevItem[] { const baseHass = ctx.hass; const registry = ctx.registry || haRegistrySnapshot(baseHass); const h = activeRegistryHass(baseHass, registry); const fullHass = fullRegistryHass(baseHass, registry); const { areaToSpace, markers, settings, excluded, showAll, firstSpaceId, loc, iconRules } = ctx; const groupLights = settings.group_lights !== false; const removed = removedPlanBindings(markers); const groups = lightGroups(h, groupLights) .filter((g) => !isRemovedPlanEntity(h, g.eid, removed)); const groupedAreas = new Set(groups.map((g) => g.area)); const entsBy = entitiesByDevice(h); const allEntsBy = allEntitiesByDevice(fullHass); const ownership = entityMarkerOwnership(markers, fullHass); const claimed = new Set(); for (const m of markers) { const [kind, ref] = m.binding.split(':'); if ((kind === 'device' || kind === 'entity') && ref) claimed.add(m.binding); } const markerFor = (kind: string, ref: string) => markers.find((m) => m.binding === kind + ':' + ref); const seen: Record = {}; const rest: DevItem[] = []; // 1) HA auto-discovered devices (not claimed by a marker, not hidden) for (const dev of Object.values(h.devices)) { const area = dev.area_id; if (!area || !areaToSpace[area]) continue; if (dev.entry_type === 'service') continue; if (claimed.has('device:' + dev.id)) continue; // a marker will take over below const bindingStatus = resolveHaBindingStatus(baseHass, 'device:' + dev.id, registry); if (bindingStatus.kind !== 'active') continue; const marker = markerFor('device', dev.id); if (marker && marker.hidden && !settings.filter_seeded) continue; // legacy: dropped entirely const residual = residualAutoDeviceEntities(h, dev.id, entsBy[dev.id] || [], ownership); if (residual.partial && !residual.entityIds.length) continue; const entIds = residual.entityIds; const itemBindingStatus = residual.partial ? { kind: 'active' as const, enabledEntityIds: entIds, allEntityIds: entIds } : bindingStatus; const dom = domainOfDevice(h, dev, entIds); // LEGACY runtime filter: only while the config is not yet materialised // (docs/FILTERING.md). A seeded config hides by explicit marker flags. const legacy = !settings.filter_seeded; if (legacy && !showAll) { if (excluded.has(dom)) continue; if (dev.model === 'Group') continue; if (/scene/i.test(dev.model || '')) continue; if (/bridge/i.test((dev.model || '') + (dev.name || ''))) continue; if (dom === 'myheat' && dev.via_device_id) continue; } const name = (dev.name_by_user || dev.name || loc('device.unnamed')).trim(); const key = name + '|' + area; let icon = resolveIcon(h, name, dev.model, entIds, iconRules); if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock'; if (legacy && !showAll && groupLights && icon === 'mdi:lightbulb' && groupedAreas.has(area)) continue; // duplicates by “name|zone” are numbered rather than hidden seen[key] = (seen[key] || 0) + 1; const dispName = seen[key] > 1 ? name + ' ' + seen[key] : name; const item: DevItem = { id: dev.id, name: dispName, model: dev.model || '', area, space: areaToSpace[area], icon, entities: entIds, allEntities: itemBindingStatus.allEntityIds, bindingStatus: itemBindingStatus, bindingKind: 'device', bindingRef: dev.id, pdfs: [], }; item.primary = primaryEntity(h, entIds, icon); if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, entIds); if (item.primary && isHumEntity(h, item.primary)) item.hum = humFor(h, entIds); rest.push(item); } // 2) light groups (not claimed by a marker) for (const g of groups) { if (!areaToSpace[g.area]) continue; if (claimed.has('entity:' + g.eid)) continue; rest.push({ id: 'lg_' + g.eid, name: g.name, model: loc('device.light_group'), area: g.area, space: areaToSpace[g.area], icon: 'mdi:lightbulb-group', entities: [g.eid], allEntities: [g.eid], bindingStatus: { kind: 'active', enabledEntityIds: [g.eid], allEntityIds: [g.eid] }, primary: g.eid, bindingKind: 'entity', bindingRef: g.eid, pdfs: [], }); } // 3) explicit markers (rebinding/metadata/virtual) for (const m of markers) { if (m.removed) continue; const [kind, ref] = m.binding.split(':'); const markerBindingStatus = kind === 'device' || kind === 'entity' ? resolveHaBindingStatus(baseHass, m.binding, registry) : null; // Hidden is a FLAG now, not an absence: the device is built (room LQI // still counts it) and the renderer decides. Legacy configs keep the old // "hidden = gone" until they are seeded (docs/FILTERING.md). // HA-disabled still needs a manageable ghost even during that one-time // legacy transition; it must not disappear just because user hidden was // already true before the seeder ran. if (m.hidden && !settings.filter_seeded && markerBindingStatus?.kind !== 'ha_disabled') continue; if (kind === 'device') { const bindingStatus = markerBindingStatus!; if (bindingStatus.kind === 'unverified') continue; const dev = fullHass.devices[ref]; const { area, space } = resolveExplicitMarkerPlacement( m, dev?.area_id, areaToSpace, firstSpaceId, ); const entIds = bindingStatus.kind === 'active' ? bindingStatus.enabledEntityIds : []; let icon = dev ? resolveIcon(h, dev.name_by_user || dev.name || '', dev.model, entIds, iconRules) : 'mdi:help-circle'; if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock'; const item: DevItem = { id: m.id, name: dev?.name_by_user || dev?.name || loc('device.fallback'), model: dev?.model || '', area, space, icon, entities: entIds, allEntities: bindingStatus.allEntityIds.length ? bindingStatus.allEntityIds : (dev ? (allEntsBy[dev.id] || []) : []), bindingStatus, bindingKind: 'device', bindingRef: ref, }; item.primary = primaryEntity(h, entIds, icon); if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, entIds); if (item.primary && isHumEntity(h, item.primary)) item.hum = humFor(h, entIds); applyMarker(item, m, fullHass, removed, registry); rest.push(item); } else if (kind === 'entity') { if (isRemovedPlanEntity(fullHass, ref, removed)) continue; const bindingStatus = markerBindingStatus!; if (bindingStatus.kind === 'unverified') continue; const reg = fullHass.entities[ref]; const registryArea = reg?.area_id || (reg?.device_id && fullHass.devices[reg.device_id]?.area_id) || ''; const { area, space } = resolveExplicitMarkerPlacement( m, registryArea, areaToSpace, firstSpaceId, ); const st = h.states[ref]; const nm = reg?.name || st?.attributes?.friendly_name || ref; let icon = resolveIcon(h, nm, '', [ref], iconRules); if (ref.startsWith('lock.')) icon = 'mdi:lock'; const item: DevItem = { id: m.id, name: nm, model: '', area, space, icon, entities: bindingStatus.kind === 'active' ? [ref] : [], allEntities: [ref], bindingStatus, primary: bindingStatus.kind === 'active' ? ref : undefined, bindingKind: 'entity', bindingRef: ref, }; if ((icon === 'mdi:thermometer' || icon === 'mdi:air-filter') && item.entities.length) { item.temp = tempFor(h, item.entities); } if (item.entities.length && isHumEntity(h, ref)) item.hum = humFor(h, item.entities); applyMarker(item, m, fullHass, removed, registry); rest.push(item); } else { // virtual const area = m.area || ''; const space = m.space || (area && areaToSpace[area]) || firstSpaceId; const item: DevItem = { id: m.id, name: m.name || loc('device.virtual'), model: m.model || '', area, space, icon: m.icon || 'mdi:map-marker', entities: [], allEntities: [], bindingStatus: { kind: 'active', enabledEntityIds: [], allEntityIds: [] }, bindingKind: 'virtual', virtual: true, }; applyMarker(item, m, fullHass, removed, registry); rest.push(item); } } return rest; } /** * Build one unsaved marker with the exact same registry, filtering, role and * icon rules as the plan. The editor memoizes this call; keeping it as a thin * projection over buildDevices prevents a second, subtly different preview * implementation from growing beside the runtime one. */ export function deviceFromMarkerDraft( ctx: Omit & { marker: Marker; /** The persisted roster whose tombstones/ownership also constrain the plan. */ siblingMarkers?: readonly Marker[]; }, ): DevItem | null { const { marker, siblingMarkers = [], ...base } = ctx; const markers = [ ...siblingMarkers.filter((item) => item.id !== marker.id), marker, ]; return buildDevices({ ...base, markers }) .find((device) => device.id === marker.id) || null; } /** * Light situation of an area: 'on' if any light entity of the area's devices is on, * 'off' if lights exist but none is on, 'none' when the area has no lights at all. */ export function areaLights(hass: any, devices: readonly LightSourceDevice[], area: string): 'on' | 'off' | 'none' { return resolvedLightState(resolvedLightSources(hass, devices, area)); } /** * Explicit room measurement source: 'entity:' reads the state as a * number; 'device:' aggregates over that device's entities (tempFor / * humFor). Used by the room-settings override (tier 3). */ export function sourceValue( hass: any, src: string | null | undefined, kind: 'temp' | 'hum', markers?: readonly Marker[] | null, ): number | null { if (!src) return null; if (isRemovedPlanSource(hass, src, markers)) return null; const i = src.indexOf(':'); if (i < 0) return null; const k = src.slice(0, i); const ref = src.slice(i + 1); if (!ref) return null; if (k === 'entity') { const v = parseFloat(hass.states[ref]?.state); if (!Number.isFinite(v)) return null; return kind === 'temp' ? Math.round(v * 10) / 10 : Math.round(v); } if (k === 'device') { const entIds = Object.entries(hass.entities as Record) .filter(([, r]) => (r as any).device_id === ref) .map(([eid]) => eid); return kind === 'temp' ? tempFor(hass, entIds) : humFor(hass, entIds); } return null; } /** Average humidity across the area's climate-ish devices (integer %, or null). */ export function areaHum( hass: any, devices: { area: string; icon?: string; entities: string[] }[], area: string, ): number | null { const vals: number[] = []; for (const dv of devices) { if (dv.area !== area) continue; // same filtering idea as areaTemp: climate sensors only, not fridges/plugs if (dv.icon !== 'mdi:thermometer' && dv.icon !== 'mdi:air-filter' && dv.icon !== 'mdi:water-percent') continue; const h = humFor(hass, dv.entities); if (h != null) vals.push(h); } if (!vals.length) return null; return Math.round(vals.reduce((a, b) => a + b, 0) / vals.length); } /** * Entity ids that measure something other than room air, however honest their * device_class is: water and coolant loops, chip/CPU/board temperatures, * battery temperature, setpoints (target/external) and the like. */ const NON_AIR_RE = new RegExp( [ 'water', 'voda', 'coolant', 'flow_?temp', 'return_?temp', 'target', 'setpoint', 'chip', 'cpu', 'processor', 'board', 'core_temp', 'device_temp', 'batter', 'akkum', 'freezer', 'fridge', 'oven', 'kettle', 'boiler', ].join('|'), 'i', ); /** * Room climate from EVERY sensor of the area — including devices that are not * placed on the plan (hidden by filtering or by the user). The old helpers read * the visible-icon list, so hiding a thermometer silently removed it from the * room card (field report, 2026-07-27). * * Filtering is kept: only devices the card itself recognises as thermometers / * air monitors count, so fridges, TRVs and chip-temperature plugs stay out. * The AUTO icon is used on purpose — a custom marker icon must not change what * a device measures. */ export interface AreaClimate { temp: number | null; hum: number | null } /** * Stable lookup key for one room's automatic climate aggregate. * * HA-area rooms deliberately keep their historical raw area id. A local room * has no HA identity, so its key is namespaced by both space and room id; room * ids alone are not globally unique across imported spaces. */ export function roomClimateKey( spaceId: string | null | undefined, room: { id?: string | null; area?: string | null }, ): string | null { if (room.area) return room.area; if (!spaceId || !room.id) return null; return `@room/${encodeURIComponent(spaceId)}/${encodeURIComponent(room.id)}`; } function markerClimateTarget(marker: Marker): string | null { // Persisted local-room placement uses an explicit null Area. Do not treat a // stale room_id without its space as a valid target or let it collide with a // room imported into another space. // #369(в): legacy/import configs carry an ABSENT area key, not an explicit // null — both mean "no HA Area", so both must resolve to the room placement. if (marker.area == null && marker.space && marker.room_id) { return roomClimateKey(marker.space, { id: marker.room_id, area: null }); } return marker.area || null; } /** * Climate for EVERY area in one registry pass (review R2-3). * * The per-area version below rescanned the whole registry for each room and * each measurement: with 60 rooms and 2000 entities that is 120 traversals per * render — an entire frame spent re-reading metadata that did not change. The * caller computes this map once per `hass` snapshot and looks rooms up in O(1). */ /** #44: THE resolver of the effective excluded-integration set. Absence of * the key means the product list; a present list REPLACES it wholesale * (including the valid empty list = "exclude nothing"). Every consumer — * discovery, static render, room climate — must read through here. */ export function effectiveExcludedIntegrations( settings: { exclude_integrations?: string[] } | null | undefined, ): ReadonlySet { const list = settings?.exclude_integrations; return list ? new Set(list) : EXCLUDED_DOMAINS; } export function roomClimateMap( hass: BuildCtx['hass'], rules?: CompiledIconRule[], markers?: Marker[] | null, // #44 H2: room climate follows the SAME user-configurable exclusion set as // discovery; the default keeps today's behaviour byte-for-byte. excluded: ReadonlySet = EXCLUDED_DOMAINS, ): Map { const out = new Map(); if (!hass?.entities) return out; // Opt-in climate sources (marker.use_climate_temp): an AC or thermostat // knows the room temperature (current_temperature) - when the user ticks // the option, that reading votes in the room average like any thermometer. // The set holds binding refs (device ids and entity ids); with no opted // markers the pass below is byte-for-byte the old one. const removed = removedPlanBindings(markers); const climOpt = new Set(); const entityTargets = new Map(); const deviceTargets = new Map(); for (const m of markers || []) { if (m?.removed) continue; const i = (m.binding || '').indexOf(':'); if (i <= 0) continue; const kind = m.binding.slice(0, i); const ref = m.binding.slice(i + 1); if (!ref) continue; if (m.use_climate_temp === true) climOpt.add(ref); const target = markerClimateTarget(m); if (!target) continue; // Match buildDevices(): malformed duplicate live bindings resolve to the // first saved marker, never to two rooms and never to two votes. if (kind === 'entity' && !entityTargets.has(ref)) entityTargets.set(ref, target); else if (kind === 'device' && !deviceTargets.has(ref)) deviceTargets.set(ref, target); } // effective room target -> physical device (or lone entity) -> entities. // Choosing the target before grouping guarantees that a manually moved // reading cannot remain in its registry Area at the same time (#317). const byTarget = new Map>(); for (const [eid, reg] of Object.entries(hass.entities)) { // A device tombstone suppresses all of its data. An entity tombstone only // suppresses a standalone entity; inside its live parent device it remains // available to device state, cards and room aggregates. if ((reg.device_id && removed.devices.has(reg.device_id) && !removed.liveEntities.has(eid)) || (!reg.device_id && removed.entities.has(eid) && !removed.liveEntities.has(eid))) continue; const dev = reg.device_id ? hass.devices?.[reg.device_id] : null; if (!isRegistryEntryEnabled(reg) || (dev && !isRegistryEntryEnabled(dev))) continue; const target = entityTargets.get(eid) || (reg.device_id ? deviceTargets.get(reg.device_id) : null) || reg.area_id || dev?.area_id || null; if (!target) continue; // Not every "temperature" is room air. Real finds on a live install: the // NAS processor temperature, the water in a smart kettle, a sauna heater at // 90 C and a virtual better_thermostat duplicating the real sensor (field // question, 2026-07-27). Three guards, cheapest first: if (reg.entity_category) continue; // diagnostic/config readings // An explicitly opted climate entity skips the platform/name filters: // the tick is the user saying "this one DOES measure room air" - even on // an excluded platform (better_thermostat) or a suspicious entity id. const optClimate = climOpt.size > 0 && eid.startsWith('climate.') && (climOpt.has(eid) || (reg.device_id && climOpt.has(reg.device_id))); if (!optClimate) { if (excluded.has(reg.platform)) continue; // filtered-out integrations (#44: user-configurable) if (NON_AIR_RE.test(eid)) continue; // water/chip/flow/target/... } let groups = byTarget.get(target); if (!groups) { groups = new Map(); byTarget.set(target, groups); } const key = reg.device_id || eid; let g = groups.get(key); if (!g) { const st = hass.states?.[eid]; g = { name: (dev ? dev.name_by_user || dev.name : reg.name || st?.attributes?.friendly_name || eid) || eid, model: dev?.model, ents: [], }; groups.set(key, g); } g.ents.push(eid); } for (const [target, groups] of byTarget) { const temps: number[] = []; const hums: number[] = []; for (const [key, g] of groups) { const icon = resolveIcon(hass, g.name, g.model, g.ents, rules); const air = icon === 'mdi:thermometer' || icon === 'mdi:air-filter'; if (air) { const t = tempFor(hass, g.ents); if (t != null) temps.push(t); } // opted climate device/entity: current_temperature joins the average if (climOpt.size > 0 && (climOpt.has(key) || g.ents.some((e) => climOpt.has(e)))) { const t = climateTempFor(hass, g.ents); if (t != null) temps.push(t); } if (air || icon === 'mdi:water-percent') { const h = humFor(hass, g.ents); if (h != null) hums.push(h); } } if (!temps.length && !hums.length) continue; out.set(target, { temp: temps.length ? Math.round((temps.reduce((a, b) => a + b, 0) / temps.length) * 10) / 10 : null, hum: hums.length ? Math.round(hums.reduce((a, b) => a + b, 0) / hums.length) : null, }); } return out; } /** * Historical area-only name retained for callers and integrations. The map * still uses raw HA area ids; when explicit local-room markers are supplied it * may additionally contain namespaced room keys returned by roomClimateKey(). */ export function areaClimateMap( hass: BuildCtx['hass'], rules?: CompiledIconRule[], markers?: Marker[] | null, ): Map { return roomClimateMap(hass, rules, markers); } /** One area's reading. Convenience wrapper — prefer the map for many areas. */ export function areaClimate( hass: any, area: string, kind: 'temp' | 'hum', rules?: CompiledIconRule[], ): number | null { if (!area) return null; return areaClimateMap(hass, rules).get(area)?.[kind] ?? null; } /** How many of the area's lights are on: {on, total}, or null without lights. */ export function areaLightStats( hass: any, devices: readonly LightSourceDevice[], area: string, ): { on: number; total: number } | null { return resolvedLightStats(resolvedLightSources(hass, devices, area)); } /** Average temperature across the area's devices (null when nothing reports one). */ /** Average zigbee signal (LQI) across an area's non-virtual devices, or null. */ export function areaLqi(hass: any, devices: { area: string; virtual?: boolean; entities: string[] }[], area: string): number | null { const vals: number[] = []; for (const d of devices) { if (d.area !== area || d.virtual) continue; const l = lqiFor(hass, d.entities); if (l != null) vals.push(l); } return averageLqi(vals); } export function areaTemp( hass: any, devices: { area: string; icon?: string; entities: string[] }[], area: string, ): number | null { const vals: number[] = []; for (const d of devices) { if (d.area !== area) continue; // Учитываем только устройства, которые сама карточка считает термометрами — // не холодильники, термоголовки, розетки с температурой чипа и т.п. if (d.icon !== 'mdi:thermometer' && d.icon !== 'mdi:air-filter') continue; const t = tempFor(hass, d.entities); if (t != null) vals.push(t); } if (!vals.length) return null; return Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 10) / 10; }