mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 12:18:51 +00:00
v1.59.1: unify device and light state
This commit is contained in:
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
Vendored
+58
-58
File diff suppressed because one or more lines are too long
+21
-5
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
# Документация House Plan
|
||||
|
||||
Этот каталог — новая точка входа в русскоязычную документацию House Plan. Материалы ниже сверены с исходным кодом и интерфейсом версии **v1.59.0-rc.1**.
|
||||
Этот каталог — новая точка входа в русскоязычную документацию House Plan. Материалы ниже сверены с исходным кодом и интерфейсом версии **v1.59.1**.
|
||||
|
||||
| Документ | Для кого | Что внутри |
|
||||
|---|---|---|
|
||||
|
||||
+7
-2
@@ -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
@@ -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.*` и привязку маркера к нужной комнате |
|
||||
|
||||
### Геометрия выглядит неверно
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) ----------
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user