mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
After v1.44.5 read the area registry instead of visible icons, every hidden temperature entity in the area became a candidate, including ones measuring something other than room air. Verified against a live 60-area install: a NAS processor temperature, kettle water, a 90 C sauna heater and a virtual better_thermostat all leaked into room averages. - areaClimate(): skip entity_category (diagnostic/config), skip EXCLUDED_DOMAINS platforms, skip entity ids naming a non-air medium (water/coolant/flow_temp/ return_temp/target/setpoint/chip/cpu/processor/board/device_temp/batter/ freezer/fridge/oven/kettle/boiler). - rules.ts: kettle/thermopot -> mdi:kettle, sauna/harvia -> mdi:hot-tub, so they no longer fall through to the generic thermometer rule. - test: all four real false positives asserted out, one real sensor left. - docs: CHANGELOG.md + CHANGELOG.ru.md + STATUS.md snapshot.
518 lines
20 KiB
TypeScript
518 lines
20 KiB
TypeScript
/**
|
|
* Building the device list from HA registries: curation, 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 } from './logic';
|
|
import type { DevItem, Marker, ServerConfig } from './types';
|
|
|
|
/** Build context: a slice of hass + config resolution. */
|
|
export interface BuildCtx {
|
|
hass: any;
|
|
/** area_id → space_id (only zones bound to rooms). */
|
|
areaToSpace: Record<string, string>;
|
|
markers: Marker[];
|
|
settings: ServerConfig['settings'];
|
|
excluded: Set<string>;
|
|
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) (map[ent.device_id] = map[ent.device_id] || []).push(eid);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
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)
|
|
);
|
|
}
|
|
|
|
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);
|
|
// Tiers: visible primary entities first, but a HIDDEN light still beats a
|
|
// visible config switch. Real case: individual lamps folded into a light
|
|
// group get hidden in the registry — the device's main function is still
|
|
// the lamp, and tap-toggle must flip IT, not the do-not-disturb switch.
|
|
const tiers = [
|
|
all.filter((e) => !e.reg.hidden && !e.reg.entity_category),
|
|
all.filter((e) => !e.reg.hidden),
|
|
all.filter((e) => !e.reg.entity_category),
|
|
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;
|
|
}
|
|
}
|
|
for (const dom of DOMAIN_PRIORITY) {
|
|
for (const tier of tiers) {
|
|
const found = tier.find((e) => e.eid.split('.')[0] === dom);
|
|
if (found) return found.eid;
|
|
}
|
|
}
|
|
for (const tier of tiers) if (tier.length) return tier[0].eid;
|
|
return undefined;
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/** 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) 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 (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;
|
|
}
|
|
|
|
function applyMarker(item: DevItem, m: Marker): void {
|
|
item.marker = m;
|
|
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;
|
|
}
|
|
|
|
/** Curation + light groups + markers (metadata/rebinding) + virtual ones. A hybrid. */
|
|
export function buildDevices(ctx: BuildCtx): DevItem[] {
|
|
const { hass: h, areaToSpace, markers, settings, excluded, showAll, firstSpaceId, loc, iconRules } = ctx;
|
|
const groupLights = settings.group_lights !== false;
|
|
const groups = lightGroups(h, groupLights);
|
|
const groupedAreas = new Set(groups.map((g) => g.area));
|
|
const entsBy = entitiesByDevice(h);
|
|
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 marker = markerFor('device', dev.id);
|
|
if (marker && marker.hidden) continue;
|
|
const entIds = entsBy[dev.id] || [];
|
|
const dom = domainOfDevice(h, dev, entIds);
|
|
// curation (can be turned off with the “show all” toggle)
|
|
if (!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 (!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,
|
|
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],
|
|
primary: g.eid,
|
|
bindingKind: 'entity',
|
|
bindingRef: g.eid,
|
|
pdfs: [],
|
|
});
|
|
}
|
|
|
|
// 3) explicit markers (rebinding/metadata/virtual)
|
|
for (const m of markers) {
|
|
if (m.hidden) continue;
|
|
const [kind, ref] = m.binding.split(':');
|
|
if (kind === 'device') {
|
|
const dev = h.devices[ref];
|
|
const area = m.area || dev?.area_id || '';
|
|
const space = (area && areaToSpace[area]) || m.space || firstSpaceId;
|
|
const entIds = dev ? entsBy[dev.id] || [] : [];
|
|
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,
|
|
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);
|
|
if (item.primary && isHumEntity(h, item.primary)) item.hum = humFor(h, entIds);
|
|
applyMarker(item, m);
|
|
rest.push(item);
|
|
} else if (kind === 'entity') {
|
|
const reg = h.entities[ref];
|
|
const area = m.area || reg?.area_id || (reg?.device_id && h.devices[reg.device_id]?.area_id) || '';
|
|
const space = (area && areaToSpace[area]) || m.space || 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: [ref],
|
|
primary: ref,
|
|
bindingKind: 'entity',
|
|
bindingRef: ref,
|
|
};
|
|
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, [ref]);
|
|
if (isHumEntity(h, ref)) item.hum = humFor(h, [ref]);
|
|
applyMarker(item, m);
|
|
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: [],
|
|
bindingKind: 'virtual',
|
|
virtual: true,
|
|
};
|
|
applyMarker(item, m);
|
|
rest.push(item);
|
|
}
|
|
}
|
|
return rest;
|
|
}
|
|
|
|
/**
|
|
* 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: { area: string; entities: string[] }[], area: string): 'on' | 'off' | 'none' {
|
|
let seen = false;
|
|
for (const d of devices) {
|
|
if (d.area !== area) continue;
|
|
for (const eid of d.entities) {
|
|
if (!eid.startsWith('light.')) continue;
|
|
seen = true;
|
|
if (hass.states[eid]?.state === 'on') return 'on';
|
|
}
|
|
}
|
|
return seen ? 'off' : 'none';
|
|
}
|
|
|
|
/**
|
|
* 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'): number | null {
|
|
if (!src) 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 curation 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 curation 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).
|
|
*
|
|
* Curation 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 function areaClimate(
|
|
hass: any, area: string, kind: 'temp' | 'hum', rules?: CompiledIconRule[],
|
|
): number | null {
|
|
if (!area || !hass?.entities) return null;
|
|
const groups = new Map<string, { name: string; model?: string; ents: string[] }>();
|
|
for (const [eid, reg] of Object.entries<any>(hass.entities)) {
|
|
const dev = reg.device_id ? hass.devices?.[reg.device_id] : null;
|
|
const entArea = reg.area_id || dev?.area_id || null;
|
|
if (entArea !== area) 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
|
|
if (EXCLUDED_DOMAINS.has(reg.platform)) continue; // curated-out integrations
|
|
if (NON_AIR_RE.test(eid)) continue; // water/chip/flow/target/...
|
|
const key = reg.device_id || eid;
|
|
if (!groups.has(key)) {
|
|
const st = hass.states?.[eid];
|
|
groups.set(key, {
|
|
name: (dev ? dev.name_by_user || dev.name : reg.name || st?.attributes?.friendly_name || eid) || eid,
|
|
model: dev?.model,
|
|
ents: [],
|
|
});
|
|
}
|
|
groups.get(key)!.ents.push(eid);
|
|
}
|
|
const vals: number[] = [];
|
|
for (const g of groups.values()) {
|
|
const icon = resolveIcon(hass, g.name, g.model, g.ents, rules);
|
|
const ok = kind === 'temp'
|
|
? icon === 'mdi:thermometer' || icon === 'mdi:air-filter'
|
|
: icon === 'mdi:thermometer' || icon === 'mdi:air-filter' || icon === 'mdi:water-percent';
|
|
if (!ok) continue;
|
|
const v = kind === 'temp' ? tempFor(hass, g.ents) : humFor(hass, g.ents);
|
|
if (v != null) vals.push(v);
|
|
}
|
|
if (!vals.length) return null;
|
|
const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
|
|
return kind === 'temp' ? Math.round(avg * 10) / 10 : Math.round(avg);
|
|
}
|
|
|
|
/** How many of the area's lights are on: {on, total}, or null without lights. */
|
|
export function areaLightStats(
|
|
hass: any,
|
|
devices: { area: string; entities: string[] }[],
|
|
area: string,
|
|
): { on: number; total: number } | null {
|
|
const seen = new Set<string>();
|
|
let on = 0;
|
|
for (const dv of devices) {
|
|
if (dv.area !== area) continue;
|
|
for (const eid of dv.entities) {
|
|
if (!eid.startsWith('light.') || seen.has(eid)) continue;
|
|
seen.add(eid);
|
|
if (hass.states[eid]?.state === 'on') on++;
|
|
}
|
|
}
|
|
return seen.size ? { on, total: seen.size } : null;
|
|
}
|
|
|
|
/** 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;
|
|
}
|