v1.59.1: unify device and light state

This commit is contained in:
Matysh
2026-08-06 21:45:18 +03:00
parent e7aca9c678
commit e7dca563d1
26 changed files with 760 additions and 316 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.59.0"
VERSION = "1.59.1"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.59.0"
"version": "1.59.1"
}
+15 -19
View File
@@ -1,16 +1,14 @@
// «Открыть/закрыть» on a curtain whose cover entity is NOT the primary one
// «Открыть/закрыть» on a curtain whose cover entity used to lose to an option
// switch in registry/domain order.
// (owner's report 2026-08-04: «в настройках "открыть\закрыть", а по нажатию
// по-прежнему инфо-карточка»).
//
// The device below is his, entity for entity: an Aqara «Roller shade driver
// E1» ships the `cover.*` HIDDEN by the integration and a perfectly visible
// `switch.*_reverse_direction` beside it. `primaryEntity` ranks visible above
// hidden and `switch` above `cover`, so the marker's primary was the service
// switch — the tap then resolved on the domain `switch` and the action the
// user had explicitly chosen degraded to the info card, while the dialog kept
// OFFERING the action, because that check already looked at every entity of
// the device. The tap now finds the cover the same way the climate
// temperature finds the thermostat: among all of them.
// `switch.*_reverse_direction` beside it. The shared role resolver must pick
// the real cover for status, while the explicit tap action decides whether a
// click controls it. A mixed lamp+cover still stays a lamp unless the owner
// explicitly selects the cover action.
//
// End to end: the action is chosen in the dialog and SAVED through the normal
// path, and the click is done on the marker the card rebuilt from that config.
@@ -77,8 +75,9 @@ const out = await page.evaluate(async () => {
const curtain = () => c._devices.find((x) => x.bindingRef === 'd_curtain');
const mixed = () => c._devices.find((x) => x.bindingRef === 'd_mixed');
o.deviceIsOnThePlan = !!curtain();
// the premise of the whole report: the cover is NOT the primary entity
o.primaryIsNotTheCover = curtain()?.primary === 'switch.office_curtain_reverse';
// Registry order and visibility no longer let an option switch outrank the
// actual functional entity.
o.primaryIsTheCover = curtain()?.primary === 'cover.office_curtain';
o.coverIsAmongTheEntities = (curtain()?.entities || []).includes('cover.office_curtain');
// ---- the dialog offers the action and SAVES it -------------------------
@@ -187,28 +186,25 @@ const out = await page.evaluate(async () => {
await setSwitch('on');
o.reverseSwitchNeverLightsTheMarker = !clsOf(curtain()).includes('on');
// THE BOUNDARY of the rule: take the explicit action away and the marker
// goes back to its primary entity — no cover indication is invented
// Status and action are independent: taking the explicit action away keeps
// the resolved cover status, but a click returns to the ordinary info path.
const savedMarker = (c._serverCfg.markers || []).find((m) => m.binding === 'device:d_curtain');
savedMarker.tap_action = 'info';
await push('opening', { device_class: 'curtain' });
await setSwitch('on');
o.withoutTheActionThePrimarySpeaks = clsOf(curtain()).includes('on')
&& !clsOf(curtain()).includes('activity-transition');
o.withoutTheActionResolvedCoverSpeaks = clsOf(curtain()).includes('activity-transition')
&& !clsOf(curtain()).includes('on');
savedMarker.tap_action = 'cover';
await push('closed', { device_class: 'curtain' });
await setSwitch('off');
o.actionBackMeansCoverBack = iconOf(curtain()) === 'mdi:curtains-closed';
// ---- the auditor's own probe: BOTH entities visible ---------------------
// DEV-2C947-04 was written up with a plain visible `cover.*` beside a
// visible `switch.*`: no hidden tier involved, `switch` simply outranks
// `cover` inside DOMAIN_PRIORITY. Same premise, same cure — pinned here so
// the finding cannot come back through the other door.
// The same rule must hold when BOTH entities are visible.
ENTS['cover.office_curtain'] = { ...ENTS['cover.office_curtain'], hidden: false };
await push('closed', { device_class: 'curtain' });
await setSwitch('off');
o.visibleCoverIsStillNotThePrimary = curtain()?.primary === 'switch.office_curtain_reverse';
o.visibleCoverIsThePrimary = curtain()?.primary === 'cover.office_curtain';
calls.length = 0;
await tap();
o.visibleCoverOpensOnTap = JSON.stringify(calls[calls.length - 1] || [])
File diff suppressed because one or more lines are too long
+58 -58
View File
File diff suppressed because one or more lines are too long
+21 -5
View File
@@ -10,7 +10,7 @@ houseplan-card/
├─ src/ # card sources (TypeScript + Lit 3)
│ ├─ houseplan-card.ts # the card: rendering, states, drag, tooltip, sticky header
│ ├─ editor.ts # GUI config editor (ha-form + selectors)
│ ├─ rules.ts # icon rules (iconFor), filtering, groups, domain priority
│ ├─ rules.ts # icon rules (iconFor), filtering, groups, fallback order
│ └─ data/
│ ├─ house.ts # geometry: ROOMS (rooms→area), FLOOR_VB (viewBox), names
│ └─ backgrounds.ts # VECTOR plans (SVG base64) + FLOOR_BG_RECT (positioning)
@@ -55,8 +55,10 @@ houseplan-card/
## Card data model (runtime)
`DevItem`: id (device_id), name, model, area, floor, icon, entities[], primary (the entity used for
more-info by domain priority), temp, members[] (light group), link/linkPrimary (Z2M group).
`DevItem`: id (device_id), name, model, area, floor, icon, entities[], primary
(the first resolved state entity for actions requiring one target), temp,
members[] (light group), link/linkPrimary (Z2M group). Marker state consumes
the complete resolved role, not this single compatibility field.
Built from the registries (`_buildDevices`), rules carried over 1-to-1 from the prototype:
- only devices with an area from the room list are shown;
@@ -422,8 +424,22 @@ hash falls back to the default.
`open_to` links after Split (`AUD-159B7-01`).
- **Marker controls** (v1.36): `marker.controls[]` (lights/switches only)
toggled as one HA-group-semantics service call on the marker's EXPLICIT
tap_action=toggle; icon state/tint mirrors the targets. `primaryEntity`
picks in tiers so domain priority beats registry `hidden` (grouped lamps).
tap_action=toggle; icon state/tint mirrors the targets.
- **Resolved device state** (2026-08-06): HA provides states per entity, not
one state per device. `resolvedDeviceStateEntities` therefore starts from
uncategorised registry entities, resolves one functional role (whole-device
domains, then semantic binary signals, then switches), and aggregates
passive readings as the final fallback. `_visualSamples` consumes the full
result; `primaryEntity` only selects its first member where a single action
target is required. Integration option switches can no longer make an
otherwise healthy device working or unavailable merely by list order.
- **Resolved light sources** (UX-12): `resolvedLightSources(hass, devices,
room?)` is the only light-membership resolver. Per marker the precedence is
`controls[]` -> the primary controllable entity when `is_light` is set ->
automatic `light.*`. It excludes hidden markers, de-duplicates entity ids,
and honours `marker.room_id` before an HA area. Glow, Light fill, room light
stats, marker indication/card ordering and group toggle all consume this
result instead of maintaining separate domain tests.
- **Island rooms** (v1.34): full nesting is legal (`polyContainsPoly`);
parents render as evenodd paths with holes (`islandsOf`).
- **Kiosk mode** (v1.41): a card-config flag, not a mode — `_setMode` is
+30
View File
@@ -2,6 +2,36 @@
## Unreleased (dev)
## v1.59.1 — 2026-08-06
This maintenance release makes device status and every light-related view use
shared semantic resolvers, softens Glow, and fixes the last compacted-wall
T-junction artifact.
- **Systemic device-state resolver instead of a “first entity”.** HA devices
have no single state, so House Plan now excludes `config`/`diagnostic`
entities first, resolves the device's functional role, then semantic binary
signals, and uses generic switches only as a fallback. Passive readings are
aggregated, so one unavailable entity no longer makes the whole marker
unavailable. The same rule fixes X50/Customized Cleaning, presence/Anti
interference, TRV service switches and equivalent integration mistakes. A
real cover now also outranks its reverse-direction option, while a mixed
lamp+cover remains a lamp unless the cover action is explicitly selected.
- **UX-12: one resolved light-source set.** Glow, Light fill, room light stats,
marker indication and group controls now consume `resolvedLightSources(room)`.
Its precedence is `controls` -> an explicit `is_light` source -> automatic
`light.*`; hidden markers are excluded, entity ids are de-duplicated, and an
explicit `room_id` outranks an HA area. Relays and controlled groups therefore
mean the same thing in every light presentation.
- **Softer source glow.** The complete glow layer now renders at `0.7`
opacity; room darkness and the yellow working-state plate are unchanged.
- **Clean real/virtual T-junctions after wall compaction.** A maximal real wall
may cross several shorter collinear room sides. Exact endpoint containment
now restores its thickness on every covered side even when the compacted
wall's midpoint lies outside that room. Room hover therefore stays on the
inner face and the adjoining real wall no longer exposes a false end cap at
a virtual continuation.
## v1.59.0 — 2026-08-06
Stable 1.59 turns House Plan into a substantially more complete floor-plan
+32
View File
@@ -8,6 +8,38 @@
## Не выпущено (dev)
## v1.59.1 — 2026-08-06
Технический релиз сводит состояние устройств и все световые представления к
общим семантическим resolver-функциям, смягчает Glow и устраняет последний
артефакт Т-стыка после объединения стен.
- **Системный resolver состояния устройства вместо «первой сущности».** У HA
нет единого состояния Device: House Plan теперь сначала исключает
`config`/`diagnostic`, затем выбирает функциональную роль устройства,
семантические binary-сигналы и лишь при их отсутствии обычные switches.
Пассивные датчики агрегируются, поэтому одна недоступная сущность не делает
недоступным весь маркер. Это одним правилом исправляет X50 с недоступным
Customized Cleaning, presence с включённым Anti interference, TRV со
служебными switches и аналогичные интеграции. Настоящая сущность cover также
сильнее настройки reverse-direction, но смешанное устройство lamp+cover
остаётся лампой до явного выбора действия шторы.
- **UX-12: один набор источников света.** Glow, заливка «Свет», строка света
карточки комнаты, индикация маркера и групповое управление теперь используют
`resolvedLightSources(room)`. Приоритет един: `controls` → флаг «Это источник
света» → автоматические `light.*`; скрытые маркеры исключаются, сущности не
дублируются, а ручной `room_id` точнее HA-зоны. Поэтому отмеченное реле и
управляемая группа одинаково трактуются во всех световых режимах.
- **Более мягкий glow источников.** Весь слой свечения теперь отображается с
непрозрачностью `0.7`; затемнение комнат и жёлтая подложка работающего
устройства не изменились.
- **Чистые Т-стыки реальных и виртуальных стен после объединения отрезков.**
Один максимальный реальный отрезок может проходить через более короткие
коллинеарные стороны нескольких комнат. Теперь точные концы восстанавливают
толщину на каждой полностью покрытой стороне, даже если midpoint длинной
записи находится за пределами комнаты. Поэтому hover остаётся на внутренней
грани, а у виртуального продолжения не появляется ложный торец реальной стены.
## v1.59.0 — 2026-08-06
Стабильная 1.59 превращает House Plan в существенно более полный редактор
+11 -8
View File
@@ -80,12 +80,15 @@ source precedence is (`_visualSamples` / `_actEntity`):
1. the device's **cover**, when the marker's tap action is explicitly
«Открыть/закрыть» (`tap_action: 'cover'` — `coverEntityOf`, the same helper
and the same entity the tap drives). It wins over EVERYTHING below;
2. the marker's bound **controls**, if it has any (a stateless remote or a
virtual wall switch aggregates what it drives, not itself; any working
target drives both the yellow plate and running activity);
3. a **lit light** among its entities (owner's principle 2026-07-29: the glow
spot and the badge may never disagree);
4. otherwise the **primary** entity (`primaryEntity`).
2. the marker's **resolved light sources** (`resolvedLightSources`): bound
`controls` first, otherwise its primary controllable entity when
`is_light` is set, otherwise automatic `light.*`. This exact set also feeds
Glow, Light fill, room light stats and group toggle;
3. otherwise the device's **resolved state role**
(`resolvedDeviceStateEntities`): functional device domains first, then
semantic binary signals, then switches, then passive readings together.
`primaryEntity` is only the first entity of this same set for actions which
require one target; it no longer defines marker availability by itself.
Rule 1 was added 2026-08-04 on the owner's report: his Aqara «Roller shade
driver E1» curtains ship the `cover.*` hidden by the integration and a visible
@@ -116,10 +119,10 @@ one answer to «what is this marker»: the option offered, the entity tapped and
the state shown are the same entity, decided in one place. Nothing changes
behind the user's back for a mixed device (a lamp that also owns a blind, a TRV
with a service switch) that was NOT told it is a curtain: it keeps its primary
and the old 2–3–4 precedence, controls and lit light included, until its owner
and the same controls/light/primary precedence until its owner
says otherwise. Two edges follow from the wording: a marker set to
«Открыть/закрыть» whose device carries no `cover.*` at all falls through to
rules 2–4 (the statement is only as strong as the entity behind it), and a
rules 2–3 (the statement is only as strong as the entity behind it), and a
curtain left on «Инфо-карточка» still indicates its primary — one click in the
dialog away, and the honest reading of what the marker was told it is.
+1 -1
View File
@@ -62,7 +62,7 @@ House Plan уже вышел за рамки «карточки с картин
|---|---|---|---|---|
| UX-10 | P1 | Карточечная настройка `show_temperature` управляет и температурой, и влажностью | Название не соответствует результату | Переименовать в «Показывать значения датчиков (° / %)»; миграция ключа не обязательна, достаточно label |
| UX-11 | P1 | «Значение вместо иконки» показывает только число и молча возвращает иконку для текстовых состояний | Пользователь выбирает value, но не видит значение `open/playing/home` | В форме показывать live preview и ограничение; в будущем поддержать локализованный текст состояния с ограничением длины |
| UX-12 | P1 | Флаг «Это источник света» включает Glow для `switch.*`, но заливка комнаты «Свет» продолжает считать только `light.*` | Один и тот же объект считается светом в одном режиме и не считается в другом | Вынести единый `resolvedLightSources(room)` и использовать его для Glow, заливки «Свет», карточки комнаты и controls |
| UX-12 | P1 | **Выполнено:** `controls`, «Это источник света» и автоматические `light.*` сведены в `resolvedLightSources(room)` | Glow, заливка «Свет», карточка комнаты, маркер и групповой toggle используют один набор | Сохранить единый resolver при добавлении новых световых представлений |
| UX-13 | P1 | Вероятная несогласованность: Glow clip строится по всем комнатам открытой зоны, не фильтруя локальный `fill_mode: none` | Комната, явно исключённая из тёмной заливки, может всё равно получить световое пятно | Добавить сценарный тест и фильтровать Glow-зону по эффективному `roomFillModeOf`; отдельно решить, должен ли свет физически заходить в opt-out комнату |
| UX-14 | P2 | Жёлтая подложка и кольцо `running` одновременно сообщают работу | В «Иконка + активность» один смысл дублируется двумя эффектами | Сохранить подложку как статус, а кольцо работы сделать более спокойным или показывать только переходы/события; дать предпросмотр |
| UX-15 | P2 | Система красный/жёлтый/оранжевый/бледный/кольцо не имеет доступной легенды в самой карточке | Поведение стройное в коде, но невидимо для пользователя | Кнопка «Легенда состояний» в Device editor и компактная справка в диалоге display с примерами текущего устройства |
+1 -1
View File
@@ -1,6 +1,6 @@
# Документация House Plan
Этот каталог — новая точка входа в русскоязычную документацию House Plan. Материалы ниже сверены с исходным кодом и интерфейсом версии **v1.59.0-rc.1**.
Этот каталог — новая точка входа в русскоязычную документацию House Plan. Материалы ниже сверены с исходным кодом и интерфейсом версии **v1.59.1**.
| Документ | Для кого | Что внутри |
|---|---|---|
+7 -2
View File
@@ -15,11 +15,11 @@
| Item | State |
|---|---|
| Version | **v1.59.0** everywhere (manifest, const.py, package.json, CARD_VERSION) — stable release. Tag `v1.59.0`, `main` and `dev` point to the same tested release commit; GitHub Release uses `prerelease=false`. Previous pre-release: v1.59.0-rc.2; previous stable: v1.58.0 |
| Version | **v1.59.1** everywhere (manifest, const.py, package.json, CARD_VERSION) — stable maintenance release. Tag `v1.59.1`, `main` and `dev` point to the same tested release commit; GitHub Release uses `prerelease=false`. Previous pre-release: v1.59.0-rc.2; previous stable: v1.59.0 |
| Workflow | Owner's rule since 2026-08-05: ordinary fixes/features are made **locally, without tests and without commits**. A requested pre-release gets the full tests/build/smokes, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. A requested stable release gets the same full gate, then `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Nothing is copied to the home instance by hand |
| GitHub | https://github.com/Matysh/houseplan-card — `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, 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 | v1.59.0 passes the local Node 22 typecheck/build, all 390 frontend tests, all 93 pure-backend tests and all 113 browser smokes. Native Windows cannot start the current HA pytest plugin (`fcntl` is Unix-only), so the exact-SHA Ubuntu Validate remains mandatory: `release.yml` withholds the asset until every matching run finishes green; GitHub Validate and the release asset are verified after the tag is pushed |
| CI | v1.59.1 passes the full local release gate; exact counts are recorded by `npm run inventory` and the test logs rather than copied here. Native Windows cannot start the current HA pytest plugin (`fcntl` is Unix-only), so the exact-SHA Ubuntu Validate remains mandatory: `release.yml` withholds the asset until every matching run finishes green; GitHub Validate and the release asset are verified after the tag is pushed |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled, mergeable clean, never drafted. Queue: 1212 open, 835 older than ours. Merge rate COLLAPSED: 75 in July but almost all in the first decade, 0 in the last week (checked 2026-07-29) — maintainers process in rare bursts; ETA unknowable, months at best. Nothing actionable on our side |
| 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), last direct copy was **v1.57.0**; from v1.58.0 on it updates itself through HACS by tag (no scp) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
@@ -93,6 +93,11 @@
projected opening gaps. Thick-wall rendering unions each room's own wall
ring, so one room's floor cannot erase another room's wall or leave white
slivers at complex crossings.
- **v1.59.1** (2026-08-06): device markers resolve a semantic entity role
instead of trusting registry order; one light-source resolver now drives
Glow, Light fill, room statistics, marker feedback and controls. Glow uses
0.7 opacity, and compacted real walls retain their correct inner face and
body at real/virtual T-junctions.
## Recent milestones (details in CHANGELOG.md)
+16 -6
View File
@@ -1,6 +1,6 @@
# House Plan — полное руководство пользователя
Актуально для **v1.59.0-rc.1**. Руководство заново составлено по исходному коду и фактическому интерфейсу этой версии. Старые README и тематические спецификации использовались только для перепроверки сценариев.
Актуально для **v1.59.1**. Руководство заново составлено по исходному коду и фактическому интерфейсу этой версии. Старые README и тематические спецификации использовались только для перепроверки сценариев.
House Plan — локальная интеграция и две Lovelace-карточки для Home Assistant:
@@ -524,7 +524,7 @@ House Plan читает реестры устройств, сущностей и
| Световые источники | Включённые лампы и отмеченные реле | Дом затемняется, вокруг источников рисуются световые пятна | Остаётся базовая темнота |
| Нет | — | Прозрачная комната/только граница | — |
| Zigbee signal | Средний LQI устройств HA-зоны | Градиент от красного (≤40) до зелёного (≥180) | Без заливки |
| Свет | Состояния видимых `light.*` HA-зоны | Цвет «включено», «всё выключено» или «нет источников» | Цвет «нет источников», если его прозрачность >0 |
| Свет | Единый набор видимых источников комнаты: `controls` → «Это источник света» → `light.*` | Цвет «включено», «всё выключено» или «нет источников» | Цвет «нет источников», если его прозрачность >0 |
| Температура | Источник комнаты или среднее датчиков | Холодно ниже минимума, комфорт между границами, жарко выше максимума | Без заливки |
Новая вручную созданная область по умолчанию предлагает «Световые источники». Старые планы без сохранённого режима остаются без заливки.
@@ -544,10 +544,18 @@ House Plan читает реестры устройств, сущностей и
|---|---:|---:|---:|
| Видимое `light.* = on` | Да | Да | Да |
| `switch.* = on`, обычный | Нет | Нет | Да |
| `switch.* = on` + «Это источник света» | Нет | Да | Да |
| Управляемая группа `controls` | Учитываются сами `light.*` комнаты | Да, по группе | Да, если работает любой источник |
| `switch.* = on` + «Это источник света» | Да | Да | Да |
| Управляемая группа `controls` | Да, по состояниям группы | Да, по группе | Да, если работает любой источник |
| Скрытый маркер | Нет | Нет | Не рисуется |
Для всех четырёх потребителей — Glow, заливки «Свет», строки света в карточке
комнаты и группового toggle — набор вычисляется одинаково. Если заданы
`controls`, именно они описывают источники маркера. Иначе флаг «Это источник
света» превращает основной `light.*`/`switch.*` маркера в один источник; без
явных настроек автоматически учитываются его `light.*`. Одна сущность не
считается дважды. Ручная привязка маркера к комнате (`room_id`) точнее HA-зоны
и работает в том числе для комнаты без HA Area.
В режиме Glow свет ограничивается внутренним контуром комнаты, проходит в комнаты через двери и свободно объединяет комнаты через виртуальные границы. Наружная дверь не выпускает свет за пределы дома. У толстых стен сектор двери дополнительно обрезается внутренним тоннелем и откосами.
Цвет пятна выбирается по приоритету: RGB-состояние лампы → цветовая температура → общий цвет света. Яркость влияет на интенсивность, но имеет минимальный визуальный порог 15%.
@@ -734,7 +742,9 @@ icon_size: 2.5
| `button_target` | `/plan-doma` | Путь к полному плану; карточка добавляет `#space=<id>` |
| `icon_size` | базовый | Размер статических маркеров |
Поле `aspect_ratio`, которое пока видно в визуальном редакторе этой карточки, в v1.59.0-rc.1 фактически не используется. Холст квадратный, а размер карточки определяется содержимым.
Устаревшее YAML-поле `aspect_ratio` в v1.59.1 фактически не используется и
больше не предлагается визуальным редактором. Холст квадратный, а размер
карточки определяется содержимым; старое поле можно безопасно удалить.
## 19. Обслуживание планов
@@ -835,7 +845,7 @@ icon_size: 2.5
| Нет значения в value-режиме | Основное состояние нечисловое; используйте иконку или живой текст подложки |
| Нет LQI | Сущности устройства не отдают известный атрибут качества; проверьте локальную настройку пространства |
| Реле не светит в Glow | Включите «Это источник света» или свяжите реальные источники в `controls` |
| Реле не влияет на заливку «Свет» | Этот режим считает `light.*`; для реле используйте Glow и флаг источника света |
| Реле не влияет на заливку «Свет» | Включите «Это источник света», проверьте состояние основного `switch.*` и привязку маркера к нужной комнате |
### Геометрия выглядит неверно
+3
View File
@@ -27,6 +27,9 @@ merges consecutive solid pieces into each maximal run of equal thickness; a
different thickness or a virtual gap remains a real break. Likewise,
touching/overlapping `open_spans` of the same room pair are stored as one span;
pair ownership remains a hard boundary so Split can derive exact `open_to` links.
When a maximal wall run crosses a collinear vertex belonging to another room,
its exact endpoints cover that room's shorter child side too; lookup does not
depend on the compacted run's midpoint remaining inside every room.
Degrade unmatched keys silently on write. Resize / undo / scale transform exact
endpoints and re-key all touched spans in the same transaction; legacy entries
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.59.0",
"version": "1.59.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.59.0",
"version": "1.59.1",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.59.0",
"version": "1.59.1",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+23
View File
@@ -36,6 +36,29 @@ const EVENT_BINARY_CLASSES = new Set(['motion', 'vibration', 'sound']);
const PRESENCE_BINARY_CLASSES = new Set(['occupancy', 'presence']);
const CONTACT_BINARY_CLASSES = new Set(['door', 'window', 'garage_door', 'opening']);
const RUNNING_BINARY_CLASSES = new Set(['running', 'power']);
const ALARM_BINARY_CLASSES = new Set([
'smoke', 'gas', 'carbon_monoxide', 'moisture', 'safety', 'tamper', 'problem',
]);
/**
* A binary entity whose state is a real device signal, not an arbitrary
* vendor option. The device-level source resolver uses the same vocabulary as
* entityVisualSample, so selection and rendering cannot drift apart.
*/
export function isSemanticBinaryEntity(hass: any, eid: string): boolean {
if (!eid.startsWith('binary_sensor.')) return false;
const dc = lower(
hass?.states?.[eid]?.attributes?.device_class
|| hass?.entities?.[eid]?.device_class
|| hass?.entities?.[eid]?.original_device_class,
);
return EVENT_BINARY_CLASSES.has(dc)
|| PRESENCE_BINARY_CLASSES.has(dc)
|| CONTACT_BINARY_CLASSES.has(dc)
|| RUNNING_BINARY_CLASSES.has(dc)
|| dc === 'moving'
|| ALARM_BINARY_CLASSES.has(dc);
}
/** States that describe real work, not a selected mode or mere availability. */
const WORKING_STATES = new Set([
+166 -56
View File
@@ -3,7 +3,8 @@
* markers (overrides/virtual). No Lit/DOM — only the hass object.
*/
import { iconFor, iconFromDeviceClasses, DOMAIN_PRIORITY, FALLBACK_ICON, type CompiledIconRule, EXCLUDED_DOMAINS } from './rules';
import { averageLqi } from './logic';
import { averageLqi, isControllable } from './logic';
import { isSemanticBinaryEntity } from './device-visual';
import type { DevItem, Marker, ServerConfig } from './types';
/** Build context: a slice of hass + config resolution. */
@@ -54,22 +55,76 @@ export function isTempEntity(hass: any, eid: string): boolean {
);
}
/**
* Domains which expose the state machine of a whole functional device. This
* is a role order, not an entity-list order: an auxiliary switch may never
* outrank the vacuum/climate/cover/etc. entity it configures.
*/
const DEVICE_STATE_DOMAINS = [
'vacuum', 'lawn_mower', 'climate', 'light', 'cover', 'lock', 'valve',
'alarm_control_panel', 'water_heater', 'media_player', 'fan', 'humidifier',
'siren', 'camera', 'remote',
];
interface DeviceEntityCandidate {
eid: string;
reg: any;
}
const visibleFirst = (items: DeviceEntityCandidate[]): DeviceEntityCandidate[] => [
...items.filter((item) => !item.reg?.hidden),
...items.filter((item) => !!item.reg?.hidden),
];
/**
* Effective entities which jointly describe one device's status.
*
* Home Assistant has no device state: it has entity states, while
* entity_category marks config/diagnostic entities as non-primary. House Plan
* therefore resolves a ROLE, not a first list item:
* 1) uncategorised HA entities (all entities only as a fallback);
* 2) a whole-device state domain, if present;
* 3) semantic binary signals (presence/contact/motion/safety/running);
* 4) switches only when the device has no stronger state-bearing role;
* 5) passive entities together, so one unavailable sensor does not make the
* whole marker unavailable while another reading is alive.
*/
export function resolvedDeviceStateEntities(hass: any, entIds: readonly string[]): string[] {
const all: DeviceEntityCandidate[] = entIds
.map((eid) => ({ eid, reg: hass?.entities?.[eid] }))
.filter((item) => !!item.reg);
if (!all.length) return [];
const haPrimary = all.filter((item) => !item.reg.entity_category);
const pool = haPrimary.length ? haPrimary : all;
for (const domain of DEVICE_STATE_DOMAINS) {
const role = pool.filter((item) => item.eid.startsWith(domain + '.'));
if (role.length) return visibleFirst(role).map((item) => item.eid);
}
const semanticBinary = pool.filter((item) => isSemanticBinaryEntity(hass, item.eid));
if (semanticBinary.length) return visibleFirst(semanticBinary).map((item) => item.eid);
const switches = pool.filter((item) => item.eid.startsWith('switch.'));
if (switches.length) return visibleFirst(switches).map((item) => item.eid);
// Passive/fallback entities are all useful for aggregate availability. Keep
// the historical domain order only as a stable ordering for primaryEntity.
const ordered: DeviceEntityCandidate[] = [];
for (const domain of DOMAIN_PRIORITY)
ordered.push(...visibleFirst(pool.filter((item) => item.eid.startsWith(domain + '.'))));
ordered.push(...visibleFirst(pool.filter(
(item) => !DOMAIN_PRIORITY.includes(item.eid.split('.')[0]),
)));
return ordered.map((item) => item.eid);
}
export function primaryEntity(hass: any, entIds: string[], icon: string): string | undefined {
const all = entIds
.map((eid) => ({ eid, reg: hass.entities[eid], st: hass.states[eid] }))
.filter((e) => e.reg);
// Tiers, 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.
// Temperature/air-monitor labels deliberately choose their measurement;
// every other device delegates to the shared role resolver below.
const tiers = [
all.filter((e) => !e.reg.hidden && !e.reg.entity_category),
all.filter((e) => !e.reg.entity_category),
@@ -82,33 +137,107 @@ export function primaryEntity(hass: any, entIds: string[], icon: string): string
if (t) return t.eid;
}
}
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;
}
return resolvedDeviceStateEntities(hass, entIds)[0];
}
/** Minimal device shape accepted by the shared light-source resolver. */
export interface LightSourceDevice {
id?: string;
area: string;
hidden?: boolean;
entities: string[];
primary?: string;
marker?: {
room_id?: string | null;
is_light?: boolean | null;
controls?: string[] | null;
} | null;
}
export type LightSourceRoom = string | { id?: string | null; area?: string | null };
export interface ResolvedLightSource<D extends LightSourceDevice = LightSourceDevice> {
/** HA entity that carries the source's on/off state and accepts controls. */
eid: string;
/** Device/marker which places this source on the plan. */
device: D;
/** Why this entity belongs to the light-source set. */
via: 'controls' | 'forced' | 'light';
on: boolean;
}
function lightSourceBelongsToRoom(d: LightSourceDevice, room: LightSourceRoom): boolean {
if (typeof room === 'string') return d.area === room;
// An explicit marker-to-room binding is more precise than the HA area and
// therefore must not leak the source into every room sharing that area.
const roomId = d.marker?.room_id;
if (roomId) return !!room.id && roomId === room.id;
return !!room.area && d.area === room.area;
}
function lightEntitiesOf(d: LightSourceDevice): { eids: string[]; via: ResolvedLightSource['via'] } {
const controls = (d.marker?.controls || []).filter(isControllable);
if (controls.length) return { eids: controls, via: 'controls' };
if (d.marker?.is_light === true) {
// A forced source is a smart switch (or light) driving real fixtures.
// Prefer its primary entity, but never treat measurements or service
// entities as light switches merely because they belong to the device.
const controllable = d.entities.filter(isControllable);
const primary = d.primary && isControllable(d.primary) ? d.primary : null;
// The marker is one physical source, so do not count its auxiliary
// controllable entities as additional lamps in room statistics.
return { eids: primary ? [primary] : controllable.slice(0, 1), via: 'forced' };
}
// 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;
return { eids: d.entities.filter((eid) => eid.startsWith('light.')), via: 'light' };
}
/**
* 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.
* One source of truth for every light-related room feature. Explicit marker
* controls win, then `is_light`, then automatic `light.*` discovery. Passing
* a room scopes sources by room_id/area; omitting it resolves the supplied
* devices (used by Glow and per-marker controls).
*/
export function resolvedLightSources<D extends LightSourceDevice>(
hass: any,
devices: readonly D[],
room?: LightSourceRoom | null,
): ResolvedLightSource<D>[] {
const out: ResolvedLightSource<D>[] = [];
const seen = new Set<string>();
for (const d of devices) {
if (d.hidden || (room != null && !lightSourceBelongsToRoom(d, room))) continue;
const { eids, via } = lightEntitiesOf(d);
for (const eid of eids) {
if (!eid || seen.has(eid)) continue;
seen.add(eid);
out.push({ eid, device: d, via, on: hass.states[eid]?.state === 'on' });
}
}
return out;
}
/** Tri-state used by room fill. */
export function resolvedLightState(sources: readonly ResolvedLightSource[]): 'on' | 'off' | 'none' {
if (!sources.length) return 'none';
return sources.some((source) => source.on) ? 'on' : 'off';
}
/** Counts used by the room card/label. */
export function resolvedLightStats(
sources: readonly ResolvedLightSource[],
): { on: number; total: number } | null {
if (!sources.length) return null;
return { on: sources.filter((source) => source.on).length, total: sources.length };
}
/** @deprecated Compatibility helper for older tests/callers; use resolvedLightSources(). */
export function litLightEntity(
hass: any, d: { entities: string[]; marker?: { is_light?: boolean | null; controls?: string[] | null } | null },
hass: any,
d: Omit<LightSourceDevice, 'area'> & { area?: string },
): 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;
return resolvedLightSources(hass, [{ ...d, area: d.area || '' }]).find((source) => source.on)?.eid || null;
}
/** Average zigbee LQI across the device's entities (*_linkquality/*_lqi sensors or an attribute). */
@@ -432,17 +561,8 @@ 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[]; hidden?: boolean }[], area: string): 'on' | 'off' | 'none' {
let seen = false;
for (const d of devices) {
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;
if (hass.states[eid]?.state === 'on') return 'on';
}
}
return seen ? 'off' : 'none';
export function areaLights(hass: any, devices: readonly LightSourceDevice[], area: string): 'on' | 'off' | 'none' {
return resolvedLightState(resolvedLightSources(hass, devices, area));
}
/**
@@ -615,20 +735,10 @@ 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[]; hidden?: boolean }[],
devices: readonly LightSourceDevice[],
area: string,
): { on: number; total: number } | null {
const seen = new Set<string>();
let on = 0;
for (const dv of devices) {
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);
if (hass.states[eid]?.state === 'on') on++;
}
}
return seen.size ? { on, total: seen.size } : null;
return resolvedLightStats(resolvedLightSources(hass, devices, area));
}
/** Average temperature across the area's devices (null when nothing reports one). */
+46 -30
View File
@@ -68,7 +68,13 @@ import {
FitParams, fitMatrix, fitFromMatrix, initialFit, reanchorFit, VacRoom,
VAC_TRAIL_LINGER_MS, Pt as VacPt,
} from './vacuum';
import { buildDevices, seedHiddenBindings, lqiFor, tempFor, humFor, climateTempFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, litLightEntity, type AreaClimate } from './devices';
import {
buildDevices, seedHiddenBindings, lqiFor, tempFor, humFor, climateTempFor, isHumEntity,
areaTemp, areaHum, sourceValue, areaClimateMap,
resolvedLightSources, resolvedLightState, resolvedLightStats,
resolvedDeviceStateEntities,
type AreaClimate,
} from './devices';
import type {
OpeningCfg,
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
@@ -91,7 +97,7 @@ import {
type DeviceActivity, type DeviceVisualState, type EntityVisualSample,
} from './device-visual';
const CARD_VERSION = '1.59.0';
const CARD_VERSION = '1.59.1';
/** HP-1552 boot-veil timing (AUD-1552-02). The veil holds for at least
* BOOT_MIN_MS; every stage-height change restarts a BOOT_QUIET_MS
* trailing-quiescence requirement (chrome still settling near the cap
@@ -2329,21 +2335,21 @@ class HouseplanCard extends LitElement {
/**
* Entities that jointly describe the marker. Explicit cover intent wins;
* controls are an aggregate; otherwise a lit light and the primary entity
* keep the glow plate, icon and activity on the same story. Critical
* secondary entities are appended so an alarm cannot hide behind a less
* important primary sensor.
* otherwise the same resolved light set used by Glow/fill/controls wins;
* a non-light marker uses its resolved device-state role. Critical secondary
* entities are appended so an alarm cannot hide behind a less important
* functional source.
*/
private _visualSamples(d: DevItem): EntityVisualSample[] {
const ids: string[] = [];
const cover = this._coverIndicator(d);
const controls = (d.marker?.controls || []).filter(isControllable);
const lights = resolvedLightSources(this.hass, [d]);
if (cover) ids.push(cover);
else if (controls.length) ids.push(...controls);
else if (lights.length) ids.push(...lights.map((source) => source.eid));
else {
const lit = litLightEntity(this.hass, d);
if (lit) ids.push(lit);
if (d.primary && !ids.includes(d.primary)) ids.push(d.primary);
const stateEntities = resolvedDeviceStateEntities(this.hass, d.entities);
if (stateEntities.length) ids.push(...stateEntities);
else if (d.primary) ids.push(d.primary);
}
// Safety wins independently of the presentation source.
for (const eid of d.entities || []) {
@@ -2492,7 +2498,9 @@ class HouseplanCard extends LitElement {
// a switch with bound targets: the EXPLICIT per-marker toggle flips them
// all with HA-group semantics (any on -> all off). Owner's decision:
// controls never fire on the card-wide default action.
const controls = (d.marker?.controls || []).filter(isControllable);
const controls = resolvedLightSources(this.hass, [d])
.filter((source) => source.via === 'controls')
.map((source) => source.eid);
if (d.tapAction === 'toggle' && controls.length) {
const act = controlsAction(controls.map((e) => this.hass.states[e]?.state));
guarded(this._t('confirm.tap_toggle', { name: d.name }), () => {
@@ -7871,16 +7879,18 @@ class HouseplanCard extends LitElement {
if (cm > 0) doorTunnelDepth.set(o.id, wallCmToUnits(cm, this._cellCm, this._gridPitch));
}
}
const litByDevice = new Map<string, string>();
for (const source of resolvedLightSources(
this.hass,
this._devices.filter((d) => d.space === space.id),
)) {
if (source.on && source.device.id && !litByDevice.has(source.device.id))
litByDevice.set(source.device.id, source.eid);
}
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.
// the SAME condition that turns the icon yellow (devices.ts)
const lightEid = litLightEntity(this.hass, d);
const lightEid = litByDevice.get(d.id);
if (!lightEid) continue;
const glow = glowColorOf(this.hass.states[lightEid], colors.glow_light.c);
if (!glow) continue;
@@ -7945,7 +7955,7 @@ class HouseplanCard extends LitElement {
${''/* Glow is presentation only. It is painted above room fills, but must
not become the pointer target: room hover and its tooltip still
belong to the room underneath the light pool. */}
<g class="glowlayer" pointer-events="none">
<g class="glowlayer" pointer-events="none" opacity="0.7">
${spots.map((sp, i) => svg`<circle cx="${sp.pos.x}" cy="${sp.pos.y}" r="${sp.r}"
fill="url(#hp-glow-${i})" ${''}
clip-path=${sp.clip ? `url(#hp-glowclip-${i})` : nothing}></circle>`)}
@@ -8482,11 +8492,18 @@ class HouseplanCard extends LitElement {
// temp works without an HA area when a tier-3 source is set
? roomFillStyle('temp', null, 'none', this._roomTemp(r),
disp.tempMin, disp.tempMax, this._fillColors)
: effFill === 'light'
// marker.room_id also supports rooms without an HA area
? roomFillStyle(
'light', null,
resolvedLightState(resolvedLightSources(this.hass, this._devices, r)),
null, disp.tempMin, disp.tempMax, this._fillColors,
)
: r.area
? roomFillStyle(
effFill,
effFill === 'lqi' ? this._roomLqi(r.area) : null,
effFill === 'light' ? areaLights(this.hass, this._devices, r.area) : 'none',
'none',
null,
disp.tempMin,
disp.tempMax,
@@ -9359,11 +9376,9 @@ class HouseplanCard extends LitElement {
// 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 (legacy storage/CSS name) when no explicit effect colour is set.
const ctrl = (m?.controls || []).filter(isControllable);
const lightSources = resolvedLightSources(this.hass, [d]);
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
? lightSources.map((source) => lightColorOf(this.hass.states[source.eid])).find((v) => v) || null
: null;
const scale = Number(m?.size) > 0 ? Number(m!.size) : 1;
const angle = Number(m?.angle) || 0;
@@ -9699,9 +9714,10 @@ class HouseplanCard extends LitElement {
const top = ((p.y - view.y) / view.h) * 100;
const op = Math.min(1, disp.opacity + 0.25);
const k = this._labelScale(r);
// optional metrics row (needs an HA area; sub-area rooms show the name only)
// Optional metrics row. Light sources may use an explicit room_id even
// when this room has no HA area; the other aggregate metrics still need it.
const rows: TemplateResult[] = [];
if (r.area || r.settings?.temp_source || r.settings?.hum_source) {
if (r.area || r.settings?.temp_source || r.settings?.hum_source || disp.labelLight) {
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>`);
@@ -9714,8 +9730,8 @@ class HouseplanCard extends LitElement {
const l = this._roomLqi(r.area);
if (l != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:zigbee"></ha-icon>${l}</span>`);
}
if (disp.labelLight && r.area) {
const ls = areaLightStats(this.hass, this._devices, r.area);
if (disp.labelLight) {
const ls = resolvedLightStats(resolvedLightSources(this.hass, this._devices, r));
if (ls) {
const txt = ls.on === 0
? this._t('roomcard.light_off')
@@ -10423,7 +10439,7 @@ class HouseplanCard extends LitElement {
else if (['sensor', 'binary_sensor', 'number', 'select'].includes(dom))
out.push({ eid, kind: 'value' });
};
for (const e of d.marker?.controls || []) push(e);
for (const source of resolvedLightSources(h, [d])) push(source.eid);
if (d.primary) push(d.primary);
for (const e of d.entities) push(e);
return out.slice(0, 12);
+1
View File
@@ -95,6 +95,7 @@ const DEVICE_CLASS_ICONS: Record<string, string> = {
humidity: 'mdi:water-percent',
motion: 'mdi:motion-sensor',
occupancy: 'mdi:motion-sensor',
presence: 'mdi:motion-sensor',
door: 'mdi:door',
window: 'mdi:window-closed',
garage_door: 'mdi:garage-variant',
+10 -4
View File
@@ -7,7 +7,7 @@
* Geometry/model math lives in space-geometry.ts (pure, unit-tested).
*/
import { html, svg, nothing, type TemplateResult } from 'lit';
import { buildDevices, areaLqi, areaLights, areaTemp } from './devices';
import { buildDevices, areaLqi, areaTemp, resolvedLightSources, resolvedLightState } from './devices';
import { spaceDisplayOf, roomFillStyle, fillColorsOf, roomFillModeOf, stageBgOf, paperRoomShapes } from './logic';
import {
wallBodiesUnionPath, paperRoomShapesWithWalls, wallBodyNeedsSolid, type WallEntry,
@@ -105,7 +105,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const vb = [fr.x, fr.y, fr.w, fr.h];
const roomShapes = space.rooms
.filter((r) => r.area || disp.showBorders)
.filter((r) => r.area || disp.showBorders || roomFillModeOf(disp.fill, r) === 'light')
.map((r) => {
let cls = 'room ' + (space.bg ? 'overlay' : 'yard');
let style = '';
@@ -115,11 +115,17 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
cls += ' styled';
const parts = [`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`];
// fill rendered exactly as configured on the full card (snapshot of current states)
const fillC = r.area
const fillC = fill === 'light'
? roomFillStyle(
'light', null,
resolvedLightState(resolvedLightSources(o.hass, spaceDevs, r)),
null, disp.tempMin, disp.tempMax, fillColorsOf(o.cfg?.settings),
)
: r.area
? roomFillStyle(
fill,
fill === 'lqi' ? areaLqi(o.hass, spaceDevs, r.area) : null,
fill === 'light' ? areaLights(o.hass, spaceDevs, r.area) : 'none',
'none',
fill === 'temp' ? areaTemp(o.hass, spaceDevs, r.area) : null,
disp.tempMin,
disp.tempMax,
+23 -4
View File
@@ -874,13 +874,32 @@ function cmsForPoly(
const a = at.orig[pi], b = at.orig[(pi + 1) % at.orig.length];
const ang = segAngle(a, b);
const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2;
let best: { cm: number; d: number } | null = null;
let best: { cm: number; d: number; exact: boolean } | null = null;
const parentLen = Math.hypot(b[0] - a[0], b[1] - a[1]);
for (const e of parsed) {
if (claimed.has(e.w.key)) continue;
if (!angleClose(e.ang, ang)) continue;
if (distToSeg(e.x, e.y, a[0], a[1], b[0], b[1]) > tol) continue;
const d = Math.hypot(e.x - mx, e.y - my);
if (!best || d < best.d) best = { cm: clampWallCm(e.w.cm), d };
const span = entrySpan(e.w, scale);
let exact = false;
let d = 0;
if (span) {
// Normalisation may compact one equal-thickness run through several
// collinear room sides. Its midpoint can then lie outside a shorter
// child side, but the lossless endpoints still prove that the run
// covers that side. Require BOTH endpoints so a partial wall cannot
// leak into the rest of its parent edge (AUD-159B6-01).
if (!angleClose(segAngle(span[0], span[1]), ang)) continue;
if (distToSeg(a[0], a[1], span[0][0], span[0][1], span[1][0], span[1][1]) > tol
|| distToSeg(b[0], b[1], span[0][0], span[0][1], span[1][0], span[1][1]) > tol) continue;
exact = true;
d = Math.max(0, Math.hypot(span[1][0] - span[0][0], span[1][1] - span[0][1]) - parentLen);
} else {
if (distToSeg(e.x, e.y, a[0], a[1], b[0], b[1]) > tol) continue;
d = Math.hypot(e.x - mx, e.y - my);
}
if (!best || (exact && !best.exact) || (exact === best.exact && d < best.d)) {
best = { cm: clampWallCm(e.w.cm), d, exact };
}
}
if (!best) continue;
for (const i of idxs) cms[i] = best.cm;
+156 -1
View File
@@ -1,6 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, climateTempFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue , areaClimate, areaClimateMap, litLightEntity, seedHiddenBindings } from '../test-build/devices.js';
import {
buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, climateTempFor,
areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimate, areaClimateMap,
litLightEntity, resolvedDeviceStateEntities, resolvedLightSources, resolvedLightState,
resolvedLightStats, seedHiddenBindings,
} from '../test-build/devices.js';
import { compileIconRules, iconFor } from '../test-build/rules.js';
/** Minimal fake hass around the pieces buildDevices reads. */
@@ -150,6 +155,108 @@ test('primaryEntity: domain priority and diagnostic-category demotion', () => {
assert.equal(primaryEntity(h, ['sensor.batt'], 'mdi:chip'), 'sensor.batt');
});
test('primaryEntity: occupancy beats an enabled vendor option switch on a presence sensor', () => {
const hass = mkHass({
entities: {
'switch.presence_anti_interference': { entity_id: 'switch.presence_anti_interference' },
'binary_sensor.presence_occupancy': { entity_id: 'binary_sensor.presence_occupancy' },
'sensor.presence_illuminance': { entity_id: 'sensor.presence_illuminance' },
},
states: {
'switch.presence_anti_interference': { state: 'on', attributes: {} },
'binary_sensor.presence_occupancy': { state: 'off', attributes: { device_class: 'occupancy' } },
'sensor.presence_illuminance': { state: '12', attributes: { device_class: 'illuminance' } },
},
});
assert.equal(
primaryEntity(
hass,
['switch.presence_anti_interference', 'binary_sensor.presence_occupancy', 'sensor.presence_illuminance'],
'mdi:motion-sensor',
),
'binary_sensor.presence_occupancy',
);
assert.deepEqual(
resolvedDeviceStateEntities(
hass,
['switch.presence_anti_interference', 'binary_sensor.presence_occupancy', 'sensor.presence_illuminance'],
),
['binary_sensor.presence_occupancy'],
);
});
test('primaryEntity: vacuum beats an unavailable unclassified Customized Cleaning switch', () => {
const hass = mkHass({
entities: {
'switch.x50_master_customized_cleaning': { entity_id: 'switch.x50_master_customized_cleaning' },
'vacuum.x50_master': { entity_id: 'vacuum.x50_master' },
'sensor.x50_master_state': { entity_id: 'sensor.x50_master_state' },
},
states: {
'switch.x50_master_customized_cleaning': { state: 'unavailable', attributes: {} },
'vacuum.x50_master': { state: 'docked', attributes: {} },
'sensor.x50_master_state': { state: 'docked', attributes: {} },
},
});
assert.equal(
primaryEntity(
hass,
['switch.x50_master_customized_cleaning', 'vacuum.x50_master', 'sensor.x50_master_state'],
'mdi:robot-vacuum',
),
'vacuum.x50_master',
);
assert.deepEqual(
resolvedDeviceStateEntities(
hass,
['switch.x50_master_customized_cleaning', 'vacuum.x50_master', 'sensor.x50_master_state'],
),
['vacuum.x50_master'],
);
});
test('primaryEntity: a real cover beats its option switch, but a mixed lamp stays a light', () => {
const hass = mkHass({
entities: {
'cover.curtain': { entity_id: 'cover.curtain', hidden: true },
'switch.curtain_reverse': { entity_id: 'switch.curtain_reverse' },
'cover.mixed': { entity_id: 'cover.mixed' },
'light.mixed': { entity_id: 'light.mixed' },
},
states: {
'cover.curtain': { state: 'closed', attributes: { device_class: 'curtain' } },
'switch.curtain_reverse': { state: 'on', attributes: {} },
'cover.mixed': { state: 'opening', attributes: { device_class: 'curtain' } },
'light.mixed': { state: 'on', attributes: {} },
},
});
assert.equal(
primaryEntity(hass, ['switch.curtain_reverse', 'cover.curtain'], 'mdi:curtains'),
'cover.curtain',
);
assert.equal(
primaryEntity(hass, ['cover.mixed', 'light.mixed'], 'mdi:lightbulb'),
'light.mixed',
);
});
test('resolvedDeviceStateEntities: passive readings aggregate availability instead of picking the first', () => {
const hass = mkHass({
entities: {
'sensor.monitor_temperature': { entity_id: 'sensor.monitor_temperature' },
'sensor.monitor_humidity': { entity_id: 'sensor.monitor_humidity' },
},
states: {
'sensor.monitor_temperature': { state: 'unavailable', attributes: { device_class: 'temperature' } },
'sensor.monitor_humidity': { state: '45', attributes: { device_class: 'humidity' } },
},
});
assert.deepEqual(
resolvedDeviceStateEntities(hass, ['sensor.monitor_temperature', 'sensor.monitor_humidity']),
['sensor.monitor_temperature', 'sensor.monitor_humidity'],
);
});
test('lqiFor: dedicated sensor wins over attribute duplication; tempFor rounds', () => {
const h = mkHass({ states: {
'sensor.x_linkquality': { state: '120', attributes: { unit_of_measurement: 'lqi' } },
@@ -584,6 +691,54 @@ test('litLightEntity: one truth for "this thing is shining"', () => {
);
});
test('resolvedLightSources: one source set feeds room fill, card, glow and controls', () => {
const hass = { states: {
'light.auto': { state: 'on' },
'switch.relay': { state: 'on' },
'light.bound': { state: 'off' },
'switch.bound': { state: 'on' },
'light.own': { state: 'on' },
'light.hidden': { state: 'on' },
} };
const devices = [
{ id: 'lamp', area: 'living', entities: ['light.auto'] },
{ id: 'relay', area: 'living', primary: 'switch.relay', entities: ['switch.relay'], marker: { is_light: true } },
{
id: 'controller', area: 'living', entities: ['light.own'],
marker: { controls: ['light.bound', 'switch.bound'] },
},
{ id: 'hidden', area: 'living', hidden: true, entities: ['light.hidden'] },
];
const sources = resolvedLightSources(hass, devices, { id: 'room-a', area: 'living' });
assert.deepEqual(
sources.map(({ eid, via, on }) => ({ eid, via, on })),
[
{ eid: 'light.auto', via: 'light', on: true },
{ eid: 'switch.relay', via: 'forced', on: true },
{ eid: 'light.bound', via: 'controls', on: false },
{ eid: 'switch.bound', via: 'controls', on: true },
],
);
assert.equal(resolvedLightState(sources), 'on');
assert.deepEqual(resolvedLightStats(sources), { on: 3, total: 4 });
assert.ok(!sources.some((source) => source.eid === 'light.own'), 'controls replace automatic device lights');
assert.ok(!sources.some((source) => source.eid === 'light.hidden'), 'hidden markers cast and count no light');
});
test('resolvedLightSources: marker room_id is more precise than a shared HA area', () => {
const hass = { states: { 'switch.one': { state: 'on' } } };
const devices = [{
id: 'one', area: 'living', primary: 'switch.one', entities: ['switch.one'],
marker: { room_id: 'room-b', is_light: true },
}];
assert.deepEqual(resolvedLightSources(hass, devices, { id: 'room-a', area: 'living' }), []);
assert.deepEqual(
resolvedLightSources(hass, devices, { id: 'room-b', area: null }).map((source) => source.eid),
['switch.one'],
);
});
// ---------- explicit hide-from-plan flags (docs/FILTERING.md) ----------
+1
View File
@@ -179,6 +179,7 @@ test('icon rules: defaults are bilingual', () => {
test('icon rules: device_class fallback', () => {
assert.equal(iconFromDeviceClasses(['temperature']), 'mdi:thermometer');
assert.equal(iconFromDeviceClasses(['unknown', 'motion']), 'mdi:motion-sensor');
assert.equal(iconFromDeviceClasses(['presence']), 'mdi:motion-sensor');
assert.equal(iconFromDeviceClasses(['unknown']), null);
assert.equal(iconFromDeviceClasses([]), null);
});
+18
View File
@@ -188,6 +188,24 @@ test('partial shared wall: a pre-atomic whole-edge key still covers both pieces'
assert.ok(ivs.every((iv) => iv.cm === 30), 'an existing plan must not lose thickness');
});
test('a compacted exact wall covers a shorter collinear side in another room', () => {
const rooms = [
{ id: 'guest', poly: [[2, 2], [2, 6], [4, 6], [4, 2]] },
{ id: 'hall', poly: [[4, 2], [8, 2], [8, 11], [4, 11]] },
];
// Production T-junction: a long vertical real wall crosses the guest-room
// corner while a horizontal virtual wall starts at that same node. The
// compacted wall midpoint (4, 6.5) lies outside the shorter guest side, but
// its exact endpoints cover that side completely.
const walls = setWallThickness([], [4, 2], [4, 11], 15, pitch);
const open = [[4, 2, 8, 2]];
const right = wallIntervals(rooms, walls, open, pitch, cellCm, GRID_PITCH)
.find((iv) => iv.roomId === 'guest'
&& Math.abs(iv.a[0] - 4) < 1e-9 && Math.abs(iv.b[0] - 4) < 1e-9);
assert.equal(right?.cm, 15);
assert.ok(right && right.half > 0, 'hover/body profile must use the real inner face');
});
test('equal solid atomic pieces compact back to one whole-wall key', () => {
const rooms = partialRooms();
const walls = [