v1.52.0: one look for light sources, whatever flipped them

Owner's rule, agreed 2026-07-29 after a field report (a lamp turned off by
tap looked different from one turned off by the wall switch):

- a lamp's colour lives ONLY in its glow. The v1.27 RGB tint of the icon,
  border and shadow is deleted — that tint was the fork: with colour data
  the lamp rendered dark-with-coloured-icon, without it plain yellow, and
  the same lamp crossed the fork depending on how it was switched.
- in glow fill the indicator IS the spot: a source's badge stays standard,
  lit or not (litLightEntity — the exact condition that casts the spot —
  gates the suppression, so a lit socket keeps its yellow even in glow).
- in every other fill a lit source is plain yellow, like a heating TRV.
- icon morphing stays everywhere; the ripple colour still falls back to the
  light colour (both explicitly confirmed by the owner).

smoke_light_badges covers the whole table (8 assertions); smoke_rgb_alarm
re-asserted: no rgb class, lit lamp yellow, ripple fallback keeps the
colour. README colour language updated. Inventory: 147 / 51 / 43 / 70.
This commit is contained in:
Matysh
2026-07-29 21:31:30 +03:00
parent 110fabd038
commit 60d6167ecd
17 changed files with 207 additions and 101 deletions
+3 -2
View File
@@ -78,8 +78,9 @@ Key advantages in short:
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.
A pulsing red ring = an emergency (leak, smoke, gas). An RGB bulb's colour lives in its glow
spot (glow fill), where the spot itself is the on/off indicator and the badge stays standard.
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.
---
+2 -1
View File
@@ -77,7 +77,8 @@ House Plan показывает ваш умный дом так, как он в
Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**:
лампа светит, розетка подаёт, вентилятор крутится, медиа играет, пылесос убирает, термоголовка
реально греет (а не просто включена). Оранжевый = открыто / не заперто. Пульсирующее красное
кольцо = авария (протечка, дым, газ). RGB-лампы окрашивают значок реальным цветом света.
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
Полупрозрачный значок = недоступно. Тёмный = покой.
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.51.3"
VERSION = "1.52.0"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.51.3"
"version": "1.52.0"
}
+62
View File
@@ -0,0 +1,62 @@
// Правило владельца (2026-07-29): у источников света подложка в glow-режиме
// всегда стандартная — индикатор включения это ПЯТНО; в остальных режимах
// горящий источник жёлтый, как греющая термоголовка. RGB лампы красит только
// пятно (и фолбэк цвета пульсации); окраска иконки/рамки убрана. Морф иконки
// остаётся везде. Розетка в glow-режиме остаётся жёлтой — она не источник.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const setFill = async (mode) => {
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
const f1 = cfg.spaces.find((s) => s.id === 'f1');
f1.settings = { ...(f1.settings || {}), fill_mode: mode };
c._serverCfg = cfg; c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 80));
};
const lamp = c._devices.find((x) => x.id === 'd_lamp');
const kettle = c._devices.find((x) => x.id === 'd_kettle'); // розетка/чайник
// зажигаем лампу с RGB и розетку
c.hass = { ...c.hass, states: { ...c.hass.states,
[lamp.primary]: { ...c.hass.states[lamp.primary], state: 'on',
attributes: { ...(c.hass.states[lamp.primary]?.attributes || {}), rgb_color: [255, 120, 40] } },
[kettle.primary]: { ...c.hass.states[kettle.primary], state: 'on' } } };
const devEl = (id) => {
// надёжнее по индексу в списке devs: ищем по позиции через _pos
const d = c._devices.find((x) => x.id === id);
const v = c._viewOr(c._baseVb());
const p = c._pos(d);
const left = (((p.x - v.x) / v.w) * 100).toFixed(0);
return [...sr().querySelectorAll('.dev')].find((e) => Math.abs(parseFloat(e.style.left) - left) < 1.5);
};
// --- режим light: горящая RGB-лампа ЖЁЛТАЯ, без rgb-класса --------------
await setFill('light');
let le = devEl('d_lamp');
o.litLampYellowInLightMode = !!le && le.classList.contains('on') && !le.classList.contains('rgb');
o.morphInLightMode = le?.querySelector('ha-icon')?.getAttribute('icon')?.includes('lightbulb') ?? false;
// --- режим glow: лампа ТЁМНАЯ (индикатор — пятно), розетка жёлтая -------
await setFill('glow');
le = devEl('d_lamp');
const ke = devEl('d_kettle');
o.litLampDarkInGlow = !!le && !le.classList.contains('on') && !le.classList.contains('rgb');
o.glowSpotPresent = !!sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
o.socketStaysYellowInGlow = !!ke && ke.classList.contains('on');
o.morphInGlow = le?.querySelector('ha-icon')?.getAttribute('icon')?.includes('lightbulb') ?? false;
// --- выключенная лампа тёмная в обоих режимах ---------------------------
c.hass = { ...c.hass, states: { ...c.hass.states,
[lamp.primary]: { ...c.hass.states[lamp.primary], state: 'off' } } };
c.requestUpdate(); await c.updateComplete;
le = devEl('d_lamp');
o.offLampDarkInGlow = !!le && !le.classList.contains('on');
await setFill('light');
le = devEl('d_lamp');
o.offLampDarkInLightMode = !!le && !le.classList.contains('on');
return o;
});
await finish(browser, checkAll(out));
+14 -3
View File
@@ -8,9 +8,20 @@ const res = await page.evaluate(async () => {
c.hass = { ...c.hass, states: { ...c.hass.states,
'light.ceiling': { entity_id: 'light.ceiling', state: 'on', attributes: { friendly_name: 'Ceiling light', rgb_color: [255, 0, 128] } } } };
await c.updateComplete;
const rgbDev = sr().querySelector('.dev.rgb');
out.rgbClass = !!rgbDev;
out.rgbVar = rgbDev?.getAttribute('style')?.includes('--light-color:rgb(255, 0, 128)');
// v1.52.0 (правило владельца): RGB больше НЕ красит значок — цвет лампы
// живёт только в glow-пятне и в фолбэке цвета пульсации
out.rgbClassGone = !sr().querySelector('.dev.rgb');
out.litLampIsYellow = [...sr().querySelectorAll('.dev.on')].length > 0;
// фолбэк пульсации сохраняет цвет свечения: маркер icon_ripple на лампе
const lampDev = c._devices.find((x) => x.entities.includes('light.ceiling'));
c._serverCfg.markers = (c._serverCfg.markers || []).filter((m) => m.id !== lampDev.id);
c._serverCfg.markers.push({ id: lampDev.id, binding: 'device:' + lampDev.bindingRef, display: 'icon_ripple' });
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
c.requestUpdate(); await c.updateComplete;
const rippleDev = [...sr().querySelectorAll('.dev')].find((e) => (e.getAttribute('style') || '').includes('--ripple-color'));
out.rippleKeepsLightColor = !!rippleDev && rippleDev.getAttribute('style').includes('rgb(255, 0, 128)');
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== lampDev.id);
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
// тревога: датчик протечки on
c.hass = { ...c.hass, states: { ...c.hass.states,
'binary_sensor.sink_leak': { entity_id: 'binary_sensor.sink_leak', state: 'on', attributes: { friendly_name: 'Leak', device_class: 'moisture' } } } };
File diff suppressed because one or more lines are too long
+21 -25
View File
File diff suppressed because one or more lines are too long
+18
View File
@@ -1,5 +1,23 @@
# Changelog
## v1.52.0 — 2026-07-29
**One look for light sources, whatever flipped them** (owner's rule)
- **A lamp's colour lives only in its glow.** The RGB tint of the icon,
border and shadow is gone: depending on whether the state carried colour
data, the same lamp used to land in the "coloured icon on a dark badge" or
the "plain yellow badge" branch — turning one lamp off by tap and the rest
by the wall switch produced visibly different results. The branch is gone.
- **In glow fill the indicator IS the glow spot.** A light source's badge
stays standard, lit or not — the pool of light around it says everything.
A lit socket, fan or kettle keeps its yellow even in glow fill: they cast
no light, the rule is for sources only.
- **In every other fill a lit source is plain yellow**, like a heating
radiator valve — RGB and white lamps alike.
- Icon morphing (the shining-bulb outline) stays in every mode, and the
ripple colour still falls back to the lamp's light colour.
## v1.51.3 — 2026-07-29
- **The icon size multiplier scales the glyph, not just the badge.** Changing
+18
View File
@@ -6,6 +6,24 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
## v1.52.0 — 2026-07-29
**Один вид источников света, чем бы их ни переключали** (правило владельца)
- **Цвет лампы живёт только в её свечении.** RGB-окраска иконки, рамки и
тени убрана: в зависимости от того, пришли ли с состоянием данные о
цвете, одна и та же лампа попадала то в ветку «цветная иконка на тёмной
подложке», то в «жёлтая подложка» — выключение одной лампы тапом и
остальных выключателем выглядело по-разному. Ветки больше нет.
- **В режиме glow индикатор — само пятно света.** Подложка источника всегда
стандартная, горит он или нет — всё говорит свет вокруг. Розетка,
вентилятор или чайник остаются жёлтыми и в glow: они не светят, правило —
только для источников.
- **В остальных режимах горящий источник — просто жёлтый**, как греющая
термоголовка; RGB и обычные лампы одинаково.
- Морфинг иконки (контур «лампа с лучами») остаётся во всех режимах, а цвет
пульсации по-прежнему берёт фолбэком цвет свечения лампы.
## v1.51.3 — 2026-07-29
- **Множитель «размер значка» масштабирует и сам глиф, а не только
+2 -2
View File
@@ -15,12 +15,12 @@
| Item | State |
|---|---|
| Version | **v1.51.3** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Version | **v1.52.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 13 months (checked 2026-07-24) |
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant``/config` does NOT exist in this SSH environment), deployed **v1.51.3** via direct copy (HACS custom repo also installed) |
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant``/config` does NOT exist in this SSH environment), deployed **v1.52.0** via direct copy (HACS custom repo also installed) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
+5
View File
@@ -244,6 +244,11 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
at 70% of a device icon and zooming WITH the plan; the small metric rows
under the room name now show in the plan editor too
[auto: smoke_room_cards gearDetached/plainInPlan]
- [ ] Light-source badges (v1.52.0): in glow fill a lit lamp's badge stays
standard (the spot is the indicator) and a lit socket stays yellow; in
other fills a lit lamp is plain yellow with no RGB tint; morphing and
the ripple colour fallback survive [auto: smoke_light_badges +
smoke_rgb_alarm]
- [ ] Icon size multiplier scales the glyph (dev): set a marker's size to 3 —
the icon inside grows with the badge instead of staying default
[auto: smoke_icon_scale]
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.51.3",
"version": "1.52.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.51.3",
"version": "1.52.0",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.51.3",
"version": "1.52.0",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+12 -6
View File
@@ -36,7 +36,7 @@ import { cardStyles } from './styles';
import { fitInSquare, contentBounds, spaceModels } from './space-geometry';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.51.3';
const CARD_VERSION = '1.52.0';
const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -4152,7 +4152,7 @@ class HouseplanCard extends LitElement {
${this._renderOpenings(disp)}
</svg>
<div class="devlayer" style="--icon-size:${((iconPct * vb[2] * (this._kiosk ? this._kioskScale.icon : 1)) / view.w).toFixed(3)}cqw;--rl-font:${this._kiosk ? this._kioskScale.font : 1}">
${devs.map((d) => this._renderDevice(d, view, showLqi))}
${devs.map((d) => this._renderDevice(d, view, showLqi, disp.fill === 'glow'))}
${this._renderOpeningLocks(view)}
${disp.showNames || this._markup
? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp))
@@ -4202,7 +4202,7 @@ class HouseplanCard extends LitElement {
`;
}
private _renderDevice(d: DevItem, view: { x: number; y: number; w: number; h: number }, showLqi = true): TemplateResult {
private _renderDevice(d: DevItem, view: { x: number; y: number; w: number; h: number }, showLqi = true, glowFill = false): TemplateResult {
const p = this._pos(d);
const left = ((p.x - view.x) / view.w) * 100;
const top = ((p.y - view.y) / view.h) * 100;
@@ -4210,7 +4210,13 @@ class HouseplanCard extends LitElement {
// and no live numbers either (HP-1510-02): no value text, no temperature,
// no humidity, no LQI badge, no state-morphed icon. The base icon and the
// name stay — enough to recognise the device and open its dialog.
const cls = d.hidden ? '' : this._stateClass(d);
// The owner's rule for LIGHT SOURCES (2026-07-29): in glow fill the
// indicator IS the glow spot — the badge stays standard, on or off; in
// every other fill a lit source goes plain yellow like a heating TRV.
// "Source" is exactly the litLightEntity condition that casts the spot,
// so a lit socket or fan keeps its yellow even in glow fill.
let cls = d.hidden ? '' : this._stateClass(d);
if (glowFill && cls === 'on' && litLightEntity(this.hass, d)) cls = '';
const temp = d.hidden ? null : this._liveTemp(d);
const hum = d.hidden ? null : this._liveHum(d);
const lqi = showLqi && !d.virtual && !d.hidden ? lqiFor(this.hass, d.entities) : null;
@@ -4256,9 +4262,9 @@ class HouseplanCard extends LitElement {
if (m?.ripple_color) st.push(`--ripple-color:${m.ripple_color}`);
else if (lightC) st.push(`--ripple-color:${lightC}`);
}
if (lightC) st.push(`--light-color:${lightC}`);
return html`<div
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''} ${d.hidden ? 'ghost' : ''} ${disp === 'ripple' && !d.hidden ? 'noicon' : ''} ${valText != null ? 'valonly' : ''} ${lightC ? 'rgb' : ''} ${alarm ? 'alarm' : ''}"
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''} ${d.hidden ? 'ghost' : ''} ${disp === 'ripple' && !d.hidden ? 'noicon' : ''} ${valText != null ? 'valonly' : ''} ${alarm ? 'alarm' : ''}"
style="${st.join(';')}"
@click=${(e: MouseEvent) => this._clickDevice(e, d)}
@contextmenu=${(e: MouseEvent) => this._ctxDevice(e, d)}
+3 -7
View File
@@ -876,13 +876,9 @@ export const cardStyles = css`
white-space: nowrap;
}
/* RGB lights: the bulb takes the light's actual color */
.dev.rgb ha-icon { color: var(--light-color); }
.dev.rgb.on {
box-shadow: 0 0 10px var(--light-color);
border-color: var(--light-color);
background: var(--hp-bg);
color: var(--light-color);
}
/* v1.52.0: the RGB tint of the icon/border is gone a lamp's colour
lives ONLY in its glow spot (owner's rule). The ripple-color fallback
keeps using the light colour; that is set inline via --ripple-color. */
/* alarms pulse red over everything */
.dev.alarm::after {
content: '';