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
+5
View File
@@ -75,6 +75,11 @@ Key advantages in short:
- **Automatic device placement.** Outline a room and bind it to a Home Assistant area — the devices of that area appear on the plan by themselves.
- **Manual additions of your own.** Any device, group or even a "virtual" point can be placed on the plan manually, with a name, icon, model, link and an attached PDF manual.
- **Live states.** Temperature, Zigbee signal strength, on/off, open/closed — everything updates in real time.
Icon colors follow one principle — **yellow means the device is doing its main job right now**:
a light is shining, a socket is powering, a fan is spinning, media is playing, a vacuum is
cleaning, a radiator valve is actually heating (not merely enabled). Orange = open / unlocked.
A pulsing red ring = an emergency (leak, smoke, gas). RGB bulbs color their icon with the real
light color. A translucent icon = unavailable. Dark = idle.
- **Crisp zoom.** Zooming in does not "blur" the picture: the plan, labels and icons remain vector-sharp at any scale.
---
+5
View File
@@ -74,6 +74,11 @@ House Plan показывает ваш умный дом так, как он в
- **Автоматическое добавление устройств.** Обвели комнату и привязали её к зоне Home Assistant — устройства этой зоны сами появляются на плане.
- **Ручное добавление своих.** Любое устройство, группу или даже «виртуальную» точку можно поставить на план вручную, задать имя, иконку, модель, ссылку и приложить PDF-инструкцию.
- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени.
Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**:
лампа светит, розетка подаёт, вентилятор крутится, медиа играет, пылесос убирает, термоголовка
реально греет (а не просто включена). Оранжевый = открыто / не заперто. Пульсирующее красное
кольцо = авария (протечка, дым, газ). RGB-лампы окрашивают значок реальным цветом света.
Полупрозрачный значок = недоступно. Тёмный = покой.
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
---
+60
View File
@@ -0,0 +1,60 @@
// Единый принцип жёлтого (2026-07-29): жёлтый = устройство прямо сейчас
// выполняет основную функцию. Термоголовка желтеет от реального нагрева
// (hvac_action), а не от служебного свитча защиты от накипи; горящая лампа
// желтит значок в любом режиме заливки тем же условием, что зажигает пятно.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(() => {
const o = {};
const c = window.__card;
const cls = (d, states) => {
const saved = c.hass;
c.hass = { ...c.hass, states: { ...c.hass.states, ...states } };
const r = c._stateClass(d);
c.hass = saved;
return r;
};
const trv = { id: 't', primary: 'climate.trv', entities: ['climate.trv', 'switch.trv_anti_scaling'], marker: null };
// реально греет → жёлтая
o.heatingIsYellow = cls(trv, {
'climate.trv': { state: 'heat', attributes: { hvac_action: 'heating' } },
'switch.trv_anti_scaling': { state: 'on' },
}) === 'on';
// включена, но не греет (idle) → чёрная, даже со включённой защитой от накипи
o.idleIsDark = cls(trv, {
'climate.trv': { state: 'heat', attributes: { hvac_action: 'idle' } },
'switch.trv_anti_scaling': { state: 'on' },
}) === '';
// выключена → чёрная
o.offIsDark = cls(trv, {
'climate.trv': { state: 'off', attributes: { hvac_action: 'off' } },
}) === '';
// без hvac_action фолбэк на state
o.stateFallback = cls(trv, { 'climate.trv': { state: 'heat', attributes: {} } }) === ''
? false : cls(trv, { 'climate.trv': { state: 'heat', attributes: {} } }) === 'on';
// горящая лампа устройства желтит значок, даже если primary — не она
const lamp = { id: 'l', primary: 'sensor.lamp_power', entities: ['sensor.lamp_power', 'light.lamp'], marker: null };
o.litLightWins = cls(lamp, {
'sensor.lamp_power': { state: '5' },
'light.lamp': { state: 'on' },
}) === 'on';
o.darkLampIdle = cls(lamp, {
'sensor.lamp_power': { state: '5' },
'light.lamp': { state: 'off' },
}) === '';
// «источник света»: умный выключатель с глупыми светильниками — жёлтый от
// того же условия, что и пятно glow
const wall = { id: 'w', primary: 'sensor.w', entities: ['sensor.w'],
marker: { is_light: true, controls: ['switch.fixtures'] } };
o.forcedSourceYellow = cls(wall, {
'sensor.w': { state: '1' }, 'switch.fixtures': { state: 'on' },
}) === 'on';
return o;
});
await finish(browser, checkAll(out));
File diff suppressed because one or more lines are too long
+16 -16
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -239,6 +239,11 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
on the plan after a reload. Same for each tap action and each fill mode
[auto: backend test_every_display_mode_the_editor_offers_is_accepted and
neighbours, test_a_marker_showing_its_value_can_be_saved]
- [ ] Yellow means working (dev): a TRV whose hvac_action is heating glows
yellow; one that is merely enabled (idle) or has a service switch on
(anti-scaling, child lock) stays dark; a lit light yellows its icon in
every fill mode by the same condition that lights the glow pool
[auto: smoke_yellow_principle + unit primaryEntity/litLightEntity]
- [ ] Editor gestures on touch (dev): in the plan editor on a phone, pinch
zooms and a moving finger pans; releasing after a gesture does not draw
a point, a clean tap still does [auto: smoke_editor_gestures]
+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;
+42 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue , areaClimate, areaClimateMap } from '../test-build/devices.js';
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue , areaClimate, areaClimateMap, litLightEntity } from '../test-build/devices.js';
import { compileIconRules, iconFor } from '../test-build/rules.js';
/** Minimal fake hass around the pieces buildDevices reads. */
@@ -483,3 +483,44 @@ test('areaClimateMap: температура и влажность живут в
};
assert.deepEqual(areaClimateMap(hass).get('hall'), { temp: 21.4, hum: 48 });
});
test('primaryEntity: a service switch never beats the visible main function (TRV)', () => {
// the owner's radiator heads, verified live: switch outranks climate in the
// domain list, and the old inner-tier loop let a CONFIG switch (anti
// scaling, child lock) become primary — the icon glowed yellow because
// scale protection was on, while the head actually heating stayed dark
const hass = {
entities: {
'climate.trv': {},
'sensor.trv_temp': {},
'switch.trv_anti_scaling': { entity_category: 'config' },
'switch.trv_child_lock': { entity_category: 'config' },
},
states: {},
};
assert.equal(
primaryEntity(hass, ['switch.trv_anti_scaling', 'switch.trv_child_lock', 'climate.trv', 'sensor.trv_temp'], 'mdi:thermostat'),
'climate.trv',
);
// but a device whose only functional entity IS a switch keeps it
const plug = { entities: { 'switch.plug': {}, 'sensor.plug_power': {} }, states: {} };
assert.equal(primaryEntity(plug, ['sensor.plug_power', 'switch.plug'], 'mdi:power-socket'), 'switch.plug');
});
test('litLightEntity: one truth for "this thing is shining"', () => {
const hass = { states: {
'light.a': { state: 'off' }, 'light.b': { state: 'on' },
'switch.wall': { state: 'on' }, 'sensor.x': { state: '5' },
} };
// a lit light.* anywhere in the device
assert.equal(litLightEntity(hass, { entities: ['light.a', 'light.b'] }), 'light.b');
assert.equal(litLightEntity(hass, { entities: ['light.a'] }), null);
// without the flag a lit switch is NOT a light
assert.equal(litLightEntity(hass, { entities: ['switch.wall'] }), null);
// with "is a light source" any lit entity counts, controls first
assert.equal(
litLightEntity(hass, { entities: ['sensor.x'], marker: { is_light: true, controls: ['switch.wall'] } }),
'switch.wall',
);
});