Files
houseplan-card/src/devices.ts
T
Codex 8d431d6ddf feat: discovery filters become visible, explained and previewable (#44)
Light grouping and the excluded-integrations list move from hidden keys
to a Discovery-filters section on the catalog's Available tab: a toggle
(unset = on, the legacy behaviour), searchable integration chips with a
Restore-recommended reset (defaults stored as key absence), and
appear/disappear counters computed by diffing the REAL
seedHiddenBindings/buildDevices outputs — no second copy of the filter.
Saving writes settings once over the ordinary expected_rev path. Every
excluded candidate now names its integration in the catalog. Room
climate follows the same user exclusions through the single
effectiveExcludedIntegrations resolver (spec H2); explicit climate
opt-in stays stronger. The field registry passports both keys as
current supported settings.

User-Visible: yes
Issue: #44
2026-08-30 14:03:35 +03:00

1609 lines
68 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, string>;
markers: Marker[];
settings: ServerConfig['settings'];
excluded: ReadonlySet<string>;
/** 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<string, string[]> {
const map: Record<string, string[]> = {};
for (const [eid, ent] of Object.entries<any>(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<string, string[]> {
const map: Record<string, string[]> = {};
for (const [eid, ent] of Object.entries<any>(hass.entities || {})) {
if (ent?.device_id) (map[ent.device_id] = map[ent.device_id] || []).push(eid);
}
return map;
}
interface EntityMarkerOwnership {
byDevice: ReadonlyMap<string, ReadonlySet<string>>;
}
/**
* 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<string, Set<string>>();
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<string>();
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<D extends LightSourceDevice = LightSourceDevice> {
/** 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<D extends LightSourceDevice = LightSourceDevice> {
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<D extends LightSourceDevice = LightSourceDevice> {
markerId: string;
controllers: IncomingLightController<D>[];
/** 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<D extends LightSourceDevice> {
fingerprint: string;
visible: D[];
markerById: Map<string, D>;
persistedByDevice: Map<D, string[]>;
incomingByMarker: Map<string, IncomingLightControl<D>>;
}
const LIGHT_GRAPH_CACHE = new WeakMap<object, CachedLightGraph<any>>();
interface CachedResolvedLightSources<D extends LightSourceDevice> {
graphFingerprint: string;
stateFingerprint: string;
virtualLightFingerprint: string;
registry: unknown;
sources: ResolvedLightSource<D>[];
}
const RESOLVED_LIGHT_CACHE = new WeakMap<object, CachedResolvedLightSources<any>>();
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
? '<runtime-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
? '<persisted-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<string>();
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 ?? '<missing>'}`).join('|');
}
function lightGraphOf<D extends LightSourceDevice>(devices: readonly D[]): CachedLightGraph<D> {
const fingerprint = lightGraphFingerprint(devices);
const previous = LIGHT_GRAPH_CACHE.get(devices as object) as CachedLightGraph<D> | undefined;
if (previous?.fingerprint === fingerprint) return previous;
const visible = devices.filter((device) => !device.hidden);
const markerById = new Map<string, D>();
const persistedByDevice = new Map<D, string[]>();
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<string, IncomingLightControl<D>>();
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<D extends LightSourceDevice>(
devices: readonly D[],
): ReadonlyMap<string, IncomingLightControl<D>> {
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<D extends LightSourceDevice>(
hass: any,
devices: readonly D[],
room?: LightSourceRoom | null,
virtualLights?: VirtualLightSnapshot | null,
): ResolvedLightSource<D>[] {
// 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<D> | undefined;
if (cached?.graphFingerprint === graphFingerprint
&& cached.stateFingerprint === stateFingerprint
&& cached.virtualLightFingerprint === manualFingerprint
&& cached.registry === hass?.entities) return cached.sources;
type Candidate = ResolvedLightSource<D>;
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<D, Candidate[]>();
const ownerByEntity = new Map<string, Candidate>();
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<D, Set<string>>();
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<string, Candidate>();
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<D extends LightSourceDevice>(
sources: readonly ResolvedLightSource<D>[],
): ResolvedLightSource<D> | 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<LightSourceDevice, 'area'> & { 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<any>(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<string>;
entities: Set<string>;
/** Exact live entity markers that intentionally override an entity or
* parent-device tombstone without restoring the rest of that device. */
liveEntities: Set<string>;
}
export interface DeletePlanMarkerResult {
markers: Marker[];
cleanupIds: Set<string>;
}
/** Remove every link to deleted marker ids without touching entity/unknown refs. */
export function removeMarkerControlReferences(
markers: readonly Marker[], removedIds: ReadonlySet<string>,
): 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<Marker, 'id' | 'controls'>[], controllerId: string, targetId: string,
): boolean {
if (!controllerId || controllerId === targetId) return true;
const graph = new Map<string, string[]>();
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<string>();
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<string>([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<string>();
const entities = new Set<string>();
const liveEntities = new Set<string>();
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<BuildCtx, 'showAll' | 'loc'>): 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<any>(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<string, string>,
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<string>();
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<string, number> = {};
const rest: DevItem[] = [];
// 1) HA auto-discovered devices (not claimed by a marker, not hidden)
for (const dev of Object.values<any>(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<BuildCtx, 'markers'> & {
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:<eid>' reads the state as a
* number; 'device:<id>' 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<string, any>)
.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<string> {
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<string> = EXCLUDED_DOMAINS,
): Map<string, AreaClimate> {
const out = new Map<string, AreaClimate>();
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<string>();
const entityTargets = new Map<string, string>();
const deviceTargets = new Map<string, string>();
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<string, Map<string, { name: string; model?: string; ents: string[] }>>();
for (const [eid, reg] of Object.entries<any>(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<string, AreaClimate> {
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;
}