mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 19:28:46 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96a01e1380 | ||
|
|
08a9cc7d27 | ||
|
|
1be79a1427 | ||
|
|
d7c20ff6a5 | ||
|
|
fa59767c69 | ||
|
|
60d6167ecd | ||
|
|
110fabd038 | ||
|
|
3456706ef6 | ||
|
|
1310c84a32 | ||
|
|
94563d2a0c | ||
|
|
5615afa33b | ||
|
|
4083e14247 | ||
|
|
518d72fb74 | ||
|
|
fc95a1f09b | ||
|
|
4e736d49a3 | ||
|
|
aa3c379540 | ||
|
|
fa15598e67 | ||
|
|
9eec856d50 | ||
|
|
0bb9282edd | ||
|
|
09f4dc4115 | ||
|
|
2551b4ea0e | ||
|
|
694e1e9a3b | ||
|
|
996a7442ec | ||
|
|
098a147f87 | ||
|
|
14f7c06bf4 | ||
|
|
9f36b39379 | ||
|
|
9da96abb05 | ||
|
|
df65d25348 | ||
|
|
8db6b2673f |
@@ -75,6 +75,12 @@ 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). 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.
|
||||
|
||||
---
|
||||
@@ -156,7 +162,7 @@ for each floor (names prefilled, a plan image is asked for one by one; any floor
|
||||
|
||||

|
||||
|
||||
In the dialog, set a **name** (for example, "1st floor") and **upload a background** — a floor-plan image in SVG, PNG or JPG format. Both fields are required: without a plan the "Save" button stays disabled.
|
||||
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is always square; an image keeps its own proportions and is centred inside it.
|
||||
|
||||

|
||||
|
||||
@@ -199,7 +205,7 @@ walls** (it slides around corners too), and a **double click opens its propertie
|
||||
|
||||
As soon as you save a room bound to an area, **the devices of that area are automatically laid out inside the outline**. These are the same devices shown on the **Settings → Devices → (filtered by the room)** page — only the meaningful ones, without service records, bridges and duplicates.
|
||||
|
||||
By default only meaningful devices make it onto the plan — service records, bridges and duplicates are filtered out. If you need to see **absolutely all** devices of the area, enable the **👁 "Show all devices"** button in the header.
|
||||
By default only meaningful devices make it onto the plan: non-physical ones (service records, bridges, scenes, individual lamps folded into a light group) arrive with the **"Hide device from plan"** checkbox already ticked. The checkbox is yours from then on — every device dialog has it, virtual devices included. To see and un-hide them, open the device editor and press **"Show hidden"**: hidden devices appear as translucent blue ghosts, a click opens the dialog. Hidden devices still count toward the room's Zigbee signal, but cast no light.
|
||||
|
||||
From here on you can just use the plan: clicking an icon opens the device card with the model, link and a button to jump into Home Assistant.
|
||||
|
||||
@@ -281,7 +287,7 @@ Services → House Plan**.
|
||||
|
||||
**Do I need to write anything in YAML?** No. The only line is adding the card to the dashboard; everything else is done with the mouse.
|
||||
|
||||
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. If the device exists but is hidden by filtering (bridges, service records, duplicates) — enable the **👁 "Show all devices"** button in the header.
|
||||
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. If the device exists but is hidden (the "Hide device from plan" checkbox — set automatically for bridges, scenes and other non-physical records) — open the device editor, press **"Show hidden"** and untick the box in its dialog.
|
||||
|
||||
**Can I hide an unwanted device or rename it?** Yes — click the device on the plan and press "Edit" in its card: there you can change the name, icon, model or hide the icon.
|
||||
|
||||
|
||||
+9
-3
@@ -74,6 +74,12 @@ House Plan показывает ваш умный дом так, как он в
|
||||
- **Автоматическое добавление устройств.** Обвели комнату и привязали её к зоне Home Assistant — устройства этой зоны сами появляются на плане.
|
||||
- **Ручное добавление своих.** Любое устройство, группу или даже «виртуальную» точку можно поставить на план вручную, задать имя, иконку, модель, ссылку и приложить PDF-инструкцию.
|
||||
- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени.
|
||||
Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**:
|
||||
лампа светит, розетка подаёт, вентилятор крутится, медиа играет, пылесос убирает, термоголовка
|
||||
реально греет (а не просто включена). Оранжевый = открыто / не заперто. Пульсирующее красное
|
||||
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
|
||||
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
|
||||
Полупрозрачный значок = недоступно. Тёмный = покой.
|
||||
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
|
||||
|
||||
---
|
||||
@@ -157,7 +163,7 @@ title: План дома
|
||||
|
||||

|
||||
|
||||
В диалоге задайте **название** (например, «1 этаж») и **загрузите подложку** — картинку плана этажа в формате SVG, PNG или JPG. Оба поля обязательны: без плана кнопка «Сохранить» неактивна.
|
||||
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст всегда квадратный; картинка сохраняет свои пропорции и центрируется внутри него.
|
||||
|
||||

|
||||
|
||||
@@ -200,7 +206,7 @@ title: План дома
|
||||
|
||||
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
|
||||
|
||||
По умолчанию на план попадают только осмысленные устройства — служебные записи, мосты и дубликаты отфильтрованы. Если нужно видеть **вообще все** устройства зоны, включите в шапке кнопку **👁 «Показать все устройства»**.
|
||||
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) приходят с уже установленной галкой **«Скрыть устройство с плана»**. Дальше галка принадлежит вам — она есть в диалоге каждого устройства, включая виртуальные. Чтобы увидеть и вернуть скрытые, откройте редактор устройств и нажмите **«Показать скрытые»**: скрытые отобразятся полупрозрачными синими призраками, клик открывает диалог. Скрытые устройства учитываются в Zigbee-сигнале комнаты, но света не дают.
|
||||
|
||||
Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant.
|
||||
|
||||
@@ -283,7 +289,7 @@ title: План дома
|
||||
|
||||
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
|
||||
|
||||
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто курированием (мосты, служебные, дубликаты) — включите в шапке кнопку **👁 «Показать все устройства»**.
|
||||
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто (галка «Скрыть устройство с плана» — для мостов, сцен и прочих нефизических записей она ставится автоматически) — откройте редактор устройств, нажмите **«Показать скрытые»** и снимите галку в его диалоге.
|
||||
|
||||
**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок.
|
||||
|
||||
|
||||
@@ -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.50.2"
|
||||
VERSION = "1.52.2"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.50.2"
|
||||
"version": "1.52.2"
|
||||
}
|
||||
|
||||
@@ -114,6 +114,21 @@ LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT
|
||||
# the exact failure HP-1500-03 closed for layout positions, one schema over.
|
||||
_GEOM = vol.All(_finite, vol.Range(min=-4.0, max=4.0))
|
||||
|
||||
# A SIZE is not a coordinate (HP-1502-01): SVG requires positive width/height,
|
||||
# and the clients divide by these. `view_box: [0,0,0,0]` passed the shared
|
||||
# validator and serialised into viewBox="0 0 0 0" — a blank plan on every
|
||||
# client. The floor is one thousandth of the canvas (1 render unit): far below
|
||||
# any real room, but keeps the maths finite.
|
||||
_EXTENT = vol.All(_finite, vol.Range(min=0.001, max=4.0))
|
||||
|
||||
|
||||
def _view_box(value):
|
||||
"""[x, y, w, h]: the first two are coordinates, the last two are sizes."""
|
||||
if not isinstance(value, (list, tuple)) or len(value) != 4:
|
||||
raise vol.Invalid("view_box must be [x, y, w, h]")
|
||||
return [_GEOM(value[0]), _GEOM(value[1]), _EXTENT(value[2]), _EXTENT(value[3])]
|
||||
|
||||
|
||||
POINT = vol.All([_GEOM], vol.Length(min=2, max=2))
|
||||
|
||||
|
||||
@@ -145,8 +160,8 @@ ROOM_SCHEMA = vol.All(
|
||||
),
|
||||
vol.Optional("x"): _GEOM,
|
||||
vol.Optional("y"): _GEOM,
|
||||
vol.Optional("w"): _GEOM,
|
||||
vol.Optional("h"): _GEOM,
|
||||
vol.Optional("w"): _EXTENT,
|
||||
vol.Optional("h"): _EXTENT,
|
||||
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3, max=MAX_POLY_POINTS)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
@@ -210,7 +225,7 @@ SPACE_SCHEMA = vol.Schema(
|
||||
vol.Optional("plan_aspect"): vol.Any(
|
||||
None, vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20))
|
||||
),
|
||||
vol.Required("view_box"): vol.All([_GEOM], vol.Length(min=4, max=4)),
|
||||
vol.Required("view_box"): _view_box,
|
||||
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
|
||||
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
|
||||
vol.Optional("openings"): vol.All([
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// На телефоне в редакторах не работали зум и навигация жестами: pointerdown
|
||||
// сцены выходил сразу при _markup, так что пинч и пан не начинались вовсе.
|
||||
// Рисование кликается, жесты двигаются — они совместимы; палец с движением
|
||||
// панорамирует, два пальца зумируют, отпускание после жеста не рисует точку.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = {};
|
||||
|
||||
const pd = (c, id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
|
||||
const pm = (c, id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
|
||||
const pu = (c, id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
|
||||
|
||||
// --- пинч-зум в редакторе плана -----------------------------------------
|
||||
out.pinchZoomsInPlanEditor = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
c._setMode('plan');
|
||||
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
|
||||
const pm = (id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
|
||||
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
|
||||
c._resetZoom();
|
||||
const z0 = c._zoom;
|
||||
pd(1, 300, 300); pd(2, 400, 300); // два пальца
|
||||
pm(1, 250, 300); pm(2, 450, 300); // разводим
|
||||
const zoomed = c._zoom > z0 * 1.5;
|
||||
pu(1, 250, 300); pu(2, 450, 300);
|
||||
return zoomed && c._path.length === 0;
|
||||
});
|
||||
|
||||
// --- пан одним пальцем в редакторе, точка не рисуется --------------------
|
||||
out.panWorksAndDoesNotDraw = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
|
||||
const pm = (id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
|
||||
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
|
||||
const st = c._stageEl;
|
||||
c._zoomAt(st.clientWidth / 2, st.clientHeight / 2, 3); // есть куда панорамировать
|
||||
const v0 = { ...c._view };
|
||||
pd(3, 300, 300);
|
||||
pm(3, 380, 340); pm(3, 420, 360);
|
||||
const panned = Math.abs(c._view.x - v0.x) > 1 || Math.abs(c._view.y - v0.y) > 1;
|
||||
const suppressed = c._suppressClick === true;
|
||||
c._markupClick({ composedPath: () => [], clientX: 420, clientY: 360 }); // синтезированный click после пана
|
||||
const noDot = c._path.length === 0;
|
||||
pu(3, 420, 360);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
return panned && suppressed && noDot;
|
||||
});
|
||||
|
||||
// --- обычный клик без движения по-прежнему рисует ------------------------
|
||||
out.tapStillDraws = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
|
||||
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
|
||||
c._resetZoom();
|
||||
c._tool = 'draw';
|
||||
const before = c._path.length;
|
||||
pd(4, 300, 300); pu(4, 300, 300);
|
||||
const st = c._stageEl.getBoundingClientRect();
|
||||
c._markupClick({ composedPath: () => [], clientX: st.left + 200, clientY: st.top + 200 });
|
||||
const drew = c._path.length > before;
|
||||
c._path = []; c._setMode('view');
|
||||
return drew;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -9,10 +9,13 @@ const res = await page.evaluate(async () => {
|
||||
const btn = sr().querySelector('.rlgearbtn');
|
||||
out.gearButtonShown = !!btn;
|
||||
const cs = btn ? getComputedStyle(btn) : null;
|
||||
out.gearReadable = cs ? parseFloat(cs.fontSize) >= 10 && cs.pointerEvents === 'auto' : null;
|
||||
// 2026-07-29: владелец вдвое уменьшил кнопку — контракт теперь не «не
|
||||
// меньше N px», а «размер от иконки устройства и кликабельность»
|
||||
out.gearReadable = cs ? parseFloat(cs.fontSize) > 0 && cs.pointerEvents === 'auto' : null;
|
||||
out.gearHasLabel = btn ? btn.textContent.trim().length > 0 : null;
|
||||
const box = btn?.getBoundingClientRect();
|
||||
out.gearTapTarget = box ? box.height >= 18 && box.width >= 40 : null;
|
||||
// (в редакторе плана .dev скрыты display:none — сравнивать не с чем)
|
||||
out.gearTapTarget = box ? box.height > 6 && box.height < 40 : null;
|
||||
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await c.updateComplete;
|
||||
out.gearOpensDialog = c._roomDialog === true && !!c._roomEditId;
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
// docs/FILTERING.md: скрытие — явная галка. Конфиг материализуется сеятелем
|
||||
// (filter_seeded), галка в диалоге у всех устройств, «Показать скрытые» —
|
||||
// локальный режим редактора, скрытые рисуются призраками и только там.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = {};
|
||||
|
||||
// --- сеятель материализует конфиг при загрузке ---------------------------
|
||||
out.configSeeded = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const t0 = Date.now();
|
||||
while (!c._serverCfg?.settings?.filter_seeded && Date.now() - t0 < 3000) {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
return c._serverCfg?.settings?.filter_seeded === true;
|
||||
});
|
||||
|
||||
// --- галка прячет, счётчик не считает, призрак только в редакторе --------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const visibleIds = () => [...sr().querySelectorAll('.dev')].length;
|
||||
const before = visibleIds();
|
||||
|
||||
const d = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device');
|
||||
c._serverCfg.markers = c._serverCfg.markers || [];
|
||||
c._serverCfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, hidden: true });
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
o.hiddenNotRendered = visibleIds() === before - 1;
|
||||
const built = c._devices.find((x) => x.id === d.id);
|
||||
o.stillBuilt = !!built && built.hidden === true;
|
||||
o.countExcludesHidden = (sr().querySelector('.count')?.textContent || '').includes(String(before - 1));
|
||||
|
||||
// в просмотре тумблера нет эффекта — призраки только в редакторе устройств
|
||||
c._showHidden = true; c.requestUpdate(); await c.updateComplete;
|
||||
o.noGhostsInView = !sr().querySelector('.dev.ghost');
|
||||
c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
|
||||
o.ghostInEditor = !!sr().querySelector('.dev.ghost');
|
||||
// призрак — конфигурация, не статус: ни жёлтого, ни unavail, ни тревоги
|
||||
const g = sr().querySelector('.dev.ghost');
|
||||
o.ghostHasNoState = !!g && !g.classList.contains('on') && !g.classList.contains('open')
|
||||
&& !g.classList.contains('unavail') && !g.classList.contains('alarm');
|
||||
// и он синий, а не тёмный — отличим от недоступного устройства
|
||||
o.ghostIsBlue = !!g && getComputedStyle(g).borderStyle.includes('dashed')
|
||||
&& getComputedStyle(g).borderColor !== 'rgb(255, 255, 255)';
|
||||
// тумблер локальный: конфиг не трогается
|
||||
o.toggleIsLocal = c._serverCfg.settings.show_all === undefined;
|
||||
|
||||
// --- галка в диалоге у авто-устройства, кнопки «Удалить» нет -----------
|
||||
const ghost = c._devices.find((x) => x.id === d.id);
|
||||
c._openMarkerDialog(ghost); await c.updateComplete;
|
||||
o.checkboxOn = c._markerDialog?.hideFromPlan === true;
|
||||
o.noDeleteForAuto = !sr().querySelector('.dialog .btn.danger');
|
||||
// снимаем галку и сохраняем — маркер остаётся с hidden:false (анти-повтор)
|
||||
c._markerDialog = { ...c._markerDialog, hideFromPlan: false };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const m = (c._serverCfg.markers || []).find((x) => x.binding === 'device:' + d.bindingRef);
|
||||
o.untickKeepsMarker = !!m && m.hidden === false;
|
||||
const back = c._devices.find((x) => x.id === d.id) || c._devices.find((x) => x.bindingRef === d.bindingRef);
|
||||
o.deviceVisibleAgain = !!back && !back.hidden;
|
||||
|
||||
// --- у виртуального кнопка «Удалить» есть -------------------------------
|
||||
c._openMarkerDialog(); await c.updateComplete;
|
||||
c._markerDialog = { ...c._markerDialog, name: 'Тест', binding: 'virtual' };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const virt = c._devices.find((x) => x.virtual);
|
||||
c._openMarkerDialog(virt); await c.updateComplete;
|
||||
o.deleteForVirtual = !!sr().querySelector('.dialog .btn.danger');
|
||||
c._markerDialog = null; c._setMode('view');
|
||||
return o;
|
||||
}));
|
||||
|
||||
// --- HP-1510-02: призрак не показывает live-значения ----------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// датчик с числом + display:value, скрыт
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'sensor.power_meter': { state: '42', attributes: { unit_of_measurement: 'kW' } } } };
|
||||
c._serverCfg.markers = c._serverCfg.markers || [];
|
||||
c._serverCfg.markers.push({ id: 'pm', binding: 'entity:sensor.power_meter',
|
||||
display: 'value', hidden: true, space: 'f1' });
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c._setMode('devices'); c._showHidden = true;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const ghosts = [...sr().querySelectorAll('.dev.ghost')];
|
||||
const pm = ghosts.find((g) => g.textContent.includes('42')) || null;
|
||||
o.ghostHidesValue = pm === null; // «42 kW» не отрисован
|
||||
o.ghostNoLiveBadges = ghosts.every((g) =>
|
||||
!g.querySelector('.valtext') && !g.querySelector('.tval') && !g.querySelector('.hval') && !g.querySelector('.lqi'));
|
||||
// у КАЖДОГО призрака есть базовая иконка — «noicon» не оправдание
|
||||
// (HP-1511-02: ripple-призрак был безликим пульсом)
|
||||
o.ghostKeepsIcon = ghosts.every((g) => !!g.querySelector('ha-icon') && !g.classList.contains('noicon'));
|
||||
c._setMode('view'); c._showHidden = false;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// --- HP-1511-02: ripple-призрак с базовой иконкой, без пульса -------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const lamp = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device' && !x.hidden);
|
||||
c._serverCfg.markers = c._serverCfg.markers || [];
|
||||
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== lamp.id);
|
||||
c._serverCfg.markers.push({ id: lamp.id, binding: 'device:' + lamp.bindingRef,
|
||||
display: 'ripple', hidden: true });
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c._setMode('devices'); c._showHidden = true;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const g = [...sr().querySelectorAll('.dev.ghost')].find((x) => x.querySelector('.ripple') || !x.classList.contains('noicon'));
|
||||
const ghosts = [...sr().querySelectorAll('.dev.ghost')];
|
||||
o.rippleGhostHasIcon = ghosts.length > 0 && ghosts.every((x) => !!x.querySelector('ha-icon'));
|
||||
o.rippleGhostNoNoicon = ghosts.every((x) => !x.classList.contains('noicon'));
|
||||
o.rippleGhostNoRipple = ghosts.every((x) => !x.querySelector('.ripple'));
|
||||
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== lamp.id);
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
c._setMode('view'); c._showHidden = false;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// --- HP-1511-01: авто-сетка одинакова на обеих карточках ------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
const f1 = cfg.spaces.find((s) => s.id === 'f1');
|
||||
// один видимый, остальные зоны living_room скрыты; layout пуст
|
||||
const vis = c._devices.filter((x) => x.area === 'living_room' && !x.virtual);
|
||||
const keep = vis[0];
|
||||
for (const d of vis.slice(1)) {
|
||||
cfg.markers = (cfg.markers || []).filter((m) => m.id !== d.id);
|
||||
cfg.markers.push({ id: d.id, binding: d.bindingKind + ':' + d.bindingRef, hidden: true });
|
||||
}
|
||||
cfg.markers = cfg.markers.filter((m) => m.id !== keep.id || !m.hidden);
|
||||
c._serverCfg = cfg; c._cfgEpoch++; c._regSignature = '';
|
||||
c._layout = {}; // пустой layout: работают только авто-позиции
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
const full = [...(c.shadowRoot || c.renderRoot).querySelectorAll('.dev')]
|
||||
.find((el) => el.style.left && !el.classList.contains('ghost'));
|
||||
// проценты полной карточки считаются от content-fit view — переводим в vb
|
||||
const v = c._viewOr(c._baseVb());
|
||||
const fullVb = full && [
|
||||
v.x + (parseFloat(full.style.left) / 100) * v.w,
|
||||
v.y + (parseFloat(full.style.top) / 100) * v.h,
|
||||
];
|
||||
|
||||
const hass = { ...c.hass, callWS: async (m) => {
|
||||
// rev уникален: у статичной карточки модульный кэш конфига по rev,
|
||||
// и одинаковый rev в соседних тестах подсовывает чужой конфиг
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 31 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {}, rev: 31 };
|
||||
return { ok: true };
|
||||
},
|
||||
// модульный кэш конфига инвалидируется только через это событие — стаб
|
||||
// сохраняет колбэк, чтобы следующий тест мог сбросить кэш
|
||||
connection: { subscribeEvents: async (cb) => { window.__hpInvalidate = cb; return () => {}; } } };
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
card.hass = hass;
|
||||
host.appendChild(card);
|
||||
const t0 = Date.now();
|
||||
while (!card.renderRoot?.querySelector('[style*="left"]') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
await card.updateComplete;
|
||||
const st = [...card.renderRoot.querySelectorAll('[style*="left"]')]
|
||||
.find((el) => /%$/.test(el.style.left || '') && el.style.top);
|
||||
// статичная карточка рендерит от полного квадрата 0..1000
|
||||
const stVb = st && [parseFloat(st.style.left) * 10, parseFloat(st.style.top) * 10];
|
||||
o.autoGridParity = !!fullVb && !!stVb
|
||||
&& Math.abs(fullVb[0] - stVb[0]) < 6 && Math.abs(fullVb[1] - stVb[1]) < 6;
|
||||
if (!o.autoGridParity) console.log('full', fullVb, 'static', stVb);
|
||||
host.remove();
|
||||
return o;
|
||||
}));
|
||||
|
||||
// --- HP-1510-01: LQI комнаты одинаков на полной и статичной карточке ------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
const f1 = cfg.spaces.find((s) => s.id === 'f1');
|
||||
f1.settings = { ...(f1.settings || {}), fill_mode: 'lqi', show_borders: true };
|
||||
// прячем ВСЕ устройства зоны living_room: комнату красит только скрытое
|
||||
for (const d of c._devices.filter((x) => x.area === 'living_room' && !x.virtual)) {
|
||||
if (!cfg.markers.some((m) => m.id === d.id)) {
|
||||
cfg.markers.push({ id: d.id, binding: d.bindingKind + ':' + d.bindingRef, hidden: true });
|
||||
} else {
|
||||
cfg.markers = cfg.markers.map((m) => (m.id === d.id ? { ...m, hidden: true } : m));
|
||||
}
|
||||
}
|
||||
c._serverCfg = cfg; c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
const fullRoom = [...(c.shadowRoot || c.renderRoot).querySelectorAll('.room')]
|
||||
.find((r) => (r.getAttribute('style') || '').includes('--room-fill'));
|
||||
const fullFill = fullRoom ? (fullRoom.getAttribute('style').match(/--room-fill:([^;]+)/) || [])[1] : null;
|
||||
|
||||
window.__hpInvalidate?.(); // сбросить модульный кэш от предыдущей карточки
|
||||
const hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 57 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {}, rev: 57 };
|
||||
return { ok: true };
|
||||
},
|
||||
connection: { subscribeEvents: async () => () => {} } };
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
card.hass = hass;
|
||||
host.appendChild(card);
|
||||
const t0 = Date.now();
|
||||
while (!card.renderRoot?.querySelector('.room') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
await card.updateComplete;
|
||||
const stRoom = [...card.renderRoot.querySelectorAll('.room')]
|
||||
.find((r) => (r.getAttribute('style') || '').includes('--room-fill'));
|
||||
const stFill = stRoom ? (stRoom.getAttribute('style').match(/--room-fill:([^;]+)/) || [])[1] : null;
|
||||
o.fullPaintsHiddenLqi = !!fullFill;
|
||||
o.staticPaintsHiddenLqi = !!stFill;
|
||||
o.lqiParity = !!fullFill && fullFill === stFill;
|
||||
host.remove();
|
||||
return o;
|
||||
}));
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -0,0 +1,38 @@
|
||||
// Множитель «размер значка» обязан масштабировать ГЛИФ вместе с подложкой:
|
||||
// --mdc-icon-size считался от базового --icon-size, и «увеличенный» значок
|
||||
// был пустой рамкой вокруг дефолтной иконки (репорт пользователя, 2026-07-29).
|
||||
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 d = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device');
|
||||
const glyphW = () => {
|
||||
const el = [...sr().querySelectorAll('.dev')].find((e) => !e.classList.contains('ghost'));
|
||||
const ic = el?.querySelector('ha-icon');
|
||||
return ic ? ic.getBoundingClientRect().width : 0;
|
||||
};
|
||||
const badgeW = () => {
|
||||
const el = [...sr().querySelectorAll('.dev')].find((e) => !e.classList.contains('ghost'));
|
||||
return el ? el.getBoundingClientRect().width : 0;
|
||||
};
|
||||
// базовый размер
|
||||
const g1 = glyphW(), b1 = badgeW();
|
||||
// множитель ×3 на всех авто-устройствах через маркер первого
|
||||
c._serverCfg.markers = (c._serverCfg.markers || []).filter((m) => m.id !== d.id);
|
||||
c._serverCfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, size: 3 });
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
const scaled = [...sr().querySelectorAll('.dev')].find((e) => (e.getAttribute('style') || '').includes('--dev-scale:3'));
|
||||
const g3 = scaled?.querySelector('ha-icon')?.getBoundingClientRect().width || 0;
|
||||
const b3 = scaled?.getBoundingClientRect().width || 0;
|
||||
o.badgeScales = b3 > b1 * 2.5;
|
||||
o.glyphScalesWithBadge = g3 > g1 * 2.5; // раньше глиф оставался базовым
|
||||
o.glyphFillsBadge = g3 / b3 > 0.45 && g3 / b3 < 0.8; // пропорция сохранена
|
||||
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== d.id);
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
return o;
|
||||
});
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -0,0 +1,65 @@
|
||||
// HP-1503-01: один и тот же повреждённый store обязан рендериться одинаково в
|
||||
// ОБЕИХ карточках. Полная карточка строила модель рукописной копией
|
||||
// spaceModels и прошла мимо safeViewBox/normRect: статическая показывала
|
||||
// восстановленный план, полная — viewBox="0 0 0 0" и rect с отрицательной
|
||||
// шириной. Вектор аудита исполняется через обе модели и оба DOM-дерева.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const main = window.__card;
|
||||
|
||||
// ровно вектор из отчёта: нулевой viewport + отрицательный legacy-rect
|
||||
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
|
||||
const f1 = cfg.spaces.find((s) => s.id === 'f1');
|
||||
f1.plan_url = null; f1.plan_aspect = null;
|
||||
f1.view_box = [0, 0, 0, 0];
|
||||
f1.rooms = [{ id: 'r1', name: 'R', area: 'living_room', x: 0.6, y: 0.7, w: -0.2, h: -0.3 }];
|
||||
f1.settings = { ...(f1.settings || {}), show_borders: true };
|
||||
const hass = { ...main.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {}, rev: 1 };
|
||||
return { ok: true };
|
||||
} };
|
||||
|
||||
main._serverCfg = cfg;
|
||||
main._cfgEpoch++;
|
||||
main._view = null;
|
||||
main.requestUpdate(); await main.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
|
||||
// модель полной карточки: fallback на весь холст + нормализованный rect
|
||||
const m = main._spaceModel('f1');
|
||||
out.fullVbFallsBack = JSON.stringify(m.vb) === JSON.stringify([0, 0, 1000, 1000]);
|
||||
const r = m.rooms[0];
|
||||
out.fullRectNormalised =
|
||||
Math.round(r.x) === 400 && Math.round(r.y) === 400
|
||||
&& Math.round(r.w) === 200 && Math.round(r.h) === 300;
|
||||
|
||||
// DOM полной карточки: конечный положительный viewBox, никаких минусов в rect
|
||||
const svg = (main.shadowRoot || main.renderRoot).querySelector('.stage svg');
|
||||
const vbAttr = (svg?.getAttribute('viewBox') || '').split(/\s+/).map(Number);
|
||||
out.fullDomViewBoxSane = vbAttr.length === 4 && vbAttr[2] > 0 && vbAttr[3] > 0;
|
||||
const rect = (main.shadowRoot || main.renderRoot).querySelector('.stage svg rect.room, .stage svg .room rect, .stage svg rect[width]');
|
||||
out.fullDomRectSane = !rect || (Number(rect.getAttribute('width')) >= 0 && Number(rect.getAttribute('height')) >= 0);
|
||||
|
||||
// статическая карточка того же store — паритет
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
card.hass = hass;
|
||||
host.appendChild(card);
|
||||
const t0 = Date.now();
|
||||
while (!card.renderRoot?.querySelector('svg') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r2) => setTimeout(r2, 60));
|
||||
}
|
||||
await card.updateComplete;
|
||||
const svb = (card.renderRoot.querySelector('svg')?.getAttribute('viewBox') || '').split(/\s+/).map(Number);
|
||||
out.staticDomViewBoxSane = svb.length === 4 && svb[2] > 0 && svb[3] > 0;
|
||||
out.parity = JSON.stringify(vbAttr.length === 4 && svb.length === 4
|
||||
? [vbAttr[2] > 0, vbAttr[3] > 0] : null) === JSON.stringify([svb[2] > 0, svb[3] > 0]);
|
||||
return out;
|
||||
});
|
||||
await finish(browser, checkAll(res));
|
||||
@@ -0,0 +1,78 @@
|
||||
// Правило владельца (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;
|
||||
|
||||
// --- HP-1520-01: в редакторах, где glow-слой скрыт, индикатор возвращается
|
||||
// (лампа горит; в devices-редакторе слой рисуется — там лампа тёмная,
|
||||
// в plan-редакторе слоя нет — там класс on должен вернуться)
|
||||
c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
|
||||
le = devEl('d_lamp');
|
||||
o.devicesEditorLampDark = !!le && !le.classList.contains('on');
|
||||
o.devicesEditorHasGlow = !!sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
|
||||
c._setMode('plan'); c.requestUpdate(); await c.updateComplete;
|
||||
// ИМЕННО лампа (mutation-проверка аудита HP-1521-01: розетка тоже on и
|
||||
// удовлетворяла бы поиску «любой .dev.on», пряча регресс самой лампы)
|
||||
le = devEl('d_lamp');
|
||||
o.planEditorLampYellow = !!le && le.classList.contains('on'); // слой скрыт — жёлтый вернулся
|
||||
o.planEditorKettleStillOn = !!devEl('d_kettle')?.classList.contains('on');
|
||||
o.planEditorNoGlow = !sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
|
||||
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
// --- выключенная лампа тёмная в обоих режимах ---------------------------
|
||||
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));
|
||||
@@ -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' } } } };
|
||||
|
||||
@@ -36,8 +36,14 @@ const res = await page.evaluate(async () => {
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
const planLbl = sr().querySelector('.roomlabel');
|
||||
out.handlesInPlan = planLbl?.querySelectorAll('.rlhandle').length === 4;
|
||||
// метрики скрыты в редакторе плана (карточка = имя, чтобы не мешать разметке)
|
||||
out.plainInPlan = sr().querySelectorAll('.roomlabel.card').length === 0;
|
||||
// метрики видны и в редакторе плана (решение владельца, 2026-07-29)
|
||||
out.plainInPlan = sr().querySelectorAll('.roomlabel.card').length > 0;
|
||||
// кнопка настроек — отдельно от подписи, в центре комнаты, с размером от иконки
|
||||
out.gearDetached = (() => {
|
||||
const g = sr().querySelector('.rlgearbtn');
|
||||
if (!g || g.closest('.roomlabel')) return false;
|
||||
return getComputedStyle(g).height !== 'auto';
|
||||
})();
|
||||
// масштаб: сымитируем resize через прямой вызов
|
||||
const room = c._spaceModel().rooms.find((r) => r.name);
|
||||
c._rlResize = { id: 'rl_' + room.id, space: spId, k0: 1, cx: 100, cy: 100, d0: 50 };
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// HP-1513-01: один и тот же маркер (size 3, angle 37) обязан выглядеть
|
||||
// одинаково на полной и статичной карточках — статичная игнорировала и
|
||||
// множитель размера, и поворот.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const d = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device');
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.markers = (cfg.markers || []).filter((m) => m.id !== d.id);
|
||||
cfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, size: 3, angle: 37 });
|
||||
c._serverCfg = cfg; c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
const pick = (root) => [...root.querySelectorAll('.dev')]
|
||||
.find((e) => (e.getAttribute('style') || '').includes('--dev-scale:3'));
|
||||
const full = pick(c.shadowRoot || c.renderRoot);
|
||||
const fW = full?.getBoundingClientRect().width || 0;
|
||||
const fT = full ? getComputedStyle(full.querySelector('ha-icon')).transform : 'none';
|
||||
|
||||
window.__hpInvalidate?.();
|
||||
const hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 83 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: c._layout, rev: 83 };
|
||||
return { ok: true };
|
||||
}, connection: { subscribeEvents: async (cb) => { window.__hpInvalidate = cb; return () => {}; } } };
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
card.hass = hass;
|
||||
host.appendChild(card);
|
||||
const t0 = Date.now();
|
||||
while (!pick(card.renderRoot || { querySelectorAll: () => [] }) && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
const st = pick(card.renderRoot);
|
||||
const sW = st?.getBoundingClientRect().width || 0;
|
||||
const sT = st ? getComputedStyle(st.querySelector('ha-icon')).transform : 'none';
|
||||
o.staticCarriesScale = sW > 0;
|
||||
// абсолютные px несравнимы (разные контейнеры/letterbox) — контракт в том,
|
||||
// что множитель РАБОТАЕТ на обеих карточках: базовый значок той же карточки
|
||||
// втрое меньше масштабированного
|
||||
const stBase = [...card.renderRoot.querySelectorAll('.dev')]
|
||||
.find((e) => !(e.getAttribute('style') || '').includes('--dev-scale'));
|
||||
const sBaseW = stBase?.getBoundingClientRect().width || 0;
|
||||
o.staticRatioIs3 = sBaseW > 0 && Math.abs(sW / sBaseW - 3) < 0.35;
|
||||
const fBase = [...(c.shadowRoot || c.renderRoot).querySelectorAll('.dev')]
|
||||
.find((e) => !(e.getAttribute('style') || '').includes('--dev-scale') && !e.classList.contains('ghost'));
|
||||
const fBaseW = fBase?.getBoundingClientRect().width || 0;
|
||||
o.fullRatioIs3 = fBaseW > 0 && Math.abs(fW / fBaseW - 3) < 0.35;
|
||||
o.bothRotated = fT !== 'none' && sT !== 'none' && fT.startsWith('matrix') && sT.startsWith('matrix');
|
||||
if (!o.staticRatioIs3) console.log('static', sW, sBaseW);
|
||||
host.remove();
|
||||
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== d.id);
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
return o;
|
||||
});
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -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
Vendored
+121
-81
File diff suppressed because one or more lines are too long
+34
-14
@@ -100,23 +100,36 @@ name?, icon?, model?, link?, description?, pdfs:[{name,url}]}`. A hybrid: auto-d
|
||||
appear on their own; a marker with `binding=device:<id>` overrides them (metadata/rebinding/hiding),
|
||||
`entity:<eid>` — for groups/helpers, `virtual` — a manual icon without HA. The marker id = device_id /
|
||||
`lg_<eid>` / `v_<rand>` (preserves the position in the layout). The binding picker excludes already-placed
|
||||
references and duplicates by name|area. Manual files: `houseplan/file/set` → `/config/houseplan/files/<id>/`,
|
||||
served from `/houseplan_files/files/`.
|
||||
references and duplicates by name|area. Manual files: transactional HTTP upload into `<config>/houseplan/files/<id>/`
|
||||
(staging `up_*` folders promoted on save), served via signed
|
||||
`/api/houseplan/content/files/…` urls.
|
||||
|
||||
## Server-side configuration (v1.3.0+)
|
||||
## Server-side configuration (current shape, v1.51+)
|
||||
|
||||
`.storage/houseplan.config` (Store):
|
||||
```json
|
||||
{ "spaces": [{ "id","title","plan_url","aspect","view_box":[4],"rooms":[{"id","name","area","x","y","w","h"}] }],
|
||||
"device_overrides": {"<device_id>": {"hidden","icon","name"}},
|
||||
"virtual_devices": [{"id","space","name","icon","x","y","note?","entity_id?"}],
|
||||
"settings": {"exclude_integrations":[],"group_lights":true} }
|
||||
{ "spaces": [{ "id","title","plan_url","plan_aspect","view_box":[4],
|
||||
"rooms":[{"id","name","area","poly|x/y/w/h","open_to","settings"}],
|
||||
"openings":[…], "decor":[…], "settings":{…} }],
|
||||
"markers": [{ "id","binding":"device:<id>|entity:<eid>|virtual","hidden",
|
||||
"name","icon","display","controls","is_light","tap_action",
|
||||
"room_id","pdfs",… }],
|
||||
"settings": { "exclude_integrations":[], "group_lights":true,
|
||||
"filter_seeded":true, "fill_colors":{…}, "icon_rules":[…],
|
||||
"known_devices":[…], "new_device_ids":[…] } }
|
||||
```
|
||||
All coordinates are **normalized (0..1 of the space plan)**; the render space is
|
||||
1000 × 1000/aspect. Layout v2: `{device_id: {"s": space, "x", "y"}}` (normalized).
|
||||
Plan files: `<config>/houseplan/plans/<space>.<ext>` → URL `/houseplan_files/plans/…`.
|
||||
If the server config is empty, the card falls back to the legacy bundle (the dacha) and shows a
|
||||
"To server" migration button in edit mode. The dacha was migrated on 2026-07-04.
|
||||
All coordinates are **normalized (0..1 of the canvas)**; the canvas is always
|
||||
**square** (v1.48.0), render space `NORM_W × NORM_W` (1000×1000). A space has no
|
||||
proportions of its own — `plan_aspect` is the IMAGE's ratio, used to letterbox
|
||||
it centred on the square. The schema bounds geometry to ±4 with strictly
|
||||
positive sizes (HP-1501/1502). `device_overrides`/`virtual_devices` are long
|
||||
gone — markers carry everything, `marker.hidden` is the explicit
|
||||
"hide from plan" flag seeded once by the old filter (docs/FILTERING.md).
|
||||
Layout v2: `{device_id | rl_<roomId>: {"s": space, "x", "y"}}` (normalized,
|
||||
bounded ±4). Plan files: `<config>/houseplan/plans/<space>.<token>.<ext>`
|
||||
(copy-on-write, never overwritten), served via signed
|
||||
`/api/houseplan/content/plans/_/<name>` urls; growth is bounded by store
|
||||
quotas, nothing is ever deleted for being old (docs/SCOPE.md).
|
||||
|
||||
## Room geometry rules (v1.19–v1.21)
|
||||
|
||||
@@ -178,9 +191,16 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
|
||||
| `houseplan/config/get` | — | `{config, rev}` |
|
||||
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
|
||||
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` — writes `<space>.<token>.<ext>`, deletes nothing |
|
||||
| `houseplan/plans/list` | — | `{plans: [{name, url, size, modified, used_by}]}` |
|
||||
| `houseplan/plans/list` | — | `{plans: [{name, url, size, modified, used_by}], total}` (newest 60) |
|
||||
| `houseplan/plans/delete` | `name` | `{ok, removed}` / err `in_use` |
|
||||
| `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) |
|
||||
| `houseplan/layout/delete` | `device_id` | `{ok, rev}`; event `houseplan_layout_updated` |
|
||||
| `houseplan/geometry/repair` | `space_id`, `aspect`, `dry_run?`, `undo?` | preview / `{ok, rev, moved}` / `{restored}`; errs `nothing_to_repair`, `no_backup` |
|
||||
| `houseplan/files/migrate` | `from_id`, `to_id` | `{mapping}` — COPY, never move |
|
||||
| `houseplan/files/cleanup` | `marker_id`, `keep?` | replacement-only collection |
|
||||
| `houseplan/content/sign` | `paths[]` | `{urls}` — authSig for `<image>`/`<a>` fetches |
|
||||
|
||||
Manual attachments upload over HTTP (streaming, transactional staging), not WS —
|
||||
the old `houseplan/file/set` was removed in v1.10.0.
|
||||
|
||||
**If the v1.48 migration crashed halfway** (HP-1500-01): the config write
|
||||
landed, the layout write did not, and both triggers are gone — markers of that
|
||||
|
||||
@@ -1,5 +1,181 @@
|
||||
# Changelog
|
||||
|
||||
## v1.52.2 — 2026-07-29
|
||||
|
||||
**From the v1.52.1 review** (no runtime changes — test and wording quality)
|
||||
|
||||
- The plan-editor regression check now targets THE lamp (HP-1521-01): the
|
||||
old assertion accepted any yellow badge, and the lit socket in the same
|
||||
fixture would have satisfied it with the lamp fix removed. Verified by
|
||||
mutation: reverting the v1.52.1 gate makes the smoke fail.
|
||||
- The one remaining "yellow in every fill mode" wording — the checklist
|
||||
entry and the _stateClass comment — now states the actual contract: the
|
||||
lit-source state is computed by the glow-pool condition, and the yellow
|
||||
badge shows only where the glow spot is not drawn (HP-1521-02).
|
||||
|
||||
## v1.52.1 — 2026-07-29
|
||||
|
||||
**From the v1.52.0 review**
|
||||
|
||||
- **A lit lamp always has exactly one indicator (HP-1520-01).** The glow
|
||||
layer is hidden in the plan editor, but the yellow suppression still
|
||||
applied there — a lit lamp showed neither the spot nor the badge. The
|
||||
suppression gate now equals the layer's actual visibility: wherever the
|
||||
spot is not drawn, the yellow badge returns.
|
||||
- **The static card honours a marker's size and rotation (HP-1513-01).** The
|
||||
same stored marker rendered at base size, unrotated, on the read-only
|
||||
card. It now mirrors the full card's --dev-scale and angle — geometry
|
||||
only, still a schematic.
|
||||
- Documentation caught up with the v1.52.0 colour contract (HP-1520-02):
|
||||
the RGB-tint expectations in TESTING/UX-MODES and a stale inline comment.
|
||||
|
||||
## 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
|
||||
a device's size grew the badge, the ripple and the value badges but left
|
||||
the icon itself at its default size — a big empty box around a small glyph
|
||||
(user report). The glyph now derives from the same per-device size as
|
||||
everything else and keeps its proportion at any multiplier and zoom.
|
||||
|
||||
## v1.51.2 — 2026-07-29
|
||||
|
||||
**From the v1.51.1 review**
|
||||
|
||||
- **The auto grid is the same on both cards (HP-1511-01).** The full card
|
||||
reserves grid cells for hidden devices (their ghosts keep a place in the
|
||||
device editor); the static card compacted the grid over visible ones only,
|
||||
so a freshly discovered marker with no saved position landed in different
|
||||
spots on the two cards. The static card now feeds the full roster to the
|
||||
same grid and still draws only the visible.
|
||||
- **A ripple-display ghost keeps its base icon (HP-1511-02).** Hidden markers
|
||||
with the "ripple" presentation rendered as an icon-less inactive pulse —
|
||||
unrecognisable in the editor. A ghost now drops the display dressing
|
||||
entirely: base icon and name, whatever the display mode.
|
||||
|
||||
## v1.51.1 — 2026-07-29
|
||||
|
||||
**From the v1.51.0 review**
|
||||
|
||||
- **The static card counts hidden devices in room LQI again (HP-1510-01).**
|
||||
Its visibility filter had quietly become the aggregation filter: the same
|
||||
room showed different Zigbee health on the two cards. Aggregation and
|
||||
rendering use separate lists now — hidden devices count toward signal on
|
||||
both cards, are drawn on neither, and still cast no light.
|
||||
- **A ghost shows no live numbers (HP-1510-02).** A hidden device in "Show
|
||||
hidden" suppressed the state colors but still painted its value text,
|
||||
temperature, humidity, LQI badge and state-morphed icon. All of that is
|
||||
gone: the ghost keeps only the base icon and name — enough to recognise it
|
||||
and open the dialog.
|
||||
|
||||
## v1.51.0 — 2026-07-29
|
||||
|
||||
**Hiding is an explicit flag now** (docs/FILTERING.md)
|
||||
|
||||
- **Every device dialog — virtual ones included — has a "Hide device from
|
||||
plan" checkbox.** The old on-the-fly filter survives only as the SEEDER of
|
||||
those flags: on first load by an editing client the config is materialised
|
||||
once — non-physical devices (bridges, scenes, service integrations, lamps
|
||||
folded into a light group) get the flag, and from then on the flag belongs
|
||||
to you. Unticking it is final: the seeder never revisits a device you have
|
||||
decided about. New non-physical devices hide silently; physical ones keep
|
||||
the red-dot flow.
|
||||
- **"Show all" became "Show hidden"** — a local tool of the device editor
|
||||
(nothing flips on the wall tablets), showing hidden devices as translucent
|
||||
BLUE dashed ghosts: clearly apart from a grey unavailable icon, and with no
|
||||
live-state paint at all — a ghost is configuration, not status. Click one
|
||||
to untick the box. "Remove from plan" is gone for bound devices (the
|
||||
checkbox is the way); a virtual device's Delete still deletes.
|
||||
- Hidden devices still count toward the room's Zigbee signal, but cast no
|
||||
glow and no light fill — an invisible device casts no visible light. Room
|
||||
climate is unchanged. Old configs behave exactly as before until an
|
||||
editing client materialises them.
|
||||
|
||||
**Yellow means working right now**
|
||||
|
||||
- One principle for the glowing icon: a light is shining, a socket is
|
||||
powering, a fan is spinning, media is playing, a vacuum is cleaning — or a
|
||||
radiator valve is ACTUALLY heating (hvac_action), not merely enabled for
|
||||
the winter. Previously a TRV could glow yellow because its anti-scaling
|
||||
service switch was on while the actually-heating one stayed dark: the
|
||||
primary-entity search let a vendor's config switch outrank the visible
|
||||
climate entity. Fixed — a service entity never beats the device's visible
|
||||
main function (this also fixes tap-toggle and icon morphing on such
|
||||
devices).
|
||||
- The glow pool and the icon color now ask the same question: a lit light
|
||||
yellows its icon in every fill mode, by exactly the condition that lights
|
||||
its glow spot. The README (en+ru) documents the color language.
|
||||
|
||||
**The editors, on a phone**
|
||||
|
||||
- Pinch zoom and pan gestures now work in every editor: drawing is
|
||||
click-based, so the two coexist — a moving finger pans, two fingers pinch,
|
||||
releasing after a gesture never draws a point, a clean tap still does.
|
||||
|
||||
**The room settings button**
|
||||
|
||||
- Detached from the (movable) room name: it sits at the VISUAL centre of the
|
||||
room — the centre of the largest inscribed circle with a pull toward the
|
||||
area centroid, so an elongated room centres it on both axes and an L-shaped
|
||||
one keeps it in the middle of its widest part, never down a thin limb.
|
||||
- Half its former size, sized from the device icon (70% of the icon box) and
|
||||
zooming WITH the plan instead of keeping a constant screen size.
|
||||
- The small metric rows under the room name (temperature, humidity, signal,
|
||||
lights) now show in the plan editor too, and the name renders in exactly
|
||||
the same spot in view mode and in the editor.
|
||||
|
||||
## v1.50.4 — 2026-07-29
|
||||
|
||||
**From the v1.50.3 review**
|
||||
|
||||
- **Both cards build their model with the same code now (HP-1503-01).** The
|
||||
full card carried a hand-copied twin of the shared model builder, and the
|
||||
twin missed the legacy-store fallbacks v1.50.3 added — the same broken
|
||||
store rendered fine in the static card and as a blank `viewBox="0 0 0 0"`
|
||||
in the main one. The duplicate is gone: the full card calls the shared
|
||||
builder and only swaps in the raw plan url its signing flow needs. A new
|
||||
smoke runs the audit's exact legacy vector through both models and both
|
||||
DOM trees and asserts parity.
|
||||
|
||||
## v1.50.3 — 2026-07-29
|
||||
|
||||
**From the v1.50.2 review**
|
||||
|
||||
- **A size is not a coordinate (HP-1502-01).** The ±4 bound from v1.50.2
|
||||
treated all four view_box elements and room w/h alike, so `[0, 0, 0, 0]`
|
||||
and negative sizes still passed — and a zero axis serialises into
|
||||
`viewBox="0 0 0 0"`, a blank plan on every client, with the static card
|
||||
computing `aspect-ratio: 0 / 0` on top. Sizes now get their own validator:
|
||||
strictly positive, floored at one thousandth of the canvas; coordinates may
|
||||
still be negative, because a crop origin legitimately sits past the edge.
|
||||
And since a store may already hold a broken viewport from before, both
|
||||
cards fall back to the whole canvas instead of a blank screen, and a legacy
|
||||
rectangle with a negative size is read as the same rectangle drawn from the
|
||||
other corner.
|
||||
|
||||
**Also in this release**
|
||||
|
||||
- The room settings button moved to the bottom of the room card, and the room
|
||||
name renders in exactly the same spot in view mode and in the plan editor —
|
||||
the button and the metrics no longer take part in the label's centring.
|
||||
|
||||
## v1.50.2 — 2026-07-29
|
||||
|
||||
**From the v1.50.1 review**
|
||||
|
||||
@@ -6,6 +6,184 @@
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## v1.52.2 — 2026-07-29
|
||||
|
||||
**По ревью v1.52.1** (рантайм не менялся — качество теста и формулировок)
|
||||
|
||||
- Проверка регресса в редакторе плана целится ИМЕННО в лампу (HP-1521-01):
|
||||
старый ассерт принимал любой жёлтый значок, и горящая розетка из той же
|
||||
фикстуры прошла бы его даже без фикса лампы. Проверено мутацией: откат
|
||||
гейта v1.52.1 валит смок.
|
||||
- Последняя формулировка «жёлтый в любом режиме» — пункт чек-листа и
|
||||
комментарий в _stateClass — приведена к фактическому контракту: состояние
|
||||
источника считается условием glow-пула, а жёлтая подложка показывается
|
||||
только там, где пятно не рисуется (HP-1521-02).
|
||||
|
||||
## v1.52.1 — 2026-07-29
|
||||
|
||||
**По ревью v1.52.0**
|
||||
|
||||
- **У горящей лампы всегда ровно один индикатор (HP-1520-01).** В редакторе
|
||||
плана glow-слой скрыт, но подавление жёлтого там всё равно действовало —
|
||||
горящая лампа не показывала ни пятна, ни подложки. Гейт подавления теперь
|
||||
равен фактической видимости слоя: где пятно не рисуется, жёлтая подложка
|
||||
возвращается.
|
||||
- **Статичная карточка уважает размер и поворот маркера (HP-1513-01).** Один
|
||||
и тот же маркер рендерился на read-only карточке базовым и неповёрнутым.
|
||||
Теперь она зеркалит --dev-scale и угол полной карточки — только геометрия,
|
||||
по-прежнему схема.
|
||||
- Документация догнала цветовой контракт v1.52.0 (HP-1520-02): ожидания
|
||||
RGB-окраски в TESTING/UX-MODES и устаревший комментарий в коде.
|
||||
|
||||
## v1.52.0 — 2026-07-29
|
||||
|
||||
**Один вид источников света, чем бы их ни переключали** (правило владельца)
|
||||
|
||||
- **Цвет лампы живёт только в её свечении.** RGB-окраска иконки, рамки и
|
||||
тени убрана: в зависимости от того, пришли ли с состоянием данные о
|
||||
цвете, одна и та же лампа попадала то в ветку «цветная иконка на тёмной
|
||||
подложке», то в «жёлтая подложка» — выключение одной лампы тапом и
|
||||
остальных выключателем выглядело по-разному. Ветки больше нет.
|
||||
- **В режиме glow индикатор — само пятно света.** Подложка источника всегда
|
||||
стандартная, горит он или нет — всё говорит свет вокруг. Розетка,
|
||||
вентилятор или чайник остаются жёлтыми и в glow: они не светят, правило —
|
||||
только для источников.
|
||||
- **В остальных режимах горящий источник — просто жёлтый**, как греющая
|
||||
термоголовка; RGB и обычные лампы одинаково.
|
||||
- Морфинг иконки (контур «лампа с лучами») остаётся во всех режимах, а цвет
|
||||
пульсации по-прежнему берёт фолбэком цвет свечения лампы.
|
||||
|
||||
## v1.51.3 — 2026-07-29
|
||||
|
||||
- **Множитель «размер значка» масштабирует и сам глиф, а не только
|
||||
подложку.** Изменение размера устройства растило подложку, пульсацию и
|
||||
бейджи значений, но иконка внутри оставалась дефолтной — большая пустая
|
||||
рамка вокруг маленького глифа (репорт пользователя). Теперь глиф считается
|
||||
от того же поустройственного размера, что и всё остальное, и держит
|
||||
пропорцию при любом множителе и зуме.
|
||||
|
||||
## v1.51.2 — 2026-07-29
|
||||
|
||||
**По ревью v1.51.1**
|
||||
|
||||
- **Авто-сетка одинакова на обеих карточках (HP-1511-01).** Полная карточка
|
||||
резервирует клетки сетки за скрытыми устройствами (их призраки держат
|
||||
место в редакторе); статичная уплотняла сетку по одним видимым — свежее
|
||||
устройство без сохранённой позиции вставало на разных местах двух
|
||||
карточек. Теперь статичная передаёт сетке полный список и по-прежнему
|
||||
рисует только видимых.
|
||||
- **Ripple-призрак сохраняет базовую иконку (HP-1511-02).** Скрытый маркер с
|
||||
отображением «пульсация» рендерился безликим неподвижным пульсом — в
|
||||
редакторе его нельзя было узнать. Призрак теперь полностью снимает
|
||||
«одежду» отображения: базовая иконка и имя, каким бы ни был режим.
|
||||
|
||||
## v1.51.1 — 2026-07-29
|
||||
|
||||
**По ревью v1.51.0**
|
||||
|
||||
- **Статичная карточка снова учитывает скрытые устройства в LQI комнаты
|
||||
(HP-1510-01).** Её фильтр видимости незаметно стал фильтром агрегации: одна
|
||||
и та же комната показывала разное Zigbee-здоровье на двух карточках. Теперь
|
||||
агрегация и отрисовка используют разные списки — скрытые устройства
|
||||
считаются в сигнале на обеих карточках, не рисуются ни на одной и
|
||||
по-прежнему не дают света.
|
||||
- **Призрак не показывает живых чисел (HP-1510-02).** Скрытое устройство в
|
||||
«Показать скрытые» подавляло цвета состояний, но по-прежнему рисовало
|
||||
значение, температуру, влажность, бейдж LQI и морф иконки. Всё это убрано:
|
||||
у призрака только базовая иконка и имя — достаточно, чтобы узнать
|
||||
устройство и открыть диалог.
|
||||
|
||||
## v1.51.0 — 2026-07-29
|
||||
|
||||
**Скрытие — теперь явная галка** (docs/FILTERING.md)
|
||||
|
||||
- **В диалоге каждого устройства — включая виртуальные — есть галка «Скрыть
|
||||
устройство с плана».** Старый фильтр «на лету» остался только сеятелем
|
||||
этих галок: при первом открытии редактором конфиг один раз
|
||||
материализуется — нефизические устройства (мосты, сцены, служебные
|
||||
интеграции, лампы, свёрнутые в световую группу) получают галку, и дальше
|
||||
она принадлежит вам. Снятая галка — навсегда: сеятель не возвращается к
|
||||
устройству, о котором вы уже решили. Новые нефизические прячутся молча;
|
||||
физические — с красной точкой, как раньше.
|
||||
- **«Показать все» стала «Показать скрытые»** — локальный инструмент
|
||||
редактора устройств (на настенных планшетах ничего не мигает): скрытые
|
||||
рисуются полупрозрачными СИНИМИ пунктирными призраками — не спутать с
|
||||
серым недоступным — и совсем без живой индикации: призрак — это
|
||||
конфигурация, а не статус. Клик — и галку можно снять. «Удалить с плана»
|
||||
у привязанных устройств убрана (галка — единственный путь); «Удалить» у
|
||||
виртуального по-прежнему удаляет.
|
||||
- Скрытые устройства учитываются в Zigbee-сигнале комнаты, но не дают ни
|
||||
пятна света, ни заливки — не видно устройства, не видно и света. Климат
|
||||
комнат без изменений. Старые конфиги ведут себя по-старому, пока их не
|
||||
материализует клиент с правом записи.
|
||||
|
||||
**Жёлтый значит «работает прямо сейчас»**
|
||||
|
||||
- Один принцип для светящегося значка: лампа светит, розетка подаёт,
|
||||
вентилятор крутится, медиа играет, пылесос убирает — или термоголовка
|
||||
РЕАЛЬНО греет (hvac_action), а не просто включена на зиму. Раньше
|
||||
термоголовка могла светиться жёлтым из-за включённой защиты от накипи, а
|
||||
реально греющая оставалась тёмной: выбор главной сущности пропускал
|
||||
служебный свитч производителя вперёд видимого climate. Исправлено —
|
||||
служебная сущность больше никогда не побеждает видимую основную функцию
|
||||
(это чинит и tap-переключение, и морфинг иконок у таких устройств).
|
||||
- Пятно света и цвет значка задают один и тот же вопрос: горящая лампа
|
||||
желтит значок в любом режиме заливки ровно тем условием, что зажигает её
|
||||
пятно. README (en+ru) описывает язык цветов.
|
||||
|
||||
**Редакторы на телефоне**
|
||||
|
||||
- Пинч-зум и панорамирование жестами работают во всех редакторах: рисование
|
||||
кликовое, так что они совместимы — палец с движением панорамирует, два
|
||||
пальца зумируют, отпускание после жеста не ставит точку, чистый тап —
|
||||
ставит.
|
||||
|
||||
**Кнопка настроек комнаты**
|
||||
|
||||
- Отвязана от (передвигаемого) названия: стоит в ВИЗУАЛЬНОМ центре комнаты —
|
||||
центр наибольшей вписанной окружности с тяготением к центроиду площади,
|
||||
поэтому вытянутая комната центрирует её по обеим осям, а Г-образная
|
||||
держит в середине широкой части и не утаскивает в узкую.
|
||||
- Вдвое меньше прежней, размер от иконки устройства (70% бокса) и зумится
|
||||
ВМЕСТЕ с планом, а не держит постоянный экранный размер.
|
||||
- Мелкие подписи под названием комнаты (температура, влажность, сигнал,
|
||||
свет) теперь видны и в редакторе плана, а название рендерится ровно в том
|
||||
же месте в просмотре и в редакторе.
|
||||
|
||||
## v1.50.4 — 2026-07-29
|
||||
|
||||
**По ревью v1.50.3**
|
||||
|
||||
- **Обе карточки строят модель одним кодом (HP-1503-01).** Полная карточка
|
||||
носила рукописную копию общего построителя модели, и копия не получила
|
||||
фолбэки для legacy-store из v1.50.3 — один и тот же битый store в
|
||||
статической карточке рендерился починенным, а в основной — пустым
|
||||
`viewBox="0 0 0 0"`. Дубликат удалён: полная карточка вызывает общий
|
||||
построитель и лишь подменяет сырой url плана, который нужен её подписи.
|
||||
Новый смок прогоняет точный вектор аудита через обе модели и оба
|
||||
DOM-дерева и проверяет паритет.
|
||||
|
||||
## v1.50.3 — 2026-07-29
|
||||
|
||||
**По ревью v1.50.2**
|
||||
|
||||
- **Размер — не координата (HP-1502-01).** Граница ±4 из v1.50.2 мерила все
|
||||
четыре элемента view_box и w/h комнат одинаково, поэтому `[0, 0, 0, 0]` и
|
||||
отрицательные размеры всё ещё проходили — а нулевая ось сериализуется в
|
||||
`viewBox="0 0 0 0"`: пустой план у всех клиентов, и `aspect-ratio: 0 / 0` у
|
||||
статичной карточки сверху. У размеров теперь свой validator: строго
|
||||
положительные, пол — одна тысячная холста; координаты по-прежнему могут
|
||||
быть отрицательными — начало кропа законно выходит за край. А поскольку в
|
||||
сторе битый viewport может уже лежать, обе карточки падают на полный холст
|
||||
вместо пустого экрана, и legacy-прямоугольник с отрицательным размером
|
||||
читается как тот же прямоугольник, нарисованный из другого угла.
|
||||
|
||||
**Также в этом релизе**
|
||||
|
||||
- Кнопка настроек комнаты переехала в самый низ карточки комнаты, а имя
|
||||
комнаты отображается ровно в одном и том же месте в просмотре и в
|
||||
редакторе плана — кнопка и метрики больше не участвуют в центрировании.
|
||||
|
||||
## v1.50.2 — 2026-07-29
|
||||
|
||||
**По ревью v1.50.1**
|
||||
|
||||
+7
-2
@@ -49,8 +49,13 @@ cp dist/houseplan-card.js custom_components/houseplan/frontend/
|
||||
|
||||
## Deployment to the dacha (ha.jbstudio.pro)
|
||||
|
||||
- SSH: port **323**, root, key `ha_jb` (the user uploads it to the chat; in the sandbox /tmp/ha_jb, chmod 600).
|
||||
- JS: `scp -P 323 -i /tmp/ha_jb dist/houseplan-card.js root@ha.jbstudio.pro:/config/custom_components/houseplan/frontend/`
|
||||
- SSH: port **22222**, root, key `ha_jb` (lives in the user folder `houseplan/.secrets/ha_jb`,
|
||||
outside git; copy into the sandbox with chmod 600 — only ask the user if it is gone).
|
||||
- **The HA config root is `/mnt/data/supervisor/homeassistant`** — in this SSH
|
||||
environment `/config` does not exist; a deploy aimed at `/config/...` fails
|
||||
with "No such file or directory".
|
||||
- JS: `scp -P 22222 -i <key> dist/houseplan-card.js root@ha.jbstudio.pro:/mnt/data/supervisor/homeassistant/custom_components/houseplan/frontend/`
|
||||
- Cache busting: `sed` the `?v=` version in `.storage/lovelace_resources`, then restart HA.
|
||||
- **The `frontend/` subfolder is not optional.** `__init__.py` registers
|
||||
`Path(__file__).parent / "frontend" / "houseplan-card.js"` as the static path.
|
||||
A copy dropped next to `__init__.py` (…/houseplan/houseplan-card.js) is served
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Filtering: the explicit "hide from plan" flag
|
||||
|
||||
Agreed with the owner 2026-07-29. This document is the source of truth for the
|
||||
mechanism; the code follows it.
|
||||
|
||||
## Principle
|
||||
|
||||
Whether a device is on the plan is an EXPLICIT, per-device fact: the
|
||||
"Hide from plan" checkbox, stored as `marker.hidden`. The old on-the-fly
|
||||
filtering algorithm survives only as the SEEDER of those flags — it decides
|
||||
the initial value once, and the user owns the flag from then on.
|
||||
|
||||
## Data model
|
||||
|
||||
- `marker.hidden: true` — hidden from the plan. For an auto device without a
|
||||
marker, hiding creates a stub marker (this mechanism predates this spec).
|
||||
- `marker.hidden: false` (marker present) — explicitly VISIBLE: the seeder
|
||||
never touches a device that has any marker, so unhiding must KEEP the stub
|
||||
marker. That is the re-seed protection.
|
||||
- No marker — never evaluated by the seeder yet, or a plain physical device.
|
||||
- `settings.filter_seeded: true` — this config has been materialised.
|
||||
- `settings.show_all` — removed (deleted during materialisation). The old
|
||||
toggle was shared config state; the new one is a local editor tool.
|
||||
|
||||
## Seeding
|
||||
|
||||
"Non-physical" = the old filter rules: excluded integration domains, model
|
||||
"Group", scene-like models, bridges, myheat children, and individual lamp
|
||||
devices in an area covered by a light group (when group folding is on).
|
||||
|
||||
The seeder runs on the editing client (write permission required) whenever
|
||||
devices rebuild, and creates `hidden: true` stub markers for non-physical
|
||||
devices in BOUND areas that have NO marker. It is idempotent: marked devices
|
||||
are never revisited. It fires on:
|
||||
|
||||
1. first load of a config without `filter_seeded` (materialises the current
|
||||
behaviour; nothing changes visually, the flags become real and editable);
|
||||
2. an area newly bound to the plan;
|
||||
3. a new device appearing in a bound area — non-physical ones are hidden
|
||||
silently (no red dot); physical ones keep the red-dot flow.
|
||||
|
||||
Until a config is seeded (`filter_seeded` absent), `buildDevices` applies the
|
||||
LEGACY runtime filter, so a read-only client on an old config sees exactly
|
||||
the old behaviour until an editing client materialises it.
|
||||
|
||||
## Behaviour
|
||||
|
||||
- Hidden devices ARE built (flagged `hidden`), but not rendered in any mode,
|
||||
except the device editor with "Show hidden devices" on — there they render
|
||||
ghosted (translucent, dashed) and clicking opens the dialog to untick.
|
||||
- "Show hidden devices" (rename of "Show all") is LOCAL, ephemeral state of
|
||||
the current tab.
|
||||
- Room LQI counts hidden devices (owner's decision).
|
||||
- Light fill and glow do NOT count hidden devices — an invisible device casts
|
||||
no visible light (owner's decision). Room climate is registry-wide and
|
||||
unaffected, as before.
|
||||
- The checkbox appears in the dialog of EVERY device kind, virtual included.
|
||||
- "Remove from plan" disappears for auto/entity devices (the checkbox is the
|
||||
one way to hide); a virtual device's "Delete" remains a real deletion.
|
||||
- Duplicate names are still numbered, light groups still fold — those are
|
||||
aggregation, not hiding.
|
||||
+14
-12
@@ -35,12 +35,12 @@ Track progress in `custom_components/houseplan/quality_scale.yaml` (done/exempt
|
||||
- [ ] `test-coverage` ≥95% backend; frontend: extract remaining pure logic (view math,
|
||||
marker resolution) into `logic.ts`/`devices.ts` and cover with node:test.
|
||||
- [x] `diagnostics.py`: config + layout dump with `async_redact_data` (redact names/links/PDF paths).
|
||||
- [ ] `reconfiguration-flow` + richer **options flow**: admin_only, curation defaults
|
||||
- [ ] `reconfiguration-flow` + richer **options flow**: admin_only, filtering defaults
|
||||
(exclude domains — UI editable, replacing the hardcoded EXCLUDED_DOMAINS fallback),
|
||||
LQI thresholds, group_lights default.
|
||||
- [ ] `repair-issues`: broken plan file references, orphaned layout entries, storage
|
||||
migration failures → Repairs UI instead of silent logs.
|
||||
- [ ] `system_health.py`: config rev, spaces/markers count, storage sizes.
|
||||
- [x] `repair-issues`: broken plan references in Repairs (repairs.py, re-checked on
|
||||
every config save); geometry/repair WS command for stranded migrations (v1.50.1).
|
||||
- [x] `system_health.py` (v1.12.0).
|
||||
- [ ] `exception-translations` + `icon-translations` where applicable.
|
||||
- [ ] Frontend resource registration: adopt the community-consensus embedded-card pattern
|
||||
end-to-end (we already do StaticPathConfig + storage-mode resource + `?v=` busting;
|
||||
@@ -48,26 +48,28 @@ Track progress in `custom_components/houseplan/quality_scale.yaml` (done/exempt
|
||||
|
||||
## Phase 9 — Universality & flexibility (product depth)
|
||||
|
||||
- [ ] **Areas/floors registry integration**: import HA floors as spaces, suggest area
|
||||
- [x] **Areas/floors registry integration**: floors-import wizard (v1.13.0); further: suggest area
|
||||
bindings from the registry, sync names (HA is moving this way — native Areas/Home
|
||||
dashboard; riding the registry is our moat).
|
||||
- [ ] **Curation without hardcode**: icon rules (`iconFor`) become data — user-editable
|
||||
- [x] **Filtering without hardcode**: icon rules became data (v1.13.0); the runtime
|
||||
filter itself became explicit per-device hide flags in v1.51.0 (docs/FILTERING.md);
|
||||
icon rules are user-editable
|
||||
mapping (regex/domain/device_class → mdi icon) stored in config, shipping EN+RU
|
||||
defaults; drop dacha-specific patterns from code.
|
||||
- [ ] **Click actions** per device/domain: toggle / more-info / navigate / custom service
|
||||
call (configurable, like standard card `tap_action`).
|
||||
- [ ] **Theming**: respect light themes (currently dark-leaning), use HA theme variables
|
||||
- [x] **Click actions** per device (v1.13.0, simplified v1.38.1): card / more-info /
|
||||
toggle, with the lock/alarm safety model.
|
||||
- [x] **Theming**: light-theme pass done in v1.13.0; HA theme variables
|
||||
everywhere, optional per-space background color.
|
||||
- [ ] Multi-instance question: keep single-instance (one house) but support **multiple
|
||||
cards** with different default spaces (already works) — document as a decision.
|
||||
- [ ] Plan formats: keep SVG/PNG/JPG/WebP; add max dimensions guidance; optional
|
||||
auto-downscale on upload.
|
||||
- [ ] More locales: extract i18n dictionaries to JSON so contributors can add languages
|
||||
without touching TS.
|
||||
- [x] More locales: i18n dictionaries are JSON since v1.13.0 (src/i18n/*.json).
|
||||
|
||||
## Phase 10 — Community & distribution
|
||||
|
||||
- [ ] hacs/default PR #8995 through moderation (expect drafting for fixes).
|
||||
- [ ] hacs/default PR **#9004** through moderation (#8995 was bot-closed for a
|
||||
non-template body; #9004 is queued with the label since 2026-07-22).
|
||||
- [ ] Demo GIF/video for README (the single biggest driver of adoption for dashboard cards).
|
||||
- [ ] Forum post in the Floorplan category + Reddit r/homeassistant showcase once
|
||||
the demo assets exist.
|
||||
|
||||
+29
-8
@@ -11,16 +11,16 @@
|
||||
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
|
||||
> ARCHITECTURE.md for design changes, ROADMAP.md when plans move.
|
||||
|
||||
## Snapshot (2026-07-28)
|
||||
## Snapshot (2026-07-29)
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.50.2** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Version | **v1.52.2** 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 1–3 months (checked 2026-07-24) |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.50.2** 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.2** 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 |
|
||||
@@ -48,6 +48,24 @@
|
||||
- **Dialog UX**: binding radios + entities checkbox + search dropdown
|
||||
(v1.38.0); tap actions simplified to Device card / more-info / Toggle,
|
||||
right-click → more-info (v1.38.1); Esc closes every dialog (v1.30.4).
|
||||
- **Room settings, tier 3** (v1.42.0): per-room fill/temp-source/label sizes;
|
||||
the settings button sits at the room's VISUAL centre (inscribed circle +
|
||||
centroid pull), icon-derived size, zooms with the plan (v1.51.0).
|
||||
- **Files & plans** (v1.44–v1.50): signed content urls with sandbox CSP,
|
||||
copy-on-write plan files, "already uploaded" picker + explicit delete
|
||||
(v1.47.0), store quotas instead of any age-based deletion (v1.49.0),
|
||||
nothing is ever deleted on an inference (docs/SCOPE.md rule).
|
||||
- **Square canvas** (v1.48.0) with a crash-safe two-store migration
|
||||
(geom_pending, v1.50.0) and an explicit geometry/repair command (v1.50.1);
|
||||
content-fit default zoom with devices as content, zoom out to 0.4×,
|
||||
measured stage height (v1.49–v1.50.2).
|
||||
- **Explicit hide flags** (v1.51.0, docs/FILTERING.md): per-device
|
||||
"Hide from plan" checkbox seeded once from the old filter; local
|
||||
"Show hidden" ghosts; hidden counts toward room LQI on both cards, casts
|
||||
no light (v1.51.1).
|
||||
- **Yellow = working right now** (v1.51.0): climate by hvac_action, service
|
||||
switches can no longer become primary, glow pool and icon share one
|
||||
condition. Editor gestures on touch (pinch/pan) landed the same release.
|
||||
|
||||
## Recent milestones (details in CHANGELOG.md)
|
||||
|
||||
@@ -129,8 +147,9 @@
|
||||
|
||||
1. **hacs/default PR #9004** — accepted by the bot into the review queue ('New default
|
||||
repository' label). Minor issues ⇒ the bot drafts the PR (fix and re-ready).
|
||||
2. GitHub PAT `houseplan-card-publish` (repo+workflow) expires ~2026-07-12; in sandbox
|
||||
`~/.git-credentials`. Revoke after the HACS queue clears, or re-issue when needed.
|
||||
2. GitHub auth: fine-grained PAT (Contents R/W, issued 2026-07-23) in the sandbox
|
||||
`~/.git-credentials`; pushes go over SSH with the `ha_jb` key. The old classic PAT
|
||||
expired and is gone.
|
||||
3. Privacy: legacy real-house plan sources (`assets/`) removed from the tree in
|
||||
v1.13.3, but they persist in git history and old release archives; 8 README
|
||||
screenshots in docs/images are still from the real house (replacement with
|
||||
@@ -150,12 +169,14 @@
|
||||
2. Restore the repo: `git clone <user-folder>/houseplan-card.git.bundle hpcN` in `/tmp`
|
||||
(files from *previous* sandbox sessions in `/tmp` belong to `nobody` and are unreadable —
|
||||
always clone into a fresh directory; `npm ci` again).
|
||||
3. Deployment needs the `ha_jb` SSH key — ask the user to upload it (uploads are readable
|
||||
only in the session they were uploaded in).
|
||||
3. Deployment needs the `ha_jb` SSH key — it lives in the user folder at
|
||||
`houseplan/.secrets/ha_jb` (outside git) and often survives in the sandbox home
|
||||
`~/.ssh/ha_jb`; copy with chmod 600. Only ask the user if both are gone.
|
||||
4. Build only in `/tmp` (never on the mount), `npm run build` (starts with `tsc --noEmit`),
|
||||
md5-verify after every deploy, restart HA via
|
||||
`nohup ha core restart >/dev/null 2>&1 </dev/null &` (otherwise the SSH session hangs).
|
||||
5. GitHub pushes need a PAT (create via the user's Chrome: settings/tokens, repo+workflow scope).
|
||||
5. GitHub pushes: SSH remote with the `ha_jb` key; API releases with the fine-grained
|
||||
PAT from `~/.git-credentials` (see the watchlist).
|
||||
|
||||
## Product scope
|
||||
|
||||
|
||||
+67
-4
@@ -206,8 +206,10 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
lock locked↔unlocked, bulb on; custom icons and unavailable states never morph [manual]
|
||||
- [ ] display "Value instead of an icon": the marker shows the measurement (°/%/unit)
|
||||
as its body, small badges hidden; non-numeric fallback keeps the icon [manual]
|
||||
- [ ] RGB lights (v1.27.0): an on light with a color tints its icon/glow and the ripple
|
||||
(explicit ripple color still wins); off/white lights unchanged [manual]
|
||||
- [ ] RGB lights (v1.27.0, contract changed in v1.52.0): a lamp's colour lives
|
||||
in its glow spot and the ripple fallback ONLY — the icon/badge/border get
|
||||
no RGB tint; explicit ripple color still wins; off lights unchanged
|
||||
[auto: smoke_light_badges + smoke_rgb_alarm]
|
||||
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
|
||||
display mode; clears on 'off'; unavailable never alarms [manual]; reduced-motion static
|
||||
- [ ] Render cost (v1.43.1, audit L1): geometry (space model, open pairs) is
|
||||
@@ -239,6 +241,66 @@ 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]
|
||||
- [ ] Room settings button (dev): detached from the (movable) name label —
|
||||
always at the room's geometric centre, one button-height below it; sized
|
||||
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]
|
||||
- [ ] One indicator always (v1.52.1, HP-1520-01): in a glow space the plan
|
||||
editor (no glow layer) shows the lit lamp yellow again; the devices
|
||||
editor draws the layer and keeps the badge standard
|
||||
[auto: smoke_light_badges]
|
||||
- [ ] Size/angle parity (v1.52.1, HP-1513-01): a marker with size 3 / angle 37
|
||||
scales x3 and rotates on BOTH cards [auto: smoke_size_angle_parity]
|
||||
- [ ] 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]
|
||||
- [ ] Auto-grid parity (v1.51.2, HP-1511-01): with an empty layout, a visible
|
||||
device among hidden ones sits at the same spot on both cards
|
||||
[auto: smoke_hidden_flag autoGridParity]
|
||||
- [ ] Ripple ghost (v1.51.2, HP-1511-02): a hidden ripple-display marker shows
|
||||
its base icon, no pulse [auto: smoke_hidden_flag rippleGhost*]
|
||||
- [ ] Hidden LQI parity (v1.51.1, HP-1510-01): a room whose only Zigbee
|
||||
devices are hidden paints the same lqi fill on the full and the static
|
||||
card [auto: smoke_hidden_flag lqiParity]
|
||||
- [ ] Ghost shows no numbers (v1.51.1, HP-1510-02): a hidden value-display
|
||||
device renders as a plain ghost — no value/temp/hum/LQI, no icon morph
|
||||
[auto: smoke_hidden_flag ghostHidesValue]
|
||||
- [ ] Hide-from-plan flag (dev, docs/FILTERING.md): every device dialog has
|
||||
the checkbox, incl. virtual; hidden devices vanish from every mode and
|
||||
the count, still count toward room LQI, cast no glow/light fill; the
|
||||
device editor's "Show hidden" (local, per tab) shows them as BLUE
|
||||
dashed ghosts — distinct from a grey unavailable icon — with NO live
|
||||
state paint (no yellow, no alarm, no ripple);
|
||||
unticking keeps a hidden:false marker (re-seed protection); an old
|
||||
config materialises on first load by an editing client and legacy
|
||||
clients keep the old behaviour until then
|
||||
[auto: smoke_hidden_flag + unit seedHiddenBindings/seeded/legacy]
|
||||
- [ ] 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 turns its state on by
|
||||
the same condition that lights the glow pool — and shows the yellow
|
||||
badge only where the glow spot is NOT drawn: with the glow layer
|
||||
visible the spot is the one indicator (v1.52.0)
|
||||
[auto: smoke_yellow_principle + smoke_light_badges]
|
||||
- [ ] 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]
|
||||
- [ ] Legacy geometry parity (v1.50.4, HP-1503-01): a store with a zero
|
||||
viewport and a negative rect renders identically sane in BOTH cards —
|
||||
full canvas fallback, normalised rectangle [auto: smoke_legacy_geometry]
|
||||
- [ ] Sizes are positive (v1.50.3, HP-1502-01): view_box or room w/h of zero
|
||||
or below is refused; a store that already holds one opens on the full
|
||||
canvas, not a blank screen [auto: test_sizes_are_not_coordinates + unit
|
||||
safeViewBox fallback]
|
||||
- [ ] Room card layout (v1.50.3): the settings button is the bottom row of the
|
||||
card and the room name sits in the same spot in view and plan modes
|
||||
[manual; verified by vb-coordinate measurement]
|
||||
- [ ] Geometry bounds (v1.50.2, HP-1501-01): a config with a 1e100 room
|
||||
vertex is refused by the server; one already stored still renders with a
|
||||
sane frame [auto: test_geometry_magnitudes_are_bounded + unit
|
||||
@@ -513,8 +575,9 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
same domain still win over hidden ones [manual: click hallway lamps]
|
||||
- [ ] Marker controls (v1.36.0): a marker with "Controls light sources" and
|
||||
tap action Toggle flips all bound lights/switches at once (any on → all
|
||||
off, all off → all on, one service call); the icon and its RGB tint
|
||||
mirror the targets, not the marker's own entity; without explicit Toggle
|
||||
off, all off → all on, one service call); the icon state (yellow badge)
|
||||
mirrors the targets, not the marker's own entity — the RGB tint is gone
|
||||
since v1.52.0, target colours reach only the glow/ripple; without explicit Toggle
|
||||
the click opens info as usual; the info card lists targets with states;
|
||||
locks/other domains are filtered out of controls [auto: smoke_controls]
|
||||
- [ ] Glow fill (v1.35.0): fill mode "Light sources" — every room painted with
|
||||
|
||||
+8
-4
@@ -59,8 +59,10 @@ Header in View: space tabs, device count, zoom cluster. Nothing else.
|
||||
- Icon dragging (ONLY here). Click on a device opens the **edit dialog directly**
|
||||
(binding, name, icon, size/angle, display badge/ripple + colors, tap override,
|
||||
model/link/description/PDFs, room).
|
||||
- + add device/entity/virtual, hide device, ↺ reset layout, 👁 show-all (curation
|
||||
tool), ⬡ icon rules.
|
||||
- + add device/entity/virtual, the "Hide device from plan" checkbox (since
|
||||
v1.51.0 the one hiding mechanism, docs/FILTERING.md), ↺ reset layout,
|
||||
👁 "Show hidden" (local editor tool; replaced the shared show-all toggle),
|
||||
⬡ icon rules.
|
||||
|
||||
## Deprecations decided
|
||||
|
||||
@@ -74,7 +76,9 @@ Header in View: space tabs, device count, zoom cluster. Nothing else.
|
||||
|
||||
1. State-reflecting icons (open/closed door variants etc., like core HA).
|
||||
2. `display: value` — show the measurement instead of an icon.
|
||||
3. Light color in the icon/ripple (RGB lights).
|
||||
3. Light color in the icon/ripple (RGB lights). *(Shipped in v1.27.0;
|
||||
superseded in v1.52.0: the colour lives in the glow spot and the ripple
|
||||
fallback only, the icon tint was removed by the owner's rule.)*
|
||||
4. Alarm visual (leak/smoke/doorbell): red pulse overlay.
|
||||
5. Rooms as sub-areas without an HA area + manual device placement by room id.
|
||||
6. Backlog (not planned): music notes for players, directional TV ripples.
|
||||
@@ -85,7 +89,7 @@ Header in View: space tabs, device count, zoom cluster. Nothing else.
|
||||
edit interactions/buttons (biggest UX win, smallest surface).
|
||||
- **It.2 — Plan tab:** move markup tools + space dialogs + labels drag + openings
|
||||
editing under Plan; colored frame indicator.
|
||||
- **It.3 — Devices tab:** drag + direct-edit click + curation tools under Devices.
|
||||
- **It.3 — Devices tab:** drag + direct-edit click + filtering tools under Devices.
|
||||
- **It.4+:** follow-up features 1–5 above, each its own release.
|
||||
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.50.2",
|
||||
"version": "1.52.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.50.2",
|
||||
"version": "1.52.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.50.2",
|
||||
"version": "1.52.2",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+88
-16
@@ -14,6 +14,8 @@ export interface BuildCtx {
|
||||
markers: Marker[];
|
||||
settings: ServerConfig['settings'];
|
||||
excluded: Set<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. */
|
||||
@@ -56,14 +58,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 +82,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[] = [];
|
||||
@@ -175,6 +204,7 @@ export function resolveIcon(hass: any, name: string, model: string | undefined,
|
||||
|
||||
function applyMarker(item: DevItem, m: Marker): void {
|
||||
item.marker = m;
|
||||
if (m.hidden) item.hidden = true;
|
||||
if (m.name) item.name = m.name;
|
||||
if (m.icon) item.icon = m.icon;
|
||||
if (m.model != null) item.model = m.model;
|
||||
@@ -184,6 +214,43 @@ function applyMarker(item: DevItem, m: Marker): void {
|
||||
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 { hass: h, areaToSpace, markers, settings, excluded, 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 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;
|
||||
const entIds = entsBy[dev.id] || [];
|
||||
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;
|
||||
}
|
||||
|
||||
/** Filtering + 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;
|
||||
@@ -207,11 +274,13 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
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;
|
||||
if (marker && marker.hidden && !settings.filter_seeded) continue; // legacy: dropped entirely
|
||||
const entIds = entsBy[dev.id] || [];
|
||||
const dom = domainOfDevice(h, dev, entIds);
|
||||
// filtering (can be turned off with the “show all” toggle)
|
||||
if (!showAll) {
|
||||
// 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;
|
||||
@@ -222,7 +291,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
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;
|
||||
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;
|
||||
@@ -265,7 +334,10 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
|
||||
// 3) explicit markers (rebinding/metadata/virtual)
|
||||
for (const m of markers) {
|
||||
if (m.hidden) continue;
|
||||
// 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).
|
||||
if (m.hidden && !settings.filter_seeded) continue;
|
||||
const [kind, ref] = m.binding.split(':');
|
||||
if (kind === 'device') {
|
||||
const dev = h.devices[ref];
|
||||
@@ -343,10 +415,10 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
* 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' {
|
||||
export function areaLights(hass: any, devices: { area: string; entities: string[]; hidden?: boolean }[], area: string): 'on' | 'off' | 'none' {
|
||||
let seen = false;
|
||||
for (const d of devices) {
|
||||
if (d.area !== area) continue;
|
||||
if (d.area !== area || d.hidden) continue; // an invisible device casts no visible light
|
||||
for (const eid of d.entities) {
|
||||
if (!eid.startsWith('light.')) continue;
|
||||
seen = true;
|
||||
@@ -503,13 +575,13 @@ export function areaClimate(
|
||||
/** 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[] }[],
|
||||
devices: { area: string; entities: string[]; hidden?: boolean }[],
|
||||
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;
|
||||
if (dv.area !== area || dv.hidden) continue; // hidden lights are not part of the picture
|
||||
for (const eid of dv.entities) {
|
||||
if (!eid.startsWith('light.') || seen.has(eid)) continue;
|
||||
seen.add(eid);
|
||||
|
||||
+194
-69
@@ -15,7 +15,7 @@ import {
|
||||
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
|
||||
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale, migratePdfUrls, roomFillModeOf, contentUrl,
|
||||
snapToWall, openingAmount,
|
||||
snapToWall, openingAmount, interiorPoint, poleOfInaccessibility,
|
||||
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
||||
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
|
||||
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors,
|
||||
@@ -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, seedHiddenBindings, 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,
|
||||
@@ -33,10 +33,10 @@ import type {
|
||||
import './editor';
|
||||
import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
import { fitInSquare, contentBounds } from './space-geometry';
|
||||
import { fitInSquare, contentBounds, spaceModels } from './space-geometry';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.50.2';
|
||||
const CARD_VERSION = '1.52.2';
|
||||
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';
|
||||
@@ -293,7 +293,8 @@ class HouseplanCard extends LitElement {
|
||||
link: string;
|
||||
description: string;
|
||||
pdfs: PdfRef[];
|
||||
room: string; // 'space#area' for a virtual one
|
||||
room: string;
|
||||
hideFromPlan: boolean; // 'space#area' for a virtual one
|
||||
busy: boolean;
|
||||
} | null = null;
|
||||
private _spaceDialog: {
|
||||
@@ -641,31 +642,18 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
private _buildModel(): SpaceModel[] {
|
||||
if (!this._serverCfg) return [];
|
||||
return this._serverCfg.spaces.map((s: any) => {
|
||||
const H = NORM_W; // the canvas is always square (v1.48.0)
|
||||
const scale = (r: any) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
area: r.area ?? null,
|
||||
open_to: r.open_to || undefined,
|
||||
settings: r.settings || undefined,
|
||||
x: r.x != null ? r.x * NORM_W : undefined,
|
||||
y: r.y != null ? r.y * H : undefined,
|
||||
w: r.w != null ? r.w * NORM_W : undefined,
|
||||
h: r.h != null ? r.h * H : undefined,
|
||||
poly: r.poly ? r.poly.map((p: number[]) => [p[0] * NORM_W, p[1] * H]) : undefined,
|
||||
});
|
||||
return {
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
|
||||
// raw url on purpose: the model is memoized on the config fingerprint,
|
||||
// so a signed url baked in here would freeze BEFORE the signature
|
||||
// arrives and the plan would never load (bug found 2026-07-27).
|
||||
// _display() is called at render time instead.
|
||||
bg: s.plan_url ? { href: s.plan_url, ...fitInSquare(s.plan_aspect, NORM_W) } : null,
|
||||
rooms: s.rooms.map(scale),
|
||||
};
|
||||
// ONE model builder for both cards. This used to be a hand-copied twin of
|
||||
// spaceModels(), and the twin missed the legacy-store fallbacks the shared
|
||||
// one gained (safeViewBox, normRect) — the same broken store rendered fine
|
||||
// in the static card and as viewBox="0 0 0 0" here (HP-1503-01). The only
|
||||
// difference this card needs is the url: raw on purpose, because the model
|
||||
// is memoized on the config fingerprint, so a signed url baked in here
|
||||
// would freeze BEFORE the signature arrives and the plan would never load
|
||||
// (bug found 2026-07-27). _display() is called at render time instead.
|
||||
const cfg = this._serverCfg;
|
||||
return spaceModels(cfg).map((m, i) => {
|
||||
const raw = (cfg.spaces[i] as any)?.plan_url;
|
||||
return m.bg && raw ? { ...m, bg: { ...m.bg, href: raw } } : m;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -684,13 +672,67 @@ class HouseplanCard extends LitElement {
|
||||
return this._serverCfg?.settings || {};
|
||||
}
|
||||
|
||||
/** LOCAL editor tool (docs/FILTERING.md): show the hidden devices ghosted.
|
||||
* The old toggle wrote settings.show_all — shared state that flipped the
|
||||
* plan for every wall tablet at once. Legacy configs still honour it
|
||||
* through buildDevices until they are seeded. */
|
||||
private _showHidden = false;
|
||||
|
||||
private get _showAll(): boolean {
|
||||
return !!this._settings.show_all;
|
||||
return this._settings.filter_seeded ? this._showHidden : !!this._settings.show_all;
|
||||
}
|
||||
|
||||
private _toggleShowAll(): void {
|
||||
if (!this._serverCfg) return;
|
||||
this._serverCfg = { ...this._serverCfg, settings: { ...this._serverCfg.settings, show_all: !this._showAll } };
|
||||
if (this._settings.filter_seeded) {
|
||||
this._showHidden = !this._showHidden;
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
// legacy config: the old shared behaviour until an editor materialises it
|
||||
this._serverCfg = { ...this._serverCfg, settings: { ...this._serverCfg.settings, show_all: !this._settings.show_all } };
|
||||
this._regSignature = '';
|
||||
this._maybeRebuildDevices();
|
||||
this._saveConfig();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* The seeder (docs/FILTERING.md): materialise the filter into explicit
|
||||
* hidden flags. Runs after every device rebuild on a client that may write;
|
||||
* idempotent — a device with ANY marker is never revisited, so an unticked
|
||||
* checkbox (a marker with hidden: false) is re-seed protection. Also
|
||||
* removes freshly hidden ids from the red-dot list: a dot on an invisible
|
||||
* device would wait forever.
|
||||
*/
|
||||
private _seedHiddenDevices(): void {
|
||||
if (!this._serverCfg || !this._norm || !this._canEdit) return;
|
||||
const cfg = this._serverCfg;
|
||||
const bindings = seedHiddenBindings({
|
||||
hass: this.hass,
|
||||
areaToSpace: Object.fromEntries(
|
||||
Object.entries(this._areaToSpace).map(([a, v]) => [a, v.space]),
|
||||
),
|
||||
markers: this._markers,
|
||||
settings: this._settings,
|
||||
excluded: this._excluded,
|
||||
firstSpaceId: this._model[0]?.id || '',
|
||||
iconRules: this._iconRules,
|
||||
});
|
||||
if (!bindings.length && cfg.settings?.filter_seeded) return;
|
||||
cfg.markers = cfg.markers || [];
|
||||
const hiddenIds: string[] = [];
|
||||
for (const b of bindings) {
|
||||
const id = 'h' + b.slice(b.indexOf(':') + 1);
|
||||
cfg.markers.push({ id, binding: b, hidden: true });
|
||||
hiddenIds.push(b.slice(b.indexOf(':') + 1));
|
||||
}
|
||||
const st = { ...(cfg.settings || {}), filter_seeded: true } as any;
|
||||
delete st.show_all; // the shared toggle retires with the runtime filter
|
||||
if (hiddenIds.length && Array.isArray(st.new_device_ids)) {
|
||||
st.new_device_ids = st.new_device_ids.filter((x: string) => !hiddenIds.includes(x));
|
||||
}
|
||||
cfg.settings = st;
|
||||
this._regSignature = '';
|
||||
this._maybeRebuildDevices();
|
||||
this._saveConfig();
|
||||
@@ -1020,6 +1062,7 @@ class HouseplanCard extends LitElement {
|
||||
});
|
||||
this._defPos = this._defaultPositions();
|
||||
this._syncNewDevices();
|
||||
this._seedHiddenDevices();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1170,6 +1213,13 @@ 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 yields 'on' here by the SAME condition that lights the
|
||||
// glow pool, so the pool and the state cannot disagree. Note the renderer
|
||||
// then STRIPS the class wherever the glow layer is actually visible —
|
||||
// there the spot is the one indicator (v1.52.0, owner's rule); the badge
|
||||
// stays yellow only where the spot is not drawn (other fills, the plan
|
||||
// editor).
|
||||
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';
|
||||
@@ -1178,6 +1228,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') {
|
||||
@@ -1429,8 +1487,17 @@ class HouseplanCard extends LitElement {
|
||||
clearTimeout(this._kioskHoldTimer);
|
||||
}
|
||||
}
|
||||
// do not interfere with icon dragging and markup drawing
|
||||
if (this._drag || this._markup) return;
|
||||
// do not interfere with icon and label dragging
|
||||
if (this._drag) return;
|
||||
if (this._markup) {
|
||||
// Drawing is CLICK-based, so gestures coexist with it: a finger that
|
||||
// MOVES pans, two fingers pinch, and _suppressClick keeps the release
|
||||
// from feeding the active tool. The editor used to opt out of gestures
|
||||
// entirely, which left a phone with no way to zoom or pan the plan
|
||||
// (owner's report). Pointers that begin on interactive children still
|
||||
// stay out — labels and handles run their own drags.
|
||||
if ((ev.target as HTMLElement).closest?.('.roomlabel, .rlhandle, .dev, .oplock, .op-hit, button')) return;
|
||||
}
|
||||
if (this._mode === 'devices' && (ev.target as HTMLElement).closest('.dev')) return;
|
||||
if (this._mode === 'decor' && this._decorPointerDown(ev)) return;
|
||||
this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
|
||||
@@ -1460,6 +1527,8 @@ class HouseplanCard extends LitElement {
|
||||
return;
|
||||
}
|
||||
this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
|
||||
// the tool preview (snap dot, measure label) keeps following the finger
|
||||
if (this._markup && this._pointers.size === 1) this._markupMove(ev);
|
||||
if (this._pinchStart && this._pointers.size >= 2) {
|
||||
const pts = [...this._pointers.values()];
|
||||
const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
|
||||
@@ -1835,6 +1904,8 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
private _markupClick(ev: MouseEvent): void {
|
||||
if (!this._markup) return;
|
||||
// a pan or pinch just happened — the synthesized click is not a draw
|
||||
if (this._suppressClick) return;
|
||||
// Room cards swallow markup clicks: dragging, resizing or just clicking a
|
||||
// card must not feed the active tool (draw point, delete room, merge/split
|
||||
// pick, opening placement). The drag itself already stops pointer events,
|
||||
@@ -2741,6 +2812,7 @@ class HouseplanCard extends LitElement {
|
||||
room: d.marker?.room_id
|
||||
? d.space + '#@' + d.marker.room_id
|
||||
: d.space && d.area ? d.space + '#' + d.area : '',
|
||||
hideFromPlan: d.marker?.hidden === true,
|
||||
busy: false,
|
||||
};
|
||||
} else {
|
||||
@@ -2750,7 +2822,7 @@ class HouseplanCard extends LitElement {
|
||||
display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0,
|
||||
tapAction: '', defaultTap: 'info', controls: [], controlsFilter: '', isLight: false,
|
||||
glowRadius: '', model: '',
|
||||
link: '', description: '', pdfs: [], room: '', busy: false,
|
||||
link: '', description: '', pdfs: [], room: '', hideFromPlan: false, busy: false,
|
||||
uploadId: 'up_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
||||
};
|
||||
}
|
||||
@@ -2954,6 +3026,10 @@ class HouseplanCard extends LitElement {
|
||||
link: dlg.link.trim() || null,
|
||||
description: dlg.description.trim() || null,
|
||||
pdfs: dlg.pdfs,
|
||||
// Explicit false, not absence: a marker of any kind is what tells the
|
||||
// seeder "the user decided" — unticking must not invite a re-hide
|
||||
// (docs/FILTERING.md).
|
||||
hidden: dlg.hideFromPlan ? true : false,
|
||||
};
|
||||
// save the room choice (always for virtual ones; for bound ones — if chosen)
|
||||
if (dlg.binding === 'virtual' || dlg.room) {
|
||||
@@ -3600,15 +3676,13 @@ class HouseplanCard extends LitElement {
|
||||
const spots: { pos: { x: number; y: number }; c: string; alpha: number; clip: string[] | null; r: number }[] = [];
|
||||
for (const d of this._devices) {
|
||||
if (d.space !== space.id) continue;
|
||||
if (d.hidden) continue; // an invisible device casts no visible light (docs/FILTERING.md)
|
||||
// A light source is normally a device with a lit light.* entity. With the
|
||||
// "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;
|
||||
@@ -3886,7 +3960,11 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
const space = this._spaceModel();
|
||||
const vb = space.vb;
|
||||
const devs = this._devices.filter((d) => d.space === space.id);
|
||||
// hidden devices render ONLY in the device editor with "show hidden" on
|
||||
// (ghosted); everywhere else the flag removes them from sight — but not
|
||||
// from the build, so room LQI still counts them (docs/FILTERING.md)
|
||||
const showGhosts = this._mode === 'devices' && this._showAll;
|
||||
const devs = this._devices.filter((d) => d.space === space.id && (!d.hidden || showGhosts));
|
||||
const disp = spaceDisplayOf(this._curSpaceCfg);
|
||||
const showLqi = disp.showLqi ?? this._config.show_signal ?? true;
|
||||
const cfgSize = this._config.icon_size ?? 2.5;
|
||||
@@ -3945,7 +4023,7 @@ class HouseplanCard extends LitElement {
|
||||
)}
|
||||
</div>`
|
||||
: nothing}
|
||||
<span class="count">${this._t('count.devices', { n: devs.length })}</span>
|
||||
<span class="count">${this._t('count.devices', { n: devs.filter((d) => !d.hidden).length })}</span>
|
||||
<span class="spacer"></span>
|
||||
<div class="zoomctl">
|
||||
<button class="btn zb" @click=${() => this._stepZoom(-1)} title=${this._t('title.zoom_out')}><ha-icon icon="mdi:minus"></ha-icon></button>
|
||||
@@ -4077,11 +4155,12 @@ 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._markup))}
|
||||
${this._renderOpeningLocks(view)}
|
||||
${disp.showNames || this._markup
|
||||
? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp))
|
||||
: nothing}
|
||||
${this._markup ? space.rooms.map((r) => this._renderRoomGear(r, space, view)) : nothing}
|
||||
</div>
|
||||
${this._measureAnchor
|
||||
? html`<div class="measurelayer">${this._renderMeasureLabel(view)}</div>`
|
||||
@@ -4126,20 +4205,35 @@ 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;
|
||||
const cls = this._stateClass(d);
|
||||
const temp = this._liveTemp(d);
|
||||
const hum = this._liveHum(d);
|
||||
const lqi = showLqi && !d.virtual ? lqiFor(this.hass, d.entities) : null;
|
||||
// a ghost is configuration, not status: no yellow, no open, no unavail —
|
||||
// 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.
|
||||
// The owner's rule for LIGHT SOURCES (2026-07-29): where the glow layer
|
||||
// is VISIBLE, the indicator IS the glow spot — the badge stays standard,
|
||||
// on or off; wherever the spot is not drawn (other fills, the plan
|
||||
// editor) a lit source goes plain yellow like a heating TRV. The gate
|
||||
// must equal the layer's visibility, or a mode ends up with neither
|
||||
// indicator (HP-1520-01). "Source" is exactly the litLightEntity
|
||||
// condition that casts the spot, so a lit socket keeps its yellow.
|
||||
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;
|
||||
const m = d.marker;
|
||||
const disp = m?.display || 'badge';
|
||||
const ripple = disp === 'ripple' || disp === 'icon_ripple';
|
||||
// a ghost drops the ripple presentation entirely (HP-1511-02): an
|
||||
// icon-less pulse is unrecognisable, and the release contract says a
|
||||
// ghost keeps the base icon and name — display modes are status dressing
|
||||
const ripple = (disp === 'ripple' || disp === 'icon_ripple') && !d.hidden;
|
||||
// value-only display: the measurement IS the marker
|
||||
const primarySt = d.primary ? this.hass.states[d.primary] : undefined;
|
||||
const valText = disp === 'value'
|
||||
const valText = disp === 'value' && !d.hidden
|
||||
? (temp != null ? temp + '°'
|
||||
: hum != null ? hum + '%'
|
||||
: primarySt && !isNaN(parseFloat(primarySt.state))
|
||||
@@ -4148,21 +4242,23 @@ class HouseplanCard extends LitElement {
|
||||
: null;
|
||||
// live state variants of the auto icon (doors, locks, bulbs), like core HA
|
||||
const domain = d.primary ? d.primary.split('.')[0] : null;
|
||||
const icon = this._config?.live_states
|
||||
const icon = this._config?.live_states && !d.hidden
|
||||
? stateIcon(d.icon, domain, primarySt?.attributes?.device_class, primarySt?.state, !!m?.icon)
|
||||
: d.icon;
|
||||
// RGB lights color the icon (and the ripple, unless a custom ripple color is set);
|
||||
// an icon with controlled targets takes the color of its first lit RGB target
|
||||
// v1.52.0: a lamp's colour lives in its GLOW and in the ripple fallback
|
||||
// only — the icon/border tint is gone. lightC is still computed (from the
|
||||
// marker's first lit RGB target, else the primary light) purely to feed
|
||||
// --ripple-color when no explicit ripple colour is set.
|
||||
const ctrl = (m?.controls || []).filter(isControllable);
|
||||
const lightC = this._config?.live_states
|
||||
const lightC = this._config?.live_states && !d.hidden
|
||||
? ctrl.length
|
||||
? ctrl.map((e) => lightColorOf(this.hass.states[e])).find((v) => v) || null
|
||||
: domain === 'light' ? lightColorOf(primarySt) : null
|
||||
: null;
|
||||
// emergencies (leak/smoke/gas/CO/siren) pulse red regardless of display mode
|
||||
const alarm = this._config?.live_states
|
||||
const alarm = this._config?.live_states && !d.hidden
|
||||
&& isAlarmState(domain, primarySt?.attributes?.device_class, primarySt?.state);
|
||||
const active = ripple && !!d.primary && isActiveState(this.hass.states[d.primary]?.state);
|
||||
const active = ripple && !d.hidden && !!d.primary && isActiveState(this.hass.states[d.primary]?.state);
|
||||
const scale = Number(m?.size) > 0 ? Number(m!.size) : 1;
|
||||
const angle = Number(m?.angle) || 0;
|
||||
const rScale = Number(m?.ripple_size) > 0 ? Number(m!.ripple_size) : 3;
|
||||
@@ -4173,9 +4269,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' : ''} ${disp === 'ripple' ? '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)}
|
||||
@@ -4194,7 +4290,7 @@ class HouseplanCard extends LitElement {
|
||||
${this._newIds.has(d.id) ? html`<span class="newdot" title=${this._t('device.new')}></span>` : nothing}
|
||||
${valText != null
|
||||
? html`<span class="valtext">${valText}</span>`
|
||||
: disp !== 'ripple'
|
||||
: disp !== 'ripple' || d.hidden
|
||||
? html`<ha-icon icon="${icon}" style=${angle ? `transform:rotate(${angle}deg)` : nothing}></ha-icon>`
|
||||
: nothing}
|
||||
${temp != null && valText == null ? html`<span class="tval">${temp}°</span>` : nothing}
|
||||
@@ -4425,6 +4521,37 @@ class HouseplanCard extends LitElement {
|
||||
this._persistLayout();
|
||||
}
|
||||
|
||||
/** The room-settings button: detached from the (movable) label, always at
|
||||
* the geometric CENTRE of the room, sized at 70% of a device icon and
|
||||
* therefore zooming with the plan (owner's spec, 2026-07-29). */
|
||||
private _gearPtCache = new WeakMap<number[][], number[]>();
|
||||
|
||||
private _renderRoomGear(
|
||||
r: RoomCfg, space: SpaceModel, view: { x: number; y: number; w: number; h: number },
|
||||
): TemplateResult | typeof nothing {
|
||||
if (!r.id) return nothing;
|
||||
let c: number[] | null = null;
|
||||
if (r.poly) {
|
||||
// the VISUAL centre (largest inscribed circle) — interiorPoint only
|
||||
// promises "inside", which sat visibly off-centre on an L-shaped room.
|
||||
// The model is memoized, so the poly array is a stable cache key.
|
||||
c = this._gearPtCache.get(r.poly) || null;
|
||||
if (!c) { c = poleOfInaccessibility(r.poly); this._gearPtCache.set(r.poly, c); }
|
||||
} else if (r.x != null && r.y != null) {
|
||||
c = [r.x + (r.w || 0) / 2, r.y + (r.h || 0) / 2];
|
||||
}
|
||||
if (!c) return nothing;
|
||||
const left = ((c[0] - view.x) / view.w) * 100;
|
||||
const top = ((c[1] - view.y) / view.h) * 100;
|
||||
return html`<button class="rlgearbtn" style="left:${left}%;top:${top}%"
|
||||
title=${this._t('room.settings_title')}
|
||||
@pointerdown=${(e: Event) => e.stopPropagation()}
|
||||
@click=${(e: Event) => { e.stopPropagation(); this._openRoomEdit(r); }}>
|
||||
<ha-icon icon="mdi:cog-outline"></ha-icon>
|
||||
<span class="rlgeartext">${this._t('room.settings_short')}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
private _renderRoomLabel(
|
||||
r: RoomCfg, space: SpaceModel, view: { x: number; y: number; w: number; h: number }, disp: SpaceDisplay,
|
||||
): TemplateResult | typeof nothing {
|
||||
@@ -4438,7 +4565,7 @@ class HouseplanCard extends LitElement {
|
||||
const k = this._labelScale(r);
|
||||
// optional metrics row (needs an HA area; sub-area rooms show the name only)
|
||||
const rows: TemplateResult[] = [];
|
||||
if ((r.area || r.settings?.temp_source || r.settings?.hum_source) && !this._markup) {
|
||||
if (r.area || r.settings?.temp_source || r.settings?.hum_source) {
|
||||
if (disp.labelTemp) {
|
||||
const t = this._roomTemp(r);
|
||||
if (t != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:thermometer"></ha-icon>${t}°</span>`);
|
||||
@@ -4469,14 +4596,7 @@ class HouseplanCard extends LitElement {
|
||||
@pointermove=${(e: PointerEvent) => this._labelMove(e, r, space.id)}
|
||||
@pointerup=${() => this._labelUp(r)}
|
||||
@pointercancel=${() => this._labelUp(r)}
|
||||
>${this._markup && r.id
|
||||
? html`<button class="rlgearbtn" title=${this._t('room.settings_title')}
|
||||
@pointerdown=${(e: Event) => e.stopPropagation()}
|
||||
@click=${(e: Event) => { e.stopPropagation(); this._openRoomEdit(r); }}>
|
||||
<ha-icon icon="mdi:cog-outline"></ha-icon>
|
||||
<span class="rlgeartext">${this._t('room.settings_short')}</span>
|
||||
</button>`
|
||||
: nothing}<span class="rlname">${r.name || (this._markup ? this._t('room.unnamed') : '')}${!this._markup && r.area
|
||||
><span class="rlname">${r.name || (this._markup ? this._t('room.unnamed') : '')}${!this._markup && r.area
|
||||
? html`<ha-icon class="rlgo" icon="mdi:open-in-new"
|
||||
title=${this._t('room.open_area')}
|
||||
@click=${(e: Event) => { e.stopPropagation(); this._clickRoom(r); }}
|
||||
@@ -5231,6 +5351,11 @@ class HouseplanCard extends LitElement {
|
||||
@change=${(e: Event) => (this._markerDialog = { ...d, isLight: (e.target as HTMLInputElement).checked })} />
|
||||
<span>${this._t('marker.is_light')}</span>
|
||||
</label>
|
||||
<label class="srcrow" title=${this._t('marker.hide_tip')}>
|
||||
<input type="checkbox" .checked=${d.hideFromPlan}
|
||||
@change=${(e: Event) => (this._markerDialog = { ...d, hideFromPlan: (e.target as HTMLInputElement).checked })} />
|
||||
<span>${this._t('marker.hide')}</span>
|
||||
</label>
|
||||
<label>${this._t('marker.glow_radius_label')}</label>
|
||||
<div class="colorrow">
|
||||
<input class="tempin" type="number" min="0.5" step="0.5"
|
||||
@@ -5315,7 +5440,7 @@ class HouseplanCard extends LitElement {
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
${d.devId
|
||||
${d.devId && d.binding === 'virtual'
|
||||
? html`<button class="btn danger" @click=${this._deleteMarker}>
|
||||
<ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('btn.remove')}
|
||||
</button>`
|
||||
|
||||
+6
-4
@@ -21,7 +21,7 @@
|
||||
"title.zoom_out": "Zoom out",
|
||||
"title.zoom_reset": "Reset zoom",
|
||||
"title.add_device": "Add a device to the plan",
|
||||
"title.show_all": "Show all area devices (no filtering)",
|
||||
"title.show_all": "Show hidden devices (ghosted, this tab only)",
|
||||
"title.markup": "Room markup: grid, lines, outlines",
|
||||
"title.configure_space": "Configure space",
|
||||
"title.add_space": "Add space",
|
||||
@@ -244,7 +244,7 @@
|
||||
"opening.lock_pending": "Working…",
|
||||
"title.close_editor": "Close editor (back to view)",
|
||||
"devbar.add": "Add",
|
||||
"devbar.show_all": "Show all",
|
||||
"devbar.show_all": "Show hidden",
|
||||
"devbar.rules": "Icon rules",
|
||||
"space.roomcard_section": "Room card shows:",
|
||||
"space.label_temp": "Temperature",
|
||||
@@ -338,5 +338,7 @@
|
||||
"btn.use": "Use",
|
||||
"confirm.delete_plan": "Delete the plan file \"{name}\" from the server? This cannot be undone.",
|
||||
"toast.plans_list_failed": "Could not list the stored plans: {err}",
|
||||
"toast.plan_delete_failed": "Could not delete the plan: {err}"
|
||||
}
|
||||
"toast.plan_delete_failed": "Could not delete the plan: {err}",
|
||||
"marker.hide": "Hide device from plan",
|
||||
"marker.hide_tip": "The device is not drawn on the plan but still counts toward the room signal. To see it: the \"Show hidden\" button in the device editor."
|
||||
}
|
||||
+6
-4
@@ -21,7 +21,7 @@
|
||||
"title.zoom_out": "Отдалить",
|
||||
"title.zoom_reset": "Сбросить масштаб",
|
||||
"title.add_device": "Добавить устройство на план",
|
||||
"title.show_all": "Показывать все устройства зоны (без фильтрации)",
|
||||
"title.show_all": "Показать скрытые устройства (полупрозрачными, только в этой вкладке)",
|
||||
"title.markup": "Разметка комнат: сетка, линии, контуры",
|
||||
"title.configure_space": "Настроить пространство",
|
||||
"title.add_space": "Добавить пространство",
|
||||
@@ -244,7 +244,7 @@
|
||||
"opening.lock_pending": "Выполняется…",
|
||||
"title.close_editor": "Закрыть редактор (вернуться к просмотру)",
|
||||
"devbar.add": "Добавить",
|
||||
"devbar.show_all": "Показать все",
|
||||
"devbar.show_all": "Показать скрытые",
|
||||
"devbar.rules": "Правила иконок",
|
||||
"space.roomcard_section": "В карточке комнаты:",
|
||||
"space.label_temp": "Температура",
|
||||
@@ -338,5 +338,7 @@
|
||||
"btn.use": "Выбрать",
|
||||
"confirm.delete_plan": "Удалить файл плана «{name}» с сервера? Действие необратимо.",
|
||||
"toast.plans_list_failed": "Не удалось получить список планов: {err}",
|
||||
"toast.plan_delete_failed": "Не удалось удалить план: {err}"
|
||||
}
|
||||
"toast.plan_delete_failed": "Не удалось удалить план: {err}",
|
||||
"marker.hide": "Скрыть устройство с плана",
|
||||
"marker.hide_tip": "Устройство не отображается на плане, но участвует в расчёте сигнала комнаты. Показать: кнопка «Показать скрытые» в редакторе устройств."
|
||||
}
|
||||
@@ -238,6 +238,74 @@ export function segmentsProperlyCross(
|
||||
* triangle centroid of consecutive vertices — the first point that passes
|
||||
* pointStrictlyInside wins.
|
||||
*/
|
||||
/**
|
||||
* The visual centre of a polygon: the centre of the largest inscribed circle
|
||||
* (pole of inaccessibility). `interiorPoint` only promises "inside", and for
|
||||
* an L-shaped room it lands near the seam — the owner's kitchen-living room
|
||||
* got its settings button visibly off-centre (2026-07-29). Grid search with
|
||||
* one refinement pass; exact enough for a button, cheap enough to cache.
|
||||
*/
|
||||
export function poleOfInaccessibility(poly: number[][], steps = 24): number[] {
|
||||
const xs = poly.map((p) => p[0]);
|
||||
const ys = poly.map((p) => p[1]);
|
||||
const minX = Math.min(...xs), maxX = Math.max(...xs);
|
||||
const minY = Math.min(...ys), maxY = Math.max(...ys);
|
||||
const span = Math.max(maxX - minX, maxY - minY) || 1;
|
||||
// Area centroid (shoelace-weighted). Clearance alone has a PLATEAU on any
|
||||
// elongated room — every point of the long midline fits the same circle —
|
||||
// and a plain argmax took the first plateau point: left of centre on the
|
||||
// owner's kitchen, above centre in the sauna. A soft pull toward the
|
||||
// centroid breaks the tie along the plateau without ever dragging the
|
||||
// point into a thinner limb (clearance differences dominate the score).
|
||||
let a2 = 0, cx = 0, cy = 0;
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const p = poly[i], q = poly[(i + 1) % poly.length];
|
||||
const cross = p[0] * q[1] - q[0] * p[1];
|
||||
a2 += cross;
|
||||
cx += (p[0] + q[0]) * cross;
|
||||
cy += (p[1] + q[1]) * cross;
|
||||
}
|
||||
const centroid = Math.abs(a2) > 1e-9
|
||||
? [cx / (3 * a2), cy / (3 * a2)]
|
||||
: [(minX + maxX) / 2, (minY + maxY) / 2];
|
||||
const clearance = (x: number, y: number): number => {
|
||||
if (!pointInPolygon([x, y], poly)) return -Infinity;
|
||||
let d = Infinity;
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const a = poly[i], b = poly[(i + 1) % poly.length];
|
||||
d = Math.min(d, distToSegment([x, y], [a[0], a[1], b[0], b[1]]));
|
||||
}
|
||||
return d;
|
||||
};
|
||||
const score = (x: number, y: number): number => {
|
||||
const d = clearance(x, y);
|
||||
if (d === -Infinity) return d;
|
||||
return d - 0.08 * Math.hypot(x - centroid[0], y - centroid[1]) - 0.0001 * span;
|
||||
};
|
||||
let best: number[] | null = null;
|
||||
let bestS = -Infinity;
|
||||
for (let i = 1; i < steps; i++) {
|
||||
for (let j = 1; j < steps; j++) {
|
||||
const x = minX + ((maxX - minX) * i) / steps;
|
||||
const y = minY + ((maxY - minY) * j) / steps;
|
||||
const sc = score(x, y);
|
||||
if (sc > bestS) { bestS = sc; best = [x, y]; }
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
const [bx, by] = best;
|
||||
const cw = (maxX - minX) / steps, ch = (maxY - minY) / steps;
|
||||
for (let i = -4; i <= 4; i++) {
|
||||
for (let j = -4; j <= 4; j++) {
|
||||
const x = bx + (cw * i) / 4, y = by + (ch * j) / 4;
|
||||
const sc = score(x, y);
|
||||
if (sc > bestS) { bestS = sc; best = [x, y]; }
|
||||
}
|
||||
}
|
||||
}
|
||||
return best || interiorPoint(poly) || poly[0];
|
||||
}
|
||||
|
||||
export function interiorPoint(poly: number[][], eps = 1e-6): number[] | null {
|
||||
if (!poly || poly.length < 3) return null;
|
||||
const n = poly.length;
|
||||
|
||||
+30
-3
@@ -28,11 +28,37 @@ export type Pt = { x: number; y: number };
|
||||
export type Layout = Record<string, { s?: string; x: number; y: number } | undefined>;
|
||||
|
||||
/** Build render-space models (NORM_W × NORM_W) from a server config. */
|
||||
/** A stored view_box the render can trust: 4 finite numbers, positive sizes.
|
||||
* The server refuses anything else NOW (HP-1502-01), but a store may already
|
||||
* hold [0,0,0,0] or negative sizes from before — and a zero axis serialises
|
||||
* into viewBox="0 0 0 0", which draws nothing on every client. Bad input
|
||||
* falls back to the whole canvas rather than to a blank screen. */
|
||||
function safeViewBox(vb: any): [number, number, number, number] {
|
||||
if (
|
||||
Array.isArray(vb) && vb.length === 4 && vb.every((n: any) => Number.isFinite(n))
|
||||
&& vb[2] > 1e-6 && vb[3] > 1e-6
|
||||
) return vb as [number, number, number, number];
|
||||
return [0, 0, 1, 1];
|
||||
}
|
||||
|
||||
/** Legacy rectangle rooms, normalised: a negative size is the same rectangle
|
||||
* drawn from the other corner; the maths downstream assumes w/h >= 0. */
|
||||
function normRect(r: any): { x?: number; y?: number; w?: number; h?: number } {
|
||||
if (r.x == null || r.y == null) return { x: r.x, y: r.y, w: r.w, h: r.h };
|
||||
const w = Number(r.w) || 0, h = Number(r.h) || 0;
|
||||
return {
|
||||
x: w < 0 ? r.x + w : r.x,
|
||||
y: h < 0 ? r.y + h : r.y,
|
||||
w: Math.abs(w),
|
||||
h: Math.abs(h),
|
||||
};
|
||||
}
|
||||
|
||||
export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
|
||||
if (!cfg || !Array.isArray(cfg.spaces)) return [];
|
||||
return cfg.spaces.map((s: any) => {
|
||||
const H = NORM_W; // square canvas
|
||||
const scale = (r: any): RoomCfg => ({
|
||||
const scale = (raw: any): RoomCfg => { const r = { ...raw, ...normRect(raw) }; return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
area: r.area ?? null,
|
||||
@@ -46,11 +72,12 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
|
||||
w: r.w != null ? r.w * NORM_W : undefined,
|
||||
h: r.h != null ? r.h * H : undefined,
|
||||
poly: r.poly ? r.poly.map((p: number[]) => [p[0] * NORM_W, p[1] * H]) : undefined,
|
||||
});
|
||||
}; };
|
||||
const vb = safeViewBox(s.view_box);
|
||||
return {
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
|
||||
vb: [vb[0] * NORM_W, vb[1] * H, vb[2] * NORM_W, vb[3] * H],
|
||||
bg: s.plan_url ? { href: contentUrl(s.plan_url), ...fitInSquare(s.plan_aspect, NORM_W) } : null,
|
||||
rooms: (s.rooms || []).map(scale),
|
||||
} as SpaceModel;
|
||||
|
||||
+25
-7
@@ -65,8 +65,19 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
loc,
|
||||
iconRules,
|
||||
});
|
||||
const devs = all.filter((d) => d.space === o.spaceId);
|
||||
const defPos = defaultPositions(devs, space, iconPct);
|
||||
// Two lists, two jobs (HP-1510-01): AGGREGATION sees every device of the
|
||||
// space — hidden ones still count toward room LQI, same as the full card —
|
||||
// while RENDERING sees only the visible ones (there is no editor here, so
|
||||
// hidden devices are never drawn). Filtering one list for both jobs made
|
||||
// the same room show different Zigbee health on the two cards.
|
||||
const spaceDevs = all.filter((d) => d.space === o.spaceId);
|
||||
const devs = spaceDevs.filter((d) => !d.hidden);
|
||||
// The auto grid is computed over the FULL roster, hidden included — the
|
||||
// full card reserves grid cells for hidden devices (their ghosts keep a
|
||||
// place in the device editor), so the static card must too, or the same
|
||||
// visible marker with no saved position lands in different spots on the
|
||||
// two cards (HP-1511-01). Rendering still draws `devs` only.
|
||||
const defPos = defaultPositions(spaceDevs, space, iconPct);
|
||||
|
||||
const roomShapes = space.rooms
|
||||
.filter((r) => r.area || disp.showBorders)
|
||||
@@ -82,9 +93,9 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
const fillC = r.area
|
||||
? roomFillStyle(
|
||||
fill,
|
||||
fill === 'lqi' ? areaLqi(o.hass, devs, r.area) : null,
|
||||
fill === 'light' ? areaLights(o.hass, devs, r.area) : 'none',
|
||||
fill === 'temp' ? areaTemp(o.hass, devs, r.area) : null,
|
||||
fill === 'lqi' ? areaLqi(o.hass, spaceDevs, r.area) : null,
|
||||
fill === 'light' ? areaLights(o.hass, spaceDevs, r.area) : 'none',
|
||||
fill === 'temp' ? areaTemp(o.hass, spaceDevs, r.area) : null,
|
||||
disp.tempMin,
|
||||
disp.tempMax,
|
||||
fillColorsOf(o.cfg?.settings),
|
||||
@@ -110,8 +121,15 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
const p = markerPos(d, o.layout, o.cfg, defPos, space);
|
||||
const left = ((p.x - vb[0]) / vb[2]) * 100;
|
||||
const top = ((p.y - vb[1]) / vb[3]) * 100;
|
||||
return html`<div class="dev ${d.virtual ? 'virtual' : ''}" style="left:${left}%;top:${top}%">
|
||||
<ha-icon icon="${d.icon}"></ha-icon>
|
||||
// per-marker size and rotation, exactly as on the full card (HP-1513-01):
|
||||
// the same stored marker must look the same on both cards. Geometry only —
|
||||
// no live-state dressing here, the static card stays a schematic.
|
||||
const scale = Number(d.marker?.size) > 0 ? Number(d.marker!.size) : 1;
|
||||
const angle = Number(d.marker?.angle) || 0;
|
||||
const st = [`left:${left}%`, `top:${top}%`];
|
||||
if (scale !== 1) st.push(`--dev-scale:${scale}`);
|
||||
return html`<div class="dev ${d.virtual ? 'virtual' : ''}" style="${st.join(';')}">
|
||||
<ha-icon icon="${d.icon}" style=${angle ? `transform:rotate(${angle}deg)` : nothing}></ha-icon>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
|
||||
+46
-13
@@ -398,28 +398,42 @@ export const cardStyles = css`
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.zoomwrap.slide-left, .zoomwrap.slide-right { animation: none; }
|
||||
}
|
||||
/* The name is the anchor: the label box is centred on the room point, so
|
||||
anything that takes part in its layout SHIFTS THE NAME. The gear button
|
||||
and the metrics hang below as absolutes — the name renders in exactly
|
||||
the same place in view mode and in the plan editor (owner's request),
|
||||
and the button sits at the very bottom of the card. */
|
||||
/* Standalone, centred on the room, sized from the device icon: icon-size
|
||||
already rescales with the view, so the button zooms WITH the plan
|
||||
instead of keeping a constant screen size (owner's spec). */
|
||||
.rlgearbtn {
|
||||
--gear-h: calc(var(--icon-size, 2.5cqw) * 0.77); /* owner: half the previous size */
|
||||
position: absolute;
|
||||
/* dead-centred on the room, both axes (owner's spec) */
|
||||
transform: translate(-50%, -50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 3px;
|
||||
padding: 3px 8px;
|
||||
gap: 0.35em;
|
||||
height: var(--gear-h);
|
||||
padding: 0 calc(var(--gear-h) * 0.38);
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--hp-accent);
|
||||
color: var(--text-primary-color, #fff);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-size: calc(var(--gear-h) * 0.42);
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
opacity: 0.92;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||||
z-index: 2;
|
||||
}
|
||||
.rlgearbtn { transition: opacity 0.15s, filter 0.15s; }
|
||||
.rlgearbtn:hover { opacity: 1; filter: brightness(1.18); }
|
||||
.rlgearbtn ha-icon { --mdc-icon-size: 14px; display: inline-flex; }
|
||||
.rlgearbtn ha-icon { --mdc-icon-size: calc(var(--gear-h) * 0.55); display: inline-flex; }
|
||||
.rlgear {
|
||||
--mdc-icon-size: 0.9em;
|
||||
display: inline-flex;
|
||||
@@ -440,6 +454,11 @@ export const cardStyles = css`
|
||||
}
|
||||
.stage.mode-view .rlgo:hover { opacity: 1; }
|
||||
.roomlabel .rlmetrics {
|
||||
position: absolute; /* below the name, outside the centring math */
|
||||
top: calc(100% + 0.15em);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55em;
|
||||
@@ -857,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: '';
|
||||
@@ -920,7 +935,11 @@ export const cardStyles = css`
|
||||
z-index: 2;
|
||||
}
|
||||
.dev ha-icon {
|
||||
--mdc-icon-size: calc(var(--icon-size, 2.5cqw) * 0.62);
|
||||
/* from --dev-size, NOT --icon-size: the per-device size multiplier must
|
||||
scale the GLYPH with its badge. Pinned to the base size, "make this
|
||||
icon bigger" grew an empty box around a default-size glyph (user
|
||||
report via the owner, 2026-07-29). */
|
||||
--mdc-icon-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.62);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -950,6 +969,20 @@ export const cardStyles = css`
|
||||
.dev.virtual {
|
||||
border-style: dashed;
|
||||
}
|
||||
/* "hide from plan" flag, shown only in the device editor with the
|
||||
"show hidden devices" toggle on (docs/FILTERING.md). BLUE, so a hidden
|
||||
device cannot be mistaken for an unavailable one (translucent dark) —
|
||||
and no live-state paint at all: a ghost is configuration, not status
|
||||
(owner's request). */
|
||||
.dev.ghost {
|
||||
opacity: 0.6;
|
||||
border-style: dashed;
|
||||
border-color: var(--hp-accent);
|
||||
background: rgba(62, 166, 255, 0.22); /* fallback for old WebViews */
|
||||
background: color-mix(in srgb, var(--hp-accent) 30%, var(--card-background-color, #1c2530));
|
||||
color: var(--hp-accent);
|
||||
box-shadow: none;
|
||||
}
|
||||
.dev.sel {
|
||||
border-color: #ffc14d;
|
||||
box-shadow: 0 0 0 3px rgba(255, 193, 77, 0.35);
|
||||
|
||||
+6
-1
@@ -90,7 +90,9 @@ export interface ServerConfig {
|
||||
settings: {
|
||||
exclude_integrations?: string[];
|
||||
group_lights?: boolean;
|
||||
show_all?: boolean;
|
||||
show_all?: boolean; // legacy: removed when the config is materialised
|
||||
/** The filter flags are materialised into markers (docs/FILTERING.md). */
|
||||
filter_seeded?: boolean;
|
||||
icon_rules?: { pattern: string; icon: string }[];
|
||||
};
|
||||
}
|
||||
@@ -101,6 +103,9 @@ export interface DevItem {
|
||||
model: string;
|
||||
area: string;
|
||||
space: string;
|
||||
/** "Hide from plan": built (so room LQI still counts it) but not rendered,
|
||||
* except ghosted in the device editor with "show hidden" on. */
|
||||
hidden?: boolean;
|
||||
icon: string;
|
||||
entities: string[];
|
||||
primary?: string;
|
||||
|
||||
+103
-1
@@ -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, seedHiddenBindings } from '../test-build/devices.js';
|
||||
import { compileIconRules, iconFor } from '../test-build/rules.js';
|
||||
|
||||
/** Minimal fake hass around the pieces buildDevices reads. */
|
||||
@@ -483,3 +483,105 @@ 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',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
// ---------- explicit hide-from-plan flags (docs/FILTERING.md) ----------
|
||||
|
||||
test('seedHiddenBindings: non-physical devices in bound areas, unmarked only', () => {
|
||||
const h = mkHass({ devices: {
|
||||
lamp: dev('lamp', 'Lamp', 'Bulb', 'living'),
|
||||
bridge: dev('bridge', 'Z2M Bridge', 'Bridge', 'living'),
|
||||
grp: dev('grp', 'All lights', 'Group', 'living'),
|
||||
scn: dev('scn', 'Evening', 'Scene switch', 'living'),
|
||||
faraway: dev('faraway', 'Bridge 2', 'Bridge', 'garage'), // unbound area
|
||||
}});
|
||||
const ctx = { hass: h, areaToSpace: { living: 'f1' }, markers: [], settings: {},
|
||||
excluded: new Set(['hacs']), firstSpaceId: 'f1' };
|
||||
assert.deepEqual(seedHiddenBindings(ctx).sort(), ['device:bridge', 'device:grp', 'device:scn']);
|
||||
// a marker of ANY kind — even hidden: false — is the user's decision
|
||||
const marked = { ...ctx, markers: [
|
||||
{ id: 'x', binding: 'device:bridge', hidden: false },
|
||||
{ id: 'y', binding: 'device:grp', hidden: true },
|
||||
] };
|
||||
assert.deepEqual(seedHiddenBindings(marked), ['device:scn'], 'marked devices are never revisited');
|
||||
});
|
||||
|
||||
test('seeded config: hidden is a flag, not an absence', () => {
|
||||
const h = mkHass({ devices: {
|
||||
lamp: dev('lamp', 'Lamp', 'Bulb', 'living'),
|
||||
plug: dev('plug', 'Plug', 'Socket', 'living'),
|
||||
}});
|
||||
const res = buildDevices(baseCtx(h, {
|
||||
settings: { filter_seeded: true },
|
||||
markers: [{ id: 'hplug', binding: 'device:plug', hidden: true }],
|
||||
}));
|
||||
const plug = res.find((d) => d.bindingRef === 'plug');
|
||||
assert.ok(plug, 'the hidden device is BUILT — room LQI still counts it');
|
||||
assert.equal(plug.hidden, true, 'and flagged for the renderer');
|
||||
assert.ok(!res.find((d) => d.id === 'lamp').hidden);
|
||||
});
|
||||
|
||||
test('legacy config (no filter_seeded): the runtime filter still applies', () => {
|
||||
const h = mkHass({ devices: {
|
||||
lamp: dev('lamp', 'Lamp', 'Bulb', 'living'),
|
||||
bridge: dev('bridge', 'Hub', 'Bridge', 'living'),
|
||||
}});
|
||||
const res = buildDevices(baseCtx(h));
|
||||
assert.deepEqual(res.map((d) => d.id), ['lamp'], 'a read-only client on an old config sees the old behaviour');
|
||||
// and a hidden marker still removes the device entirely, as before
|
||||
const res2 = buildDevices(baseCtx(h, { markers: [{ id: 'x', binding: 'device:lamp', hidden: true }] }));
|
||||
assert.deepEqual(res2.map((d) => d.id), []);
|
||||
});
|
||||
|
||||
test('hidden lights cast no visible light, but count toward LQI', () => {
|
||||
const h = mkHass({
|
||||
devices: {},
|
||||
states: { 'light.a': { state: 'on', attributes: {} } },
|
||||
});
|
||||
const devices = [
|
||||
{ area: 'living', entities: ['light.a'], hidden: true },
|
||||
];
|
||||
assert.equal(areaLights(h, devices, 'living'), 'none', 'an invisible device casts no visible light');
|
||||
assert.equal(areaLightStats(h, devices, 'living'), null);
|
||||
});
|
||||
|
||||
+28
-2
@@ -15,8 +15,7 @@ import {
|
||||
contentUrl, chunk, referencedContentUrls, MAX_SIGN_PATHS,
|
||||
interiorPoint,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
|
||||
} from '../test-build/logic.js';
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility } from '../test-build/logic.js';
|
||||
import {
|
||||
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
||||
} from '../test-build/rules.js';
|
||||
@@ -910,3 +909,30 @@ test('chunk / referencedContentUrls: signing batches and cache pruning (review R
|
||||
assert.equal(referencedContentUrls(null).size, 0);
|
||||
assert.equal(referencedContentUrls({}).size, 0);
|
||||
});
|
||||
|
||||
|
||||
test('poleOfInaccessibility: the VISUAL centre, not just any interior point', () => {
|
||||
// a square: the pole is the centre
|
||||
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
const p = poleOfInaccessibility(sq);
|
||||
assert.ok(Math.abs(p[0] - 5) < 0.6 && Math.abs(p[1] - 5) < 0.6);
|
||||
|
||||
// an ELONGATED rectangle: clearance is a plateau along the midline, and a
|
||||
// plain argmax used to take its first point — left of centre on the
|
||||
// owner's kitchen, above centre in the sauna. The centroid pull centres it.
|
||||
const wide = [[0, 0], [30, 0], [30, 10], [0, 10]];
|
||||
const w = poleOfInaccessibility(wide);
|
||||
assert.ok(Math.abs(w[0] - 15) < 1.2 && Math.abs(w[1] - 5) < 1.2, 'centred both axes: ' + w);
|
||||
const tall = [[0, 0], [8, 0], [8, 28], [0, 28]];
|
||||
const tl = poleOfInaccessibility(tall);
|
||||
assert.ok(Math.abs(tl[0] - 4) < 1 && Math.abs(tl[1] - 14) < 1.2, 'sauna case, centred vertically: ' + tl);
|
||||
|
||||
// the owner's kitchen-living room shape: a THICK top slab with a narrower
|
||||
// column hanging off the right side. The button belongs near the middle of
|
||||
// the slab — pulled toward the area centroid, never down the thin column.
|
||||
const L = [[0, 0], [20, 0], [20, 16], [16, 16], [16, 8], [0, 8]];
|
||||
const q = poleOfInaccessibility(L);
|
||||
assert.ok(pointInPolygon(q, L), 'inside, always');
|
||||
assert.ok(q[1] < 8, 'in the thick slab (y < 8), not down the column: ' + q);
|
||||
assert.ok(Math.abs(q[0] - 11.3) < 2.5, 'near the centroid x within the slab: ' + q);
|
||||
});
|
||||
|
||||
@@ -165,3 +165,21 @@ test('contentBounds: never degenerate, never unbounded (HP-1500-03)', () => {
|
||||
}], markers: [] })[0];
|
||||
assert.equal(contentBounds(allBad), null, 'the caller keeps the full canvas');
|
||||
});
|
||||
|
||||
test('spaceModels: a stored broken view_box falls back to the canvas (HP-1502-01)', () => {
|
||||
// the server refuses these now, but a store may already hold one
|
||||
for (const vb of [[0, 0, 0, 0], [0, 0, -1, -2], [0, 0, 1], null, [0, 0, NaN, 1]]) {
|
||||
const m = spaceModels({ spaces: [{ id: 's', view_box: vb, rooms: [] }], markers: [] })[0];
|
||||
assert.deepEqual(m.vb, [0, 0, 1000, 1000], JSON.stringify(vb) + ' falls back');
|
||||
}
|
||||
// a legitimate crop viewport is preserved
|
||||
const crop = spaceModels({ spaces: [{ id: 's', view_box: [0.1, 0.2, 0.5, 0.4], rooms: [] }], markers: [] })[0];
|
||||
assert.deepEqual(crop.vb, [100, 200, 500, 400]);
|
||||
// a legacy rect with a negative size is the same rectangle from the other corner
|
||||
const m = spaceModels({ spaces: [{
|
||||
id: 's', view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'r', x: 0.6, y: 0.7, w: -0.2, h: -0.3 }],
|
||||
}], markers: [] })[0];
|
||||
const r = m.rooms[0];
|
||||
assert.deepEqual([r.x, r.y, r.w, r.h].map(Math.round), [400, 400, 200, 300]);
|
||||
});
|
||||
|
||||
@@ -236,6 +236,27 @@ def test_geometry_magnitudes_are_bounded():
|
||||
{"id": "r", "name": "R", "poly": [[-0.2, 0], [1.3, 0], [1, 1]]}]}]})
|
||||
|
||||
|
||||
def test_sizes_are_not_coordinates():
|
||||
"""HP-1502-01: a size must be strictly positive — SVG refuses zero and
|
||||
negative width/height, and the clients divide by these. view_box [0,0,0,0]
|
||||
used to pass the shared validator and blank the plan on every client.
|
||||
Coordinates may still be negative: a crop origin can sit past the edge."""
|
||||
base = {"id": "s1", "title": "S", "view_box": [0, 0, 1, 1], "rooms": []}
|
||||
for cfg in (
|
||||
{**base, "view_box": [0, 0, 0, 0]},
|
||||
{**base, "view_box": [0, 0, -1, -2]},
|
||||
{**base, "view_box": [0, 0, 1, 0.0001]}, # below the 0.001 floor
|
||||
{**base, "rooms": [{"id": "r", "name": "R", "x": 0.1, "y": 0.1, "w": 0, "h": 0.5}]},
|
||||
{**base, "rooms": [{"id": "r", "name": "R", "x": 0.1, "y": 0.1, "w": 0.5, "h": -1}]},
|
||||
):
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [cfg]})
|
||||
# negative COORDINATES stay legal, and a normal crop viewport passes
|
||||
assert v.CONFIG_SCHEMA({"spaces": [{**base, "view_box": [-0.2, -0.1, 1.4, 1.2]}]})
|
||||
assert v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
|
||||
{"id": "r", "name": "R", "x": -0.1, "y": -0.1, "w": 0.4, "h": 0.3}]}]})
|
||||
|
||||
|
||||
def test_openings_cap_enforced():
|
||||
"""audit follow-up B5: MAX_OPENINGS was defined but never wired in."""
|
||||
many = [{"id": f"o{i}", "type": "door", "x": 0.1, "y": 0.1, "angle": 0, "length": 0.1}
|
||||
|
||||
Reference in New Issue
Block a user