yellow means working: one principle for the glowing icon
Validate / hacs (push) Failing after 6s
Validate / hassfest (push) Failing after 7s
Validate / frontend (push) Failing after 1m33s
Validate / smoke (push) Skipped
Validate / backend (push) Failing after 6m17s

Research on the owner's install (verified live): the radiator heads that
glowed yellow were the ones with SCALE PROTECTION on, and the ones actually
heating stayed dark. Cause: the primary-entity search ran domains outside
tiers, and switch outranks climate — so a vendor's config switch (anti
scaling, child lock) became the device's primary, driving the color, the
icon morphing and tap-toggle alike.

The principle now: yellow = the device is doing its main job RIGHT NOW.

- primaryEntity: tiers outside, domains inside — a service entity never
  beats the visible main function; a hidden lamp still beats a visible
  config switch (grouped lights), and a plug's switch stays primary.
- climate joins the state table: yellow by hvac_action (heating/cooling/
  drying/fan) — 'which radiators are heating', not 'enabled for winter';
  the coarser state is only a fallback when the integration reports no
  action.
- one truth for light: litLightEntity() is asked by BOTH the glow pool and
  the icon color, in every fill mode — the pool and the icon can no longer
  disagree. The 'is a light source' flag keeps counting controls first.
- README (en+ru): the color table, in words.

Tests: TRV + plug primary units, litLightEntity unit, smoke_yellow_principle
(heating yellow / idle dark / off dark / fallback / lit-light wins / forced
source). Inventory: 142 / 51 / 43 / 67.
This commit is contained in:
Matysh
2026-07-29 11:07:34 +03:00
parent 098a147f87
commit 996a7442ec
9 changed files with 198 additions and 46 deletions
+34 -7
View File
@@ -56,14 +56,22 @@ export function primaryEntity(hass: any, entIds: string[], icon: string): string
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.
// Tiers, in order: functional and visible; functional but hidden (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); config/diagnostic but visible; everything.
//
// The TIER loop is the OUTER one. It used to be inner, which made the
// domain priority stronger than visibility — and `switch` outranks
// `climate`, so a TRV's primary was whatever service switch the vendor
// ships (child lock, anti-scaling): the icon glowed yellow because scale
// protection was on, while the head that was actually HEATING stayed dark
// (owner's install, verified live 2026-07-29). A service entity must never
// beat the device's visible main function.
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.filter((e) => !e.reg.hidden),
all,
];
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') {
@@ -72,16 +80,35 @@ export function primaryEntity(hass: any, entIds: string[], icon: string): string
if (t) return t.eid;
}
}
for (const dom of DOMAIN_PRIORITY) {
for (const tier of tiers) {
for (const tier of tiers) {
for (const dom of DOMAIN_PRIORITY) {
const found = tier.find((e) => e.eid.split('.')[0] === dom);
if (found) return found.eid;
}
}
// no known domain anywhere — first entity of the best non-empty tier
for (const tier of tiers) if (tier.length) return tier[0].eid;
return undefined;
}
/**
* The lit light entity of a device, or null — ONE truth for "this thing is
* shining": the glow-mode pool and the yellow icon both ask here, so the
* light pool and the icon can never disagree (owner's principle, 2026-07-29).
* Normally that is a lit `light.*`; with the "is a light source" flag (a smart
* switch driving dumb fixtures) any lit entity counts — the switch itself, or
* the lights it controls when they are bound.
*/
export function litLightEntity(
hass: any, d: { entities: string[]; marker?: { is_light?: boolean | null; controls?: string[] | null } | null },
): string | null {
const forced = d.marker?.is_light === true;
const pool = forced
? [...(d.marker?.controls || []), ...d.entities]
: d.entities.filter((e) => e.startsWith('light.'));
return pool.find((e) => hass.states[e]?.state === 'on') || 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[] = [];
+15 -6
View File
@@ -25,7 +25,7 @@ import {
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES, ROOM_FILL_MODES,
} from './logic';
import { ContentSigner } from './signing';
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, type AreaClimate } from './devices';
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, litLightEntity, type AreaClimate } from './devices';
import type {
OpeningCfg,
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
@@ -1157,6 +1157,10 @@ class HouseplanCard extends LitElement {
const controls = (d.marker?.controls || []).filter(isControllable);
if (controls.length)
return controls.some((e) => this.hass.states[e]?.state === 'on') ? 'on' : '';
// A shining light makes its icon yellow in EVERY fill mode — and it is
// the same condition that lights the glow pool, so the pool and the icon
// cannot disagree (they used to read different entities).
if (litLightEntity(this.hass, d)) return 'on';
const p = d.primary ? this.hass.states[d.primary] : undefined;
if (!p) return '';
if (p.state === 'unavailable') return 'unavail';
@@ -1165,6 +1169,14 @@ class HouseplanCard extends LitElement {
// TESTING.md edge-case run)
const dom = d.primary!.split('.')[0];
if (['light', 'switch', 'fan', 'humidifier'].includes(dom)) return p.state === 'on' ? 'on' : '';
if (dom === 'climate') {
// yellow = actually working right now ("which radiators are heating"),
// not "enabled for the winter": hvac_action when the integration
// reports one, the coarser state only as a fallback
const act = p.attributes?.hvac_action;
if (act != null) return ['heating', 'cooling', 'drying', 'fan'].includes(act) ? 'on' : '';
return ['off', 'unknown'].includes(p.state) ? '' : 'on';
}
if (dom === 'cover' || dom === 'valve') return ['open', 'opening'].includes(p.state) ? 'open' : '';
if (dom === 'lock') return ['unlocked', 'open'].includes(p.state) ? 'open' : '';
if (dom === 'binary_sensor') {
@@ -3604,11 +3616,8 @@ class HouseplanCard extends LitElement {
// "is a light source" flag (field request: a smart SWITCH driving dumb
// fixtures) any lit entity counts — the switch itself, or the lights it
// controls when they are bound.
const forced = d.marker?.is_light === true;
const pool = forced
? [...(d.marker?.controls || []), ...d.entities]
: d.entities.filter((e) => e.startsWith('light.'));
const lightEid = pool.find((e) => this.hass.states[e]?.state === 'on');
// the SAME condition that turns the icon yellow (devices.ts)
const lightEid = litLightEntity(this.hass, d);
if (!lightEid) continue;
const glow = glowColorOf(this.hass.states[lightEid], colors.glow_light.c);
if (!glow) continue;