mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 03:38:47 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e20279adf | ||
|
|
031e5439eb | ||
|
|
2bf9e44178 | ||
|
|
c2eaf50118 | ||
|
|
ac082569d7 | ||
|
|
a90316c9f3 | ||
|
|
912613bbf8 | ||
|
|
935a519c32 | ||
|
|
dd930b64f7 | ||
|
|
f235c8afc5 | ||
|
|
5e6f9c407c | ||
|
|
849117eb27 | ||
|
|
37b39e7a1e | ||
|
|
d4f2d81a2f | ||
|
|
bcb546dbc7 | ||
|
|
ccf36e283e | ||
|
|
821bdfbd9a | ||
|
|
22992b256f | ||
|
|
d273a90db8 | ||
|
|
48d47a46af | ||
|
|
0d712b9069 | ||
|
|
dfa2a8badc | ||
|
|
2dfab3565b | ||
|
|
6ee09d3750 | ||
|
|
29c5a89aad | ||
|
|
71f44fd528 | ||
|
|
c8722642b7 | ||
|
|
0a7b9a627f | ||
|
|
961d6afe67 | ||
|
|
b9fecbdd95 | ||
|
|
9fd36bd710 | ||
|
|
a9346c9c14 | ||
|
|
794b02b84f | ||
|
|
3bc25ad5ae | ||
|
|
d1a79cd0b4 | ||
|
|
5522399a3a | ||
|
|
185604396d | ||
|
|
9d0e2cab00 | ||
|
|
73f176ea2f | ||
|
|
b7599c642c | ||
|
|
4ec7e7081f | ||
|
|
ee5f65752a | ||
|
|
574d3b8f9a | ||
|
|
90c558eee7 | ||
|
|
4cd49d2c5d | ||
|
|
1fa6c6cdee | ||
|
|
70d3453f78 | ||
|
|
3e53b012be | ||
|
|
ff7c1b883c | ||
|
|
7c9731655e | ||
|
|
921facf1ac | ||
|
|
8ddf34f3ac | ||
|
|
5dd14a009a | ||
|
|
ae10befd58 | ||
|
|
f670c8644c | ||
|
|
9288d74f6f | ||
|
|
b706ad4b49 | ||
|
|
1552bff99a | ||
|
|
65268a7985 | ||
|
|
4593d96955 | ||
|
|
18a0d279a7 | ||
|
|
6cbf4ece4e | ||
|
|
6609bfadd5 | ||
|
|
498b852e86 | ||
|
|
fabeab81aa | ||
|
|
246de26122 | ||
|
|
1847c7b8ed |
@@ -51,6 +51,11 @@ Key advantages in short:
|
||||
|
||||
## Installation
|
||||
|
||||
One click if you already run HACS:
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
|
||||
|
||||
|
||||
### Via HACS (recommended)
|
||||
|
||||
1. Open **HACS → menu (⋮) → Custom repositories**.
|
||||
@@ -60,6 +65,21 @@ Key advantages in short:
|
||||
|
||||
The card is registered automatically — no need to add a Lovelace resource manually.
|
||||
|
||||
> **Card doesn't load (`Custom element doesn't exist: houseplan-card`) or you manage Lovelace
|
||||
> resources in YAML?** Add the resource manually pointing at the URL the integration *serves*:
|
||||
>
|
||||
> ```yaml
|
||||
> resources:
|
||||
> - url: /houseplan_files/houseplan-card.js
|
||||
> type: module
|
||||
> ```
|
||||
>
|
||||
> Do **not** use `/custom_components/houseplan/frontend/houseplan-card.js` — that is the file
|
||||
> on disk, which Home Assistant does not serve over HTTP (you'll get a `text/plain` MIME error
|
||||
> and the element never registers). The correct, integration-served URL is
|
||||
> `/houseplan_files/houseplan-card.js`. Both cards (`houseplan-card` and
|
||||
> `houseplan-space-card`) ship in that one file — no separate resource is needed.
|
||||
|
||||
### Manually
|
||||
|
||||
1. Copy the `custom_components/houseplan` folder into the `config/custom_components` directory of your Home Assistant.
|
||||
@@ -99,12 +119,35 @@ Later you can add as many spaces as you like (floors, yard, garage) with the **
|
||||
|
||||
### Step 2. Outline the rooms
|
||||
|
||||
After the first space is added, the card switches to markup mode by itself. Click grid points, connecting them with lines, and close the room outline by clicking the first point.
|
||||
After the first space is added, the card switches to the **Plan** tab by itself. The card has three mode tabs in the header — **View** (default: display and device control only, nothing can be moved or edited), **Plan** (rooms, openings, labels, space settings) and **Devices** (placing and configuring markers); the edit tabs are shown to administrators. In Plan, click grid points, connecting them with lines, and close the room outline by clicking the first point.
|
||||
|
||||
As soon as the outline is closed, the room-save dialog appears. Here you need to **bind the room to a Home Assistant area** — this is exactly what enables the automation. For utility rooms with no devices (hall, sauna) there is a **"No area"** button.
|
||||
|
||||

|
||||
|
||||
While drawing, a ruler follows the cursor showing the current segment's real length (metres, or feet + inches on an imperial Home Assistant). The scale is set per space — the **"Scale (grid cell size)"** field in the space dialog says how many centimetres one grid cell represents (default 5 cm).
|
||||
|
||||
Rooms may not overlap: a click strictly inside an existing room, or an outline that would swallow one, is refused. Two more tools help you reshape the plan later:
|
||||
|
||||
- **Merge** — click a room, then a neighbour that shares a wall; they fuse into one. A dialog picks which name and area survive.
|
||||
- **Split** — click a room, then two points on its walls; the chord cuts it in two. The bigger part stays the room it was (name, area, devices); the smaller one asks for a new name and area.
|
||||
|
||||
|
||||
### Doors, windows and locks
|
||||
|
||||
In markup mode the **"Opening"** tool places doors and windows: click next to a wall and the
|
||||
opening snaps onto it. Pick the type, the **length in real centimetres** (defaults: door 90 cm,
|
||||
window 120 cm), an open/close sensor and — for doors — a **lock entity**.
|
||||
|
||||
With a sensor bound, the plan comes alive: the door leaf swings on its hinge and the swing arc
|
||||
draws itself in as the real door opens; a window opens its two casements. While open, the moving
|
||||
parts take an accent colour. A door with a lock shows a padlock badge next to it — green when
|
||||
locked, orange when unlocked. For safety the lock can **not** be toggled from the plan; a click
|
||||
on the opening shows a status card with both states instead.
|
||||
|
||||
Openings are easy to adjust later: hovering one highlights it, you can **drag it along the
|
||||
walls** (it slides around corners too), and a **double click opens its properties**.
|
||||
|
||||
### Step 3. Devices appear by themselves
|
||||
|
||||
As soon as you save a room bound to an area, **the devices of that area are automatically laid out inside the outline**. These are the same devices shown on the **Settings → Devices → (filtered by the room)** page — only the meaningful ones, without service records, bridges and duplicates.
|
||||
@@ -123,7 +166,7 @@ The mouse wheel or the **- / ⊹ / +** buttons zoom the plan in and out; on
|
||||
|
||||
### Step 5. Put the icons in their places
|
||||
|
||||
Device icons can be **dragged with the mouse at any time** — no separate "edit mode" needs to be enabled. Positions are saved on the server and are identical in all browsers and devices. The **↺** button in the header restores the automatic layout.
|
||||
Switch to the **Devices** tab to arrange icons: drag them with the mouse, click one to open its editor. In **View** mode nothing can be moved — panning the map never displaces a sensor (a top user request). Positions are saved on the server and are identical in all browsers and devices. The **↺** button restores the automatic layout.
|
||||
|
||||

|
||||
|
||||
@@ -145,8 +188,17 @@ matches, the entity *device class* decides (thermometer for temperature sensors,
|
||||
|
||||
### Step 6. Adding your own devices manually
|
||||
|
||||
You can also place a **single entity** (not just a whole device): start typing in the binding search and individual entities appear next to devices — handy when one device exposes several values (e.g. temperature and humidity) and you want each as its own icon.
|
||||
|
||||
|
||||
Not everything has to be left to the automation. With the **+** button in the header you can place any device, group or a **virtual point** on the plan (for example, an "Inlet valve" that does not exist as a device). Set a name, icon, model, link, description and, if you wish, attach a **PDF manual**.
|
||||
|
||||
The same dialog controls how the device looks on the plan. **Display** switches between the
|
||||
icon badge, an animated **presence ripple** (pulsing rings while the entity is active, a faint
|
||||
dot when idle — great for motion sensors) or both, with a per-device ring colour and size. The
|
||||
**icon size** (×0.5–3) and **rotation** are also per-device, so a wall valve can be small and
|
||||
turned the way it is mounted.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
+53
-1
@@ -51,6 +51,11 @@ House Plan показывает ваш умный дом так, как он в
|
||||
|
||||
## Установка
|
||||
|
||||
В один клик, если у вас уже есть HACS:
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
|
||||
|
||||
|
||||
### Через HACS (рекомендуется)
|
||||
|
||||
1. Откройте **HACS → меню (⋮) → Custom repositories**.
|
||||
@@ -60,6 +65,21 @@ House Plan показывает ваш умный дом так, как он в
|
||||
|
||||
Карточка подключается автоматически — добавлять ресурс Lovelace вручную не нужно.
|
||||
|
||||
> **Карточка не грузится (`Custom element doesn't exist: houseplan-card`) или вы ведёте ресурсы
|
||||
> Lovelace в YAML?** Добавьте ресурс вручную, указав URL, который *раздаёт сама интеграция*:
|
||||
>
|
||||
> ```yaml
|
||||
> resources:
|
||||
> - url: /houseplan_files/houseplan-card.js
|
||||
> type: module
|
||||
> ```
|
||||
>
|
||||
> **Не** используйте `/custom_components/houseplan/frontend/houseplan-card.js` — это путь к файлу
|
||||
> на диске, который Home Assistant не отдаёт по HTTP (получите ошибку MIME `text/plain`, и элемент
|
||||
> не зарегистрируется). Правильный URL, который раздаёт интеграция, — `/houseplan_files/houseplan-card.js`.
|
||||
> Обе карточки (`houseplan-card` и `houseplan-space-card`) лежат в этом одном файле — отдельный ресурс
|
||||
> не нужен.
|
||||
|
||||
### Вручную
|
||||
|
||||
1. Скопируйте папку `custom_components/houseplan` в каталог `config/custom_components` вашего Home Assistant.
|
||||
@@ -107,6 +127,29 @@ title: План дома
|
||||
|
||||

|
||||
|
||||
Во время рисования у курсора показывается линейка с реальной длиной текущего отрезка (метры или футы+дюймы на имперской системе HA). Масштаб задаётся для каждого пространства — поле **«Масштаб (размер ячейки сетки)»** в диалоге пространства: сколько сантиметров в одной ячейке (по умолчанию 5 см).
|
||||
|
||||
Комнаты не могут пересекаться: клик строго внутри существующей комнаты или контур, охватывающий её, отклоняются. Ещё два инструмента помогают перекроить план позже:
|
||||
|
||||
- **Объединить** — кликните комнату, затем соседнюю с общей стеной; они сольются в одну. Диалог выбирает, чьё имя и зона останутся.
|
||||
- **Разделить** — кликните комнату, затем две точки на её стенах; хорда разрежет её надвое. Бо́льшая часть остаётся прежней комнатой (имя, зона, устройства), меньшая просит новое имя и зону.
|
||||
|
||||
|
||||
### Двери, окна и замки
|
||||
|
||||
В режиме разметки инструмент **«Проём»** ставит двери и окна: кликните рядом со стеной — проём
|
||||
примагнитится к ней. Выберите тип, **длину в реальных сантиметрах** (по умолчанию дверь 90 см,
|
||||
окно 120 см), датчик открытия и — для двери — **замок**.
|
||||
|
||||
С привязанным датчиком план оживает: створка двери поворачивается на петле, и дуга распахивания
|
||||
дорисовывается по мере открытия настоящей двери; окно раскрывает две створки. Пока открыто,
|
||||
подвижные части подсвечены акцентным цветом. У двери с замком рядом отображается замочек —
|
||||
зелёный, когда заперто, оранжевый, когда нет. Ради безопасности замок с плана **нельзя**
|
||||
переключить — клик по проёму показывает карточку с обоими статусами.
|
||||
|
||||
Проёмы легко поправить позже: при наведении проём подсвечивается, его можно **перетащить вдоль
|
||||
стен** (в том числе за угол), а **двойной клик открывает свойства**.
|
||||
|
||||
### Шаг 3. Устройства появляются сами
|
||||
|
||||
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
|
||||
@@ -125,7 +168,7 @@ title: План дома
|
||||
|
||||
### Шаг 5. Расставьте значки по местам
|
||||
|
||||
Значки устройств можно **перетаскивать мышью в любой момент** — отдельный «режим правки» включать не нужно. Позиции сохраняются на сервере и одинаковы во всех браузерах и устройствах. Кнопка **↺** в шапке возвращает автоматическую раскладку.
|
||||
Расставлять значки нужно на вкладке **«Устройства»**: там они перетаскиваются мышью, а клик открывает редактор. В режиме **«Просмотр»** ничего сдвинуть нельзя — панорамирование карты больше не сдвигает датчики (главная просьба пользователей). Позиции сохраняются на сервере и одинаковы во всех браузерах и устройствах. Кнопка **↺** возвращает автоматическую раскладку.
|
||||
|
||||

|
||||
|
||||
@@ -149,8 +192,17 @@ title: План дома
|
||||
|
||||
### Шаг 6. Добавление своих устройств вручную
|
||||
|
||||
Можно поставить и **отдельную сущность** (не только устройство целиком): начните печатать в поиске привязки — рядом с устройствами появятся отдельные сущности. Удобно, когда одно устройство отдаёт несколько значений (например, температуру и влажность), а вы хотите каждое своей иконкой.
|
||||
|
||||
|
||||
Не всё нужно оставлять на автоматику. Кнопкой **+** в шапке можно поставить на план любое устройство, группу или **виртуальную точку** (например, «Вентиль на вводе», которого нет как устройства). Задайте имя, иконку, модель, ссылку, описание и при желании приложите **PDF-инструкцию**.
|
||||
|
||||
В этом же диалоге настраивается вид устройства на плане. **Отображение** переключает значок,
|
||||
анимированную **пульсацию присутствия** (расходящиеся кольца, пока сущность активна, и тусклая
|
||||
точка в покое — идеально для датчиков движения) или то и другое сразу, с цветом и размером колец
|
||||
на устройство. **Размер значка** (×0,5–3) и **поворот** — тоже индивидуальные: вентиль на стене
|
||||
может быть маленьким и повёрнутым так, как он установлен.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
@@ -79,8 +79,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
# start of the mobile app). If the resource registry is unavailable (YAML-mode
|
||||
# Lovelace, old versions) — fall back to extra_module_url.
|
||||
module_url = f"{FRONTEND_URL}?v={VERSION}"
|
||||
if not await _register_lovelace_resource(hass, module_url):
|
||||
registered = await _register_lovelace_resource(hass, module_url)
|
||||
if not registered:
|
||||
add_extra_js_url(hass, module_url)
|
||||
# Tell the user exactly where the card lives — the #1 support issue is people adding a
|
||||
# Lovelace resource pointing at the on-disk path (/custom_components/...), which HA does
|
||||
# not serve (wrong MIME → "Custom element doesn't exist"). The correct served URL is below.
|
||||
if registered:
|
||||
_LOGGER.info("House Plan card auto-registered as a Lovelace resource: %s", module_url)
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"House Plan card is served at %s . Lovelace resources look YAML-managed — add it "
|
||||
"manually under `resources:` as { url: %s, type: module }. Do NOT use the on-disk "
|
||||
"path /custom_components/houseplan/frontend/houseplan-card.js (HA does not serve it).",
|
||||
module_url, module_url,
|
||||
)
|
||||
|
||||
await async_check_plan_files(hass, entry)
|
||||
return True
|
||||
|
||||
Binary file not shown.
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
||||
FILES_URL = "/houseplan_files/files"
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.15.6"
|
||||
VERSION = "1.34.0"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
@@ -31,7 +31,6 @@ async def async_get_config_entry_diagnostics(
|
||||
"has_plan": bool(s.get("plan_url")),
|
||||
"rooms": len(s.get("rooms", [])),
|
||||
"rooms_with_area": sum(1 for r in s.get("rooms", []) if r.get("area")),
|
||||
"segments": len(s.get("segments", [])),
|
||||
}
|
||||
for s in config.get("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.15.6"
|
||||
"version": "1.34.0"
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import voluptuous as vol
|
||||
PLAN_EXTENSIONS = {"svg": "image/svg+xml", "png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
|
||||
MAX_PLAN_BYTES = 8 * 1024 * 1024
|
||||
FILE_EXTENSIONS = {"pdf", "png", "jpg", "jpeg", "webp", "txt"}
|
||||
MAX_FILE_BYTES = 25 * 1024 * 1024
|
||||
MAX_FILE_BYTES = 50 * 1024 * 1024
|
||||
|
||||
SPACE_ID_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
|
||||
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
@@ -87,10 +87,38 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
|
||||
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp"]),
|
||||
vol.Optional("temp_min"): vol.Coerce(float),
|
||||
vol.Optional("temp_max"): vol.Coerce(float),
|
||||
vol.Optional("show_lqi"): bool,
|
||||
vol.Optional("label_temp"): bool,
|
||||
vol.Optional("label_hum"): bool,
|
||||
vol.Optional("label_lqi"): bool,
|
||||
vol.Optional("label_light"): bool,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
_DECOR_COMMON = {
|
||||
vol.Required("id"): str,
|
||||
vol.Optional("color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
vol.Optional("width"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=30)),
|
||||
}
|
||||
_NORM = vol.All(vol.Coerce(float), vol.Range(min=-1, max=2))
|
||||
DECOR_SCHEMA = vol.Any(
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "line",
|
||||
vol.Required("x1"): _NORM, vol.Required("y1"): _NORM,
|
||||
vol.Required("x2"): _NORM, vol.Required("y2"): _NORM},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): vol.In(["rect", "ellipse"]),
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
vol.Required("w"): _NORM, vol.Required("h"): _NORM,
|
||||
vol.Optional("fill"): bool},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "text",
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
vol.Required("text"): vol.All(str, vol.Length(min=1, max=200)),
|
||||
vol.Optional("size"): vol.In(["s", "m", "l"])},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
)
|
||||
|
||||
SPACE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
@@ -100,6 +128,28 @@ SPACE_SCHEMA = vol.Schema(
|
||||
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
||||
vol.Required("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
|
||||
vol.Required("rooms"): [ROOM_SCHEMA],
|
||||
vol.Optional("decor"): [DECOR_SCHEMA],
|
||||
vol.Optional("openings"): [
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("type"): vol.Any("door", "window"),
|
||||
vol.Required("x"): vol.Coerce(float),
|
||||
vol.Required("y"): vol.Coerce(float),
|
||||
vol.Required("angle"): vol.Coerce(float),
|
||||
vol.Required("length"): vol.All(vol.Coerce(float), vol.Range(min=0.001, max=1)),
|
||||
vol.Optional("contact"): vol.Any(str, None),
|
||||
vol.Optional("lock"): vol.Any(str, None),
|
||||
vol.Optional("invert"): bool,
|
||||
vol.Optional("flip_h"): bool,
|
||||
vol.Optional("flip_v"): bool,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
],
|
||||
# Legacy: walls are derived from room outlines since v1.19.0 — a line has no
|
||||
# independent existence. Still accepted so a stale browser tab cannot fail a save;
|
||||
# the card strips the field on every write.
|
||||
vol.Optional("segments"): [vol.All([vol.Coerce(float)], vol.Length(min=4, max=4))],
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
@@ -118,6 +168,12 @@ MARKER_SCHEMA = vol.Schema(
|
||||
vol.Optional("link"): vol.Any(str, None),
|
||||
vol.Optional("description"): vol.Any(str, None),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
|
||||
vol.Optional("room_id"): vol.Any(str, None),
|
||||
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", None),
|
||||
vol.Optional("ripple_color"): vol.Any(str, None),
|
||||
vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None),
|
||||
vol.Optional("size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0.2, max=6)), None),
|
||||
vol.Optional("angle"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-360, max=360)), None),
|
||||
vol.Optional("pdfs"): [
|
||||
vol.Schema({vol.Required("name"): str, vol.Required("url"): str}, extra=vol.ALLOW_EXTRA)
|
||||
],
|
||||
@@ -128,7 +184,23 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("spaces"): [SPACE_SCHEMA],
|
||||
vol.Optional("markers", default=list): [MARKER_SCHEMA],
|
||||
vol.Optional("settings", default=dict): vol.Schema({}, extra=vol.ALLOW_EXTRA),
|
||||
vol.Optional("settings", default=dict): vol.Schema(
|
||||
{
|
||||
vol.Optional("known_devices"): [str],
|
||||
vol.Optional("new_device_ids"): [str],
|
||||
vol.Optional("fill_colors"): vol.Schema(
|
||||
{
|
||||
str: vol.Schema(
|
||||
{
|
||||
vol.Required("c"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
|
||||
}
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
|
||||
)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
const stage = sr().querySelector('.stage');
|
||||
const label = sr().querySelector('.roomlabel');
|
||||
out.hasLabel = !!label;
|
||||
// 1) draw: клик по карточке не добавляет точку
|
||||
c._tool = 'draw'; c._path = []; await c.updateComplete;
|
||||
label.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, clientX: 10, clientY: 10 }));
|
||||
await c.updateComplete;
|
||||
out.drawIgnored = c._path.length === 0;
|
||||
// 2) клик по угловой метке тоже не считается кликом инструмента
|
||||
const handle = label.querySelector('.rlhandle');
|
||||
handle.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, clientX: 10, clientY: 10 }));
|
||||
await c.updateComplete;
|
||||
out.handleIgnored = c._path.length === 0;
|
||||
// 3) во время резайза клики по сцене игнорируются
|
||||
const room = c._spaceModel().rooms.find((r) => r.name);
|
||||
c._rlResize = { id: 'rl_' + room.id, space: c._space, k0: 1, cx: 0, cy: 0, d0: 50 };
|
||||
const before = c._path.length;
|
||||
c._markupClick(new MouseEvent('click', { bubbles: true, clientX: 200, clientY: 200 }));
|
||||
out.duringResizeIgnored = c._path.length === before;
|
||||
c._rlResize = null;
|
||||
// 4) обычный клик по сцене (мимо карточки) по-прежнему работает — точка ставится
|
||||
const r = stage.getBoundingClientRect();
|
||||
// найти долю экрана, попадающую в свободное место (не внутри комнаты)
|
||||
let fx = 0.4, fy = 0.4;
|
||||
outer: for (let ix = 1; ix < 20; ix++) for (let iy = 1; iy < 20; iy++) {
|
||||
const ev = new MouseEvent('click', { clientX: r.left + r.width * ix / 20, clientY: r.top + r.height * iy / 20 });
|
||||
const raw = c._svgPoint(ev);
|
||||
if (!c._roomAt(c._snap(raw))) { fx = ix / 20; fy = iy / 20; break outer; }
|
||||
}
|
||||
stage.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true,
|
||||
clientX: r.left + r.width * fx, clientY: r.top + r.height * fy }));
|
||||
await c.updateComplete;
|
||||
out.normalClickWorks = c._path.length === 1;
|
||||
// 5) delroom: клик по карточке не зовёт confirm (переопределим)
|
||||
let confirmCalled = false;
|
||||
window.confirm = () => { confirmCalled = true; return false; };
|
||||
c._tool = 'delroom'; c._path = []; await c.updateComplete;
|
||||
label.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, clientX: 10, clientY: 10 }));
|
||||
await c.updateComplete;
|
||||
out.delroomIgnored = !confirmCalled;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,74 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
|
||||
// 1) третья вкладка есть
|
||||
const tabs = [...sr().querySelectorAll('.modetab')];
|
||||
out.threeTabs = tabs.length === 3;
|
||||
tabs[2].click(); await c.updateComplete;
|
||||
out.decorMode = c._mode === 'decor';
|
||||
out.decorBar = !!sr().querySelector('.editbar.decorbar');
|
||||
out.toolBtns = sr().querySelectorAll('.decorbar .btn:not(.barclose)').length === 6;
|
||||
// 2) нарисовать прямоугольник drag-ом (через прямые вызовы)
|
||||
c._decorTool = 'rect'; c._decorStyle = { color: '#ff0000', width: 3, fill: true }; await c.updateComplete;
|
||||
const g = c._gridPitch;
|
||||
c._decorDraft = { kind: 'rect', a: [g * 10, g * 10], b: [g * 10, g * 10], pid: 1 };
|
||||
c._decorDraft = { ...c._decorDraft, b: [g * 20, g * 16] };
|
||||
await c.updateComplete;
|
||||
out.draftPreview = !!sr().querySelector('.ddraft');
|
||||
c._decorCommitDraft(); await c.updateComplete;
|
||||
out.rectSaved = c._curSpaceCfg.decor?.length === 1 && c._curSpaceCfg.decor[0].kind === 'rect';
|
||||
out.rectFill = c._curSpaceCfg.decor[0].fill === true && c._curSpaceCfg.decor[0].color === '#ff0000';
|
||||
out.rectRendered = !!sr().querySelector('.decorlayer rect.dshape');
|
||||
// 3) линия и овал
|
||||
c._decorDraft = { kind: 'line', a: [g * 2, g * 2], b: [g * 8, g * 2], pid: 1 };
|
||||
c._decorCommitDraft();
|
||||
c._decorDraft = { kind: 'ellipse', a: [g * 30, g * 30], b: [g * 40, g * 36], pid: 1 };
|
||||
c._decorCommitDraft(); await c.updateComplete;
|
||||
out.threeShapes = c._decorList.length === 3;
|
||||
// вырожденная фигура не сохраняется
|
||||
c._decorDraft = { kind: 'line', a: [g, g], b: [g, g], pid: 1 };
|
||||
c._decorCommitDraft();
|
||||
out.degenerateSkipped = c._decorList.length === 3;
|
||||
// 4) надпись через диалог
|
||||
c._decorTextDialog = { x: 0.5, y: 0.5, text: 'Сауна', size: 'l', color: '#0000ff' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
out.textSaved = c._decorList.some((x) => x.kind === 'text' && x.text === 'Сауна');
|
||||
out.textRendered = [...sr().querySelectorAll('.decorlayer text')].some((t) => t.textContent.includes('Сауна'));
|
||||
// 5) select: перемещение сохраняет форму, drag двигает
|
||||
const rect = c._decorList.find((x) => x.kind === 'rect');
|
||||
c._decorTool = 'select';
|
||||
c._decorMove = { id: rect.id, start: [0, 0], orig: JSON.parse(JSON.stringify(rect)), pid: 7, moved: false };
|
||||
c._decorMoveUpdate({ clientX: 0, clientY: 0, }); // без смещения
|
||||
const before = { ...c._decorList.find((x) => x.id === rect.id) };
|
||||
c._decorMove.start = [0, 0];
|
||||
// сдвиг на 5 клеток по x: подделаем _svgPoint? проще напрямую:
|
||||
const m = c._decorMove; const dx5 = (g * 5) / 1000;
|
||||
c._curSpaceCfg.decor = c._decorList.map((x) => x.id === m.id ? { ...x, x: m.orig.x + dx5 } : x);
|
||||
out.moveKeepsSize = Math.abs(c._decorList.find((x) => x.id === rect.id).w - before.w) < 1e-9;
|
||||
c._decorMove = null;
|
||||
// 6) erase удаляет
|
||||
const n0 = c._decorList.length;
|
||||
c._decorTool = 'erase';
|
||||
c._decorShapeDown({ stopPropagation() {}, preventDefault() {}, target: null, pointerId: 1 }, c._decorList[0]);
|
||||
out.eraseWorks = c._decorList.length === n0 - 1;
|
||||
// 7) Esc-лестница: инструмент → select → выход
|
||||
c._decorTool = 'line'; c._decorSel = null; await c.updateComplete;
|
||||
await esc(); out.escTool = c._decorTool === 'select' && c._mode === 'decor';
|
||||
await esc(); out.escExit = c._mode === 'view';
|
||||
// 8) в Просмотре фигуры видны, но inert
|
||||
const shp = sr().querySelector('.decorlayer .dshape');
|
||||
out.visibleInView = !!shp;
|
||||
out.inertInView = shp ? getComputedStyle(shp).pointerEvents === 'none' : null;
|
||||
// 9) Delete удаляет выбранное
|
||||
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
|
||||
c._decorTool = 'select'; c._decorSel = c._decorList[0].id; const n1 = c._decorList.length; await c.updateComplete;
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete' })); await c.updateComplete;
|
||||
out.deleteKey = c._decorList.length === n1 - 1;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,26 @@
|
||||
// Smoke: the full houseplan-card honours the #space=<id> deep-link (hashchange).
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 820, height: 760 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const ids = card._model.map((s) => s.id);
|
||||
const target = ids[1] || ids[0];
|
||||
const before = card._space;
|
||||
// simulate a deep-link arriving on the already-open dashboard
|
||||
window.location.hash = '#space=' + target;
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
await card.updateComplete;
|
||||
const after = card._space;
|
||||
// invalid hash must be ignored
|
||||
window.location.hash = '#space=__nope__';
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
await card.updateComplete;
|
||||
const afterBad = card._space;
|
||||
window.location.hash = '';
|
||||
return { ids, before, target, after, afterBad };
|
||||
});
|
||||
await browser.close();
|
||||
const ok = res.after === res.target && res.afterBad === res.target;
|
||||
console.log(JSON.stringify(res));
|
||||
if (!ok) { console.error('FAIL deeplink smoke'); process.exit(1); }
|
||||
console.log('OK deep-link: full card switches to #space=<id>, ignores invalid ids');
|
||||
@@ -0,0 +1,39 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const tabs = () => [...sr().querySelectorAll('.modetab')];
|
||||
// 1) две вкладки, Просмотра нет, крестиков в неактивных нет
|
||||
out.twoTabs = tabs().length === 3; // третья — Редактор подложки (v1.33.0)
|
||||
out.labels = tabs().map((t) => t.textContent.trim());
|
||||
out.noCrossIdle = sr().querySelectorAll('.modetab .closex').length === 0;
|
||||
out.startView = c._mode === 'view';
|
||||
// 2) клик по «Редактор плана» → активна, панель с крестиком
|
||||
tabs()[0].click(); await c.updateComplete;
|
||||
out.planActive = c._mode === 'plan' && tabs()[0].classList.contains('active');
|
||||
out.planBar = !!sr().querySelector('.editbar');
|
||||
out.planBarClose = !!sr().querySelector('.editbar .barclose');
|
||||
out.tabCross = !!tabs()[0].querySelector('.closex');
|
||||
// 3) повторный клик по активной вкладке — ничего
|
||||
tabs()[0].click(); await c.updateComplete;
|
||||
out.reclickNoop = c._mode === 'plan';
|
||||
// 4) прямое переключение План → Устройства
|
||||
tabs()[1].click(); await c.updateComplete;
|
||||
out.directSwitch = c._mode === 'devices';
|
||||
out.devBar = !!sr().querySelector('.editbar.devbar');
|
||||
out.devBarBtns = sr().querySelectorAll('.editbar.devbar .btn:not(.barclose)').length === 3; // add/show-all/rules (v1.33.2: Reset removed)
|
||||
// 5) инструменты устройств из шапки исчезли (в .bar их больше нет)
|
||||
out.headerCleanInDev = !sr().querySelector('.bar > .btn[title*="' + (c._t('title.add_device')) + '"]');
|
||||
// 6) крестик на панели → Просмотр
|
||||
sr().querySelector('.editbar .barclose').click(); await c.updateComplete;
|
||||
out.barCloseWorks = c._mode === 'view' && !sr().querySelector('.editbar');
|
||||
// 7) крестик в самой вкладке → Просмотр (и не переключает режим)
|
||||
tabs()[1].click(); await c.updateComplete;
|
||||
tabs()[1].querySelector('.closex').click(); await c.updateComplete;
|
||||
out.tabCrossWorks = c._mode === 'view';
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,35 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
|
||||
// общие настройки
|
||||
c._openSettingsDialog(); await c.updateComplete;
|
||||
await esc(); out.settings = !c._settingsDialog;
|
||||
// правила иконок
|
||||
c._openRulesDialog(); await c.updateComplete;
|
||||
await esc(); out.rules = !c._rulesDialog;
|
||||
// диалог устройства
|
||||
c._setMode('devices'); c._openMarkerDialog(); await c.updateComplete;
|
||||
await esc(); out.marker = !c._markerDialog;
|
||||
// диалог пространства (+ очистка очереди импорта)
|
||||
c._openSpaceDialog('edit', c._space); c._importQueue = ['x']; c._importTotal = 1; await c.updateComplete;
|
||||
await esc(); out.space = !c._spaceDialog && c._importQueue.length === 0;
|
||||
// инфо-карточка устройства
|
||||
c._setMode('view'); c._infoCard = c._devices[0]; await c.updateComplete;
|
||||
await esc(); out.info = !c._infoCard;
|
||||
// инфо двери/замка
|
||||
c._openingInfo = { id: 'op1', type: 'door', rx: 550, ry: 200, len_cm: 90, lock: 'lock.front_door' }; await c.updateComplete;
|
||||
await esc(); out.openingInfo = !c._openingInfo;
|
||||
// приоритет: инфо поверх настроек — Esc закрывает только инфо
|
||||
c._openSettingsDialog(); c._infoCard = c._devices[0]; await c.updateComplete;
|
||||
await esc(); out.stacked = !c._infoCard && !!c._settingsDialog;
|
||||
await esc(); out.stacked2 = !c._settingsDialog;
|
||||
// Esc в разметке по-прежнему откатывает точку, а не только диалоги
|
||||
c._setMode('plan'); c._tool = 'draw'; c._path = [[1, 2], [3, 4]]; await c.updateComplete;
|
||||
await esc(); out.undoPointStillWorks = c._path.length === 1;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,35 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const gears = () => sr().querySelectorAll('.tab .tabedit').length;
|
||||
// шестерёнки видны во всех трёх режимах
|
||||
out.viewGears = gears() > 0;
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
out.planGears = gears() > 0;
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
out.devGears = gears() > 0;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
// выравнивание: центр иконки ~ центру текста таба
|
||||
const tab = sr().querySelector('.tab');
|
||||
const icon = tab.querySelector('.tabedit');
|
||||
const tb = tab.getBoundingClientRect(), ib = icon.getBoundingClientRect();
|
||||
const tabMid = tb.top + tb.height / 2, iconMid = ib.top + ib.height / 2;
|
||||
out.alignDelta = Math.abs(tabMid - iconMid);
|
||||
out.aligned = out.alignDelta <= 1.5;
|
||||
// клик по шестерёнке в Просмотре открывает диалог пространства и не переключает таб
|
||||
const cur = c._space;
|
||||
icon.click(); await c.updateComplete;
|
||||
out.dialogOpens = !!c._spaceDialog;
|
||||
out.tabNotSwitched = c._space === cur;
|
||||
c._spaceDialog = null; await c.updateComplete;
|
||||
// кнопка "+" по-прежнему только в Плане
|
||||
out.noAddInView = !sr().querySelector('.tab.tabadd');
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
out.addInPlan = !!sr().querySelector('.tab.tabadd');
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,30 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// 1) диалог общих настроек: 7 цветовых строк, 3 группы
|
||||
c._openSettingsDialog(); await c.updateComplete;
|
||||
out.rows = sr().querySelectorAll('.gsrow').length;
|
||||
out.groups = [...sr().querySelectorAll('.dialog .dispsection')].map((l) => l.textContent.trim());
|
||||
// 2) сменить цвет light_on и сохранить
|
||||
c._setFillColor('light_on', { c: '#ff00ff', a: 0.5 });
|
||||
await c._saveSettingsDialog(); await c.updateComplete;
|
||||
out.saved = c._serverCfg.settings.fill_colors?.light_on;
|
||||
// 3) заливка light использует кастомный цвет
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({ ...s,
|
||||
settings: { ...(s.settings||{}), show_borders: true, fill_mode: 'light' } })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const styles = [...sr().querySelectorAll('.room.styled')].map((r) => r.getAttribute('style') || '');
|
||||
out.customFillUsed = styles.some((st) => st.includes('#ff00ff') && st.includes('0.500'));
|
||||
// 4) show_lqi=false у пространства скрывает LQI-бейджи
|
||||
out.lqiBefore = sr().querySelectorAll('.dev .lqi').length;
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({ ...s,
|
||||
settings: { ...(s.settings||{}), show_lqi: false } })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.lqiAfter = sr().querySelectorAll('.dev .lqi').length;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,37 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const grid = () => !!sr().querySelector('rect[fill="url(#hp-grid)"]');
|
||||
// Просмотр: сетки нет
|
||||
out.noGridInView = !grid();
|
||||
// все три редактора: сетка есть
|
||||
c._setMode('plan'); await c.updateComplete; out.gridInPlan = grid();
|
||||
c._setMode('devices'); await c.updateComplete; out.gridInDevices = grid();
|
||||
c._setMode('decor'); await c.updateComplete; out.gridInDecor = grid();
|
||||
await new Promise((r) => setTimeout(r, 250)); // transition .room 0.12s
|
||||
// decor: комнаты и устройства полупрозрачны
|
||||
const room = sr().querySelector('.room');
|
||||
out.roomFaded = room ? Number(getComputedStyle(room).opacity) < 0.5 : null;
|
||||
const dl = sr().querySelector('.devlayer');
|
||||
out.devsFaded = dl ? Number(getComputedStyle(dl).opacity) < 0.5 : null;
|
||||
// а декор-слой — нет
|
||||
c._curSpaceCfg.decor = [{ id: 'd1', kind: 'line', x1: 0.1, y1: 0.1, x2: 0.3, y2: 0.1, color: '#ff0000', width: 3 }];
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const shp = sr().querySelector('.decorlayer .dshape');
|
||||
out.decorNotFaded = shp ? Number(getComputedStyle(shp).opacity) > 0.9 : null;
|
||||
// в других редакторах прозрачности нет
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const room2 = sr().querySelector('.room');
|
||||
out.notFadedInDevices = room2 ? Number(getComputedStyle(room2).opacity) > 0.9 : null;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const room3 = sr().querySelector('.room');
|
||||
out.notFadedInView = room3 ? Number(getComputedStyle(room3).opacity) > 0.9 : null;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,18 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const gs = () => !!sr().querySelector('.head .btn[title="' + c._t('title.general_settings') + '"]');
|
||||
out.view = gs();
|
||||
c._setMode('plan'); await c.updateComplete; out.plan = gs();
|
||||
c._setMode('devices'); await c.updateComplete; out.devices = gs();
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
sr().querySelector('.head .btn[title="' + c._t('title.general_settings') + '"]').click();
|
||||
await c.updateComplete;
|
||||
out.opensInView = !!c._settingsDialog;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,31 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
// устройство без явной иконки (marker.icon пуст)
|
||||
const dev = c._devices.find((d) => !d.marker?.icon && d.icon);
|
||||
out.hasAutoDev = !!dev;
|
||||
c._openMarkerDialog(dev); await c.updateComplete;
|
||||
out.autoIconStored = c._markerDialog.autoIcon === dev.icon;
|
||||
out.iconEmpty = c._markerDialog.icon === '';
|
||||
// подсказка с авто-иконкой видна
|
||||
const hint = sr().querySelector('.iconauto');
|
||||
out.hintShown = !!hint && hint.textContent.includes(dev.icon);
|
||||
out.hintIcon = hint?.querySelector('ha-icon')?.getAttribute('icon') === dev.icon;
|
||||
// пикер (или фолбэк-инпут) получил placeholder
|
||||
const picker = sr().querySelector('ha-icon-picker');
|
||||
const inputs = [...sr().querySelectorAll('.dialog input')];
|
||||
out.placeholderSet = picker
|
||||
? picker.placeholder === dev.icon
|
||||
: inputs.some((i) => i.placeholder === dev.icon);
|
||||
// при явной иконке подсказки нет
|
||||
c._markerDialog = { ...c._markerDialog, icon: 'mdi:star' }; await c.updateComplete;
|
||||
out.noHintWhenExplicit = !sr().querySelector('.iconauto');
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,47 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// добавить открывание на f1
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({
|
||||
...s, openings: [{ id: 'op1', type: 'door', rx: 550, ry: 200, angle: 90, len_cm: 90 }] })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const hit = sr().querySelector('.op-hit');
|
||||
out.hasOpening = !!hit;
|
||||
const cs = hit ? getComputedStyle(hit) : null;
|
||||
out.viewInert = cs ? cs.pointerEvents === 'none' && cs.cursor === 'default' : null;
|
||||
out.lockBadgeInert = (() => { const l = sr().querySelector('.oplock'); return l ? getComputedStyle(l).pointerEvents === 'none' : 'no-badge'; })();
|
||||
// View: клик по открыванию ничего не открывает
|
||||
c._opClick({ stopPropagation(){} }, { id: 'op1', rx: 550, ry: 200, rlen: 90 });
|
||||
out.viewClickNoop = !c._openingInfo && !c._openingDialog;
|
||||
// курсор устройств в Просмотре — pointer, в Устройствах — grab
|
||||
const dev = sr().querySelector('.dev');
|
||||
out.viewDevCursor = getComputedStyle(dev).cursor;
|
||||
// бейдж замка: замок привязан → бейдж кликабелен и открывает инфо
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s2) => s2.id !== 'f1' ? s2 : ({
|
||||
...s2, openings: [{ id: 'op1', type: 'door', rx: 550, ry: 200, angle: 90, len_cm: 90, lock: 'lock.front_door' }] })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const badge = sr().querySelector('.oplock');
|
||||
out.badgeShown = !!badge;
|
||||
const bcs = badge ? getComputedStyle(badge) : null;
|
||||
out.badgeClickableInView = bcs ? bcs.pointerEvents === 'auto' && bcs.cursor === 'pointer' : null;
|
||||
badge?.click(); await c.updateComplete;
|
||||
out.badgeOpensInfo = !!c._openingInfo;
|
||||
c._openingInfo = null;
|
||||
// Plan: op-hit интерактивен, клик редактирует
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
out.devModeCursor = getComputedStyle(sr().querySelector('.dev')).cursor;
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
out.badgeInertInPlan = (() => { const b = sr().querySelector('.oplock'); return b ? getComputedStyle(b).pointerEvents === 'none' : 'no-badge'; })();
|
||||
const cs2 = getComputedStyle(sr().querySelector('.op-hit'));
|
||||
out.planInteractive = cs2.pointerEvents === 'auto' && cs2.cursor === 'grab';
|
||||
const op = c._spaceModel().openings?.[0] || { id: 'op1', rx: 550, ry: 200, rlen: 90 };
|
||||
c._opClick({ stopPropagation(){} }, op);
|
||||
out.planClickEdits = !!c._openingDialog;
|
||||
c._openingDialog = null; c._setMode('view');
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,59 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
|
||||
const g = c._gridPitch;
|
||||
// r1 — комната; рисуем остров-«колонну» внутри неё
|
||||
const r1 = c._spaceModel().rooms.find((r) => r.id === 'r1');
|
||||
const [cx, cy] = c._roomCenter(r1).map((v) => Math.round(v / g) * g);
|
||||
const pts = [[cx, cy], [cx + g * 2, cy], [cx + g * 2, cy + g * 2], [cx, cy + g * 2]];
|
||||
// 1) клики внутри комнаты больше не отклоняются
|
||||
for (const p of pts) c._markupClick({ clientX: 0, clientY: 0, composedPath: () => [], ...{} , __pt: p });
|
||||
// _markupClick берёт координаты из _svgPoint(ev) — подменим напрямую через _path:
|
||||
c._path = [];
|
||||
for (const p of pts) {
|
||||
const before = c._path.length;
|
||||
// эмулируем клик готовой точкой: повторим логику через прямой вызов невозможен — построим путь вручную и замкнём
|
||||
c._path = [...c._path, p];
|
||||
if (c._path.length === before) break;
|
||||
}
|
||||
out.pointsAccepted = c._path.length === 4;
|
||||
// замыкание: проверка пересечений должна пройти (вложенность легальна)
|
||||
const clash = c._overlapRoom([...c._path]);
|
||||
out.nestedAllowed = !clash;
|
||||
// частичное перекрытие всё ещё запрещено
|
||||
const partial = [[cx - g * 4, cy], [cx + g * 2, cy], [cx + g * 2, cy + g * 2], [cx - g * 4, cy + g * 2]];
|
||||
// сдвинем так, чтобы вылезло за границу r1: возьмём точку заведомо снаружи
|
||||
const poly1 = r1.poly || [[r1.x, r1.y], [r1.x + r1.w, r1.y], [r1.x + r1.w, r1.y + r1.h], [r1.x, r1.y + r1.h]];
|
||||
const minX = Math.min(...poly1.map((p) => p[0]));
|
||||
const cross = [[minX - g * 2, cy], [cx, cy], [cx, cy + g * 2], [minX - g * 2, cy + g * 2]];
|
||||
out.partialStillRejected = !!c._overlapRoom(cross);
|
||||
// 2) сохранить остров как комнату без зоны (замкнуть контур)
|
||||
c._path = [...pts, pts[0]];
|
||||
c._nameSel = 'Колонна'; c._areaSel = '';
|
||||
c._commitRoom();
|
||||
await c.updateComplete;
|
||||
const island = c._spaceModel().rooms.find((r) => r.name === 'Колонна');
|
||||
out.islandSaved = !!island;
|
||||
// 3) внешняя комната рендерится как path с evenodd и дыркой
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
// включим границы, чтобы r1 рендерился
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
|
||||
...s, settings: { ...(s.settings || {}), show_borders: true } })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const evenodd = [...sr().querySelectorAll('path.room')].find((p) => p.getAttribute('fill-rule') === 'evenodd');
|
||||
out.evenoddPath = !!evenodd;
|
||||
out.holeInPath = evenodd ? (evenodd.getAttribute('d').match(/M /g) || []).length === 2 : null;
|
||||
// 4) остров кликабелен (элемент существует и он поверх дырки)
|
||||
const islandEl = [...sr().querySelectorAll('.room')].find((el) => {
|
||||
const d = el.getAttribute('points') || el.getAttribute('d') || '';
|
||||
return d.includes(String(cx)) && el !== evenodd;
|
||||
});
|
||||
out.islandRendered = !!islandEl;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,51 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const calls = [];
|
||||
c.hass = { ...c.hass, callService: (d, s, data) => calls.push([d, s, data.entity_id]) };
|
||||
await c.updateComplete;
|
||||
// добавить дверь с замком на f1
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({
|
||||
...s, openings: [{ id: 'op1', type: 'door', rx: 550, ry: 200, angle: 90, len_cm: 90, lock: 'lock.front_door' }] })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const op = c._spaceModel()?.openings?.[0] || { id: 'op1', type: 'door', rx: 550, ry: 200, len_cm: 90, lock: 'lock.front_door' };
|
||||
out.hasLockedDoor = !!op.lock;
|
||||
const lockId = op.lock;
|
||||
// 1) замок закрыт → кнопка "Unlock"
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, [lockId]: { state: 'locked', attributes: {} } } };
|
||||
c._openingInfo = op; await c.updateComplete;
|
||||
let btn = sr().querySelector('.btn.lockact');
|
||||
out.unlockBtnShown = !!btn && !btn.disabled;
|
||||
out.unlockBtnDanger = btn?.classList.contains('warn');
|
||||
btn.click();
|
||||
out.unlockCalled = JSON.stringify(calls.at(-1)) === JSON.stringify(['lock', 'unlock', lockId]);
|
||||
// 2) замок открыт → кнопка "Lock"
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, [lockId]: { state: 'unlocked', attributes: {} } } };
|
||||
await c.updateComplete;
|
||||
btn = sr().querySelector('.btn.lockact');
|
||||
btn.click();
|
||||
out.lockCalled = JSON.stringify(calls.at(-1)) === JSON.stringify(['lock', 'lock', lockId]);
|
||||
// 3) переходное состояние → кнопка задизейблена, сервис не зовётся
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, [lockId]: { state: 'unlocking', attributes: {} } } };
|
||||
await c.updateComplete;
|
||||
btn = sr().querySelector('.btn.lockact');
|
||||
out.pendingDisabled = !!btn && btn.disabled;
|
||||
// 4) недоступен → кнопки нет
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, [lockId]: { state: 'unavailable', attributes: {} } } };
|
||||
await c.updateComplete;
|
||||
out.noBtnWhenUnavailable = !sr().querySelector('.btn.lockact');
|
||||
// 5) тап по иконке на плане по-прежнему НЕ дёргает замок (hard block)
|
||||
const n = calls.length;
|
||||
const dev = c._devices.find((d) => d.domains?.includes('lock'));
|
||||
out.planTapStillBlocked = true;
|
||||
if (dev) {
|
||||
c._clickDevice(new MouseEvent('click'), dev); await c.updateComplete;
|
||||
out.planTapStillBlocked = !calls.slice(n).some((x) => x[0] === 'lock' && (x[1] === 'lock' || x[1] === 'unlock'));
|
||||
}
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,46 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const near = (a, b) => a && b && Math.abs(a.x - b.x) < 1e-6 && Math.abs(a.y - b.y) < 1e-6 && a.s === b.s;
|
||||
// подопытный: авто-устройство с сохранённой позицией
|
||||
const dev = c._devices.find((d) => !d.virtual && !d.marker && c._layout[d.id]);
|
||||
out.hasDev = !!dev;
|
||||
const pos0 = { ...c._layout[dev.id] };
|
||||
// 1) смена комнаты (та же space) → позиция не меняется
|
||||
const otherRoom = c._spaceModel(dev.space).rooms.find((r) => r.id && r.area && r.area !== dev.area);
|
||||
out.hasOtherRoom = !!otherRoom;
|
||||
c._openMarkerDialog(dev);
|
||||
c._markerDialog = { ...c._markerDialog, room: dev.space + '#' + otherRoom.area };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
out.stayAfterRoomChange = near(c._layout[dev.id], pos0);
|
||||
// вернуть комнату назад
|
||||
c._openMarkerDialog(c._devices.find((d) => d.id === dev.id));
|
||||
c._markerDialog = { ...c._markerDialog, room: dev.space + '#' + dev.area };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
// 2) смена привязки device -> entity (id меняется) → позиция мигрирует
|
||||
const freeEnt = Object.keys(c.hass.states).find((e) => e.startsWith('sensor.') &&
|
||||
!c._devices.some((d) => d.id === 'lg_' + e));
|
||||
out.hasFreeEnt = !!freeEnt;
|
||||
c._openMarkerDialog(c._devices.find((d) => d.id === dev.id));
|
||||
c._markerDialog = { ...c._markerDialog, binding: 'entity:' + freeEnt };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const newId = 'lg_' + freeEnt;
|
||||
out.migrated = near(c._layout[newId], pos0);
|
||||
out.oldGone = !c._layout[dev.id];
|
||||
// 3) новый виртуальный маркер по-прежнему центрируется в выбранной комнате
|
||||
c._openMarkerDialog();
|
||||
const room = c._spaceModel().rooms.find((r) => r.id && r.area);
|
||||
c._markerDialog = { ...c._markerDialog, name: 'Тест', binding: 'virtual', room: c._space + '#' + room.area };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const vid = c._serverCfg.markers.find((m) => m.name === 'Тест')?.id;
|
||||
const center = c._roomCenter(room);
|
||||
const vpos = c._layout[vid];
|
||||
const aspect = c._curSpaceCfg.aspect || 1;
|
||||
out.newCentered = vpos && Math.abs(vpos.x * 1000 - center[0]) < 1 && Math.abs(vpos.y * (1000 / aspect) - center[1]) < 1;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,24 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('plan'); c._tool = 'merge'; await c.updateComplete;
|
||||
const room = c._spaceModel().rooms.find((r) => r.name);
|
||||
c._mergeSel = room.id; c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 250)); // дождаться transition 0.12s
|
||||
const el = [...sr().querySelectorAll('.room')].find((e) => e.classList.contains('picked'));
|
||||
out.pickedRendered = !!el;
|
||||
const cs = el ? getComputedStyle(el) : null;
|
||||
out.amberStroke = cs ? cs.stroke.includes('255, 193, 77') : null;
|
||||
out.amberFill = cs ? cs.fill.includes('255, 193, 77') : null;
|
||||
// split-выбор подсвечивается так же
|
||||
c._mergeSel = null; c._tool = 'split'; c._splitSel = { roomId: room.id, pts: [] }; c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const el2 = [...sr().querySelectorAll('.room')].find((e) => e.classList.contains('picked'));
|
||||
out.splitPicked = !!el2 && getComputedStyle(el2).stroke.includes('255, 193, 77');
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,83 @@
|
||||
// Merge & split room ops (v1.21.0) via the card's markup handlers (norm coords).
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg));
|
||||
const restore = () => page.evaluate((s) => {
|
||||
const c = window.__card; c._serverCfg = JSON.parse(s);
|
||||
c._mergeSel = null; c._mergeDialog = null; c._splitSel = null; c._pendingSplit = null; c._roomDialog = false;
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); return c.updateComplete && true;
|
||||
}, snap);
|
||||
// norm→render helper mirrors what _markupClick passes to handlers
|
||||
const R = (nx, ny) => page.evaluate(([nx, ny]) => {
|
||||
const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H];
|
||||
}, [nx, ny]);
|
||||
const S = () => page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
return { rooms: c._serverCfg.spaces.find((s) => s.id==='f1').rooms.map((r)=>({id:r.id,name:r.name,area:r.area})),
|
||||
mergeDlg: !!c._mergeDialog, roomDlg: !!c._roomDialog, pendingSplit: !!c._pendingSplit, toast: c._toast };
|
||||
});
|
||||
const enter = (t) => page.evaluate((t)=>{const c=window.__card; if(!c._markup)c._setMode('plan'); c._tool=t; return true;}, t);
|
||||
const out = {};
|
||||
|
||||
// MERGE living(r1)+kitchen(r2) — share wall x=0.55, y∈[0.05..0.45]
|
||||
await enter('merge');
|
||||
await page.evaluate((p)=>window.__card._mergeClick(p), await R(0.3,0.3));
|
||||
await page.evaluate((p)=>window.__card._mergeClick(p), await R(0.75,0.25));
|
||||
out.mergeDialog = (await S()).mergeDlg;
|
||||
await page.evaluate(()=>window.__card._commitMerge());
|
||||
let s = await S();
|
||||
out.mergeRooms = s.rooms.length; // 4→3
|
||||
out.mergeKeptR1 = s.rooms.some(r=>r.id==='r1');
|
||||
out.mergeDroppedR2 = !s.rooms.some(r=>r.id==='r2');
|
||||
await restore();
|
||||
|
||||
// MERGE non-adjacent: living(r1)+garden's-only? use r2+r4 (kitchen & hallway don't share a wall)
|
||||
await enter('merge');
|
||||
await page.evaluate((p)=>window.__card._mergeClick(p), await R(0.75,0.25)); // kitchen
|
||||
await page.evaluate((p)=>window.__card._mergeClick(p), await R(0.3,0.8)); // hallway
|
||||
s = await S();
|
||||
out.nonAdjRefused = !s.mergeDlg && s.rooms.length===4;
|
||||
out.nonAdjToast = !!s.toast;
|
||||
await restore();
|
||||
|
||||
// grid-aligned test room (real rooms are snapped; demo rooms are not)
|
||||
await page.evaluate(()=>{
|
||||
const c=window.__card;
|
||||
c._serverCfg.spaces.find(s=>s.id==='f1').rooms.push(
|
||||
{id:'rg', name:'GridRoom', area:null, poly:[[0.05,0.0625],[0.5,0.0625],[0.5,0.5],[0.05,0.5]]});
|
||||
c._regSignature=''; c._maybeRebuildDevices(); c.requestUpdate();
|
||||
});
|
||||
// SPLIT rg vertical chord x=0.25 (all on grid nodes)
|
||||
await enter('split');
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.28,0.28)); // pick rg
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.0625)); // wall pt a
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.5)); // wall pt b
|
||||
out.splitPending = (await S()).pendingSplit;
|
||||
out.splitDialog = (await S()).roomDlg;
|
||||
// cancel keeps it whole
|
||||
await page.evaluate(()=>window.__card._roomDialogCancel());
|
||||
out.cancelWhole = (await S()).rooms.length===5; // 4 базовых + rg
|
||||
// redo + confirm with a name (no area)
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.28,0.28));
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.0625));
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.5));
|
||||
await page.evaluate(()=>{const c=window.__card; c._nameSel='Cabinet'; c._saveRoomNoArea();});
|
||||
s = await S();
|
||||
out.splitRooms = s.rooms.length===6; // 4 базовых + rg разрезанная надвое
|
||||
out.bigKeepsLiving = s.rooms.some(r=>r.id==='r1' && r.area==='living_room');
|
||||
out.newRoom = s.rooms.find(r=>!['r1','r2','r3','r4','rg'].includes(r.id))?.name;
|
||||
await restore();
|
||||
|
||||
// SPLIT along a wall → refused (both pts on top wall of rg)
|
||||
await page.evaluate(()=>{const c=window.__card; c._serverCfg.spaces.find(s=>s.id==='f1').rooms.push(
|
||||
{id:'rg2', name:'G2', area:null, poly:[[0.05,0.0625],[0.5,0.0625],[0.5,0.5],[0.05,0.5]]});
|
||||
c._regSignature=''; c._maybeRebuildDevices();});
|
||||
await enter('split');
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.28,0.28));
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.1,0.0625));
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.4,0.0625));
|
||||
s = await S();
|
||||
out.alongWallRefused = !s.roomDlg && !s.pendingSplit;
|
||||
|
||||
console.log(JSON.stringify(out,null,1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,79 @@
|
||||
// UX modes shell (v1.25.0): view is display-only; plan/devices gate the tools.
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = {};
|
||||
const q = (sel) => page.evaluate((s) => (window.__card.shadowRoot || window.__card.renderRoot).querySelectorAll(s).length, sel);
|
||||
const st = () => page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
return {
|
||||
mode: c._mode,
|
||||
modeTabs: sr.querySelectorAll('.modetab').length,
|
||||
active: sr.querySelector('.modetab.active')?.textContent.trim(),
|
||||
editBtns: sr.querySelectorAll('.head .btn:not(.zb)').length,
|
||||
gears: sr.querySelectorAll('.tabedit').length,
|
||||
markupBar: !!sr.querySelector('.editbar'),
|
||||
stageClass: sr.querySelector('.stage').className,
|
||||
};
|
||||
});
|
||||
// 1) старт: view, чистая шапка
|
||||
out.start = await st();
|
||||
// 2) drag в view ничего не двигает и не мешает пану
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const d = c._devices.find((x) => x.space === 'f1');
|
||||
const before = { ...c._pos(d) };
|
||||
c._pointerDown({ preventDefault(){}, clientX: 10, clientY: 10, target: { setPointerCapture(){} }, pointerId: 1 }, d);
|
||||
c._pointerMove({ clientX: 90, clientY: 60 }, d);
|
||||
c._pointerUp({}, d);
|
||||
const after = { ...c._pos(d) };
|
||||
window.__viewDragMoved = Math.abs(after.x - before.x) + Math.abs(after.y - before.y) > 0.5;
|
||||
clearTimeout(c._holdTimer);
|
||||
});
|
||||
out.viewDragMoved = await page.evaluate(() => window.__viewDragMoved);
|
||||
// long-press в view открывает инфо
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const d = c._devices.find((x) => x.space === 'f1');
|
||||
c._pointerDown({ preventDefault(){}, clientX: 10, clientY: 10, target: { setPointerCapture(){} }, pointerId: 2 }, d);
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
});
|
||||
out.viewHoldInfo = await page.evaluate(() => { const r = !!window.__card._infoCard; window.__card._infoCard = null; window.__card._holdFired = false; return r; });
|
||||
// 3) режим План
|
||||
await page.evaluate(() => window.__card._setMode('plan'));
|
||||
await page.waitForTimeout(200);
|
||||
out.plan = await st();
|
||||
out.planIconsHidden = await page.evaluate(() => {
|
||||
const sr = window.__card.shadowRoot || window.__card.renderRoot;
|
||||
const dev = sr.querySelector('.dev');
|
||||
return dev ? getComputedStyle(dev).display === 'none' : 'no-dev';
|
||||
});
|
||||
// 4) режим Устройства: drag работает, клик открывает редактор
|
||||
await page.evaluate(() => window.__card._setMode('devices'));
|
||||
await page.waitForTimeout(200);
|
||||
out.devices = await st();
|
||||
out.devDragWorks = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const d = c._devices.find((x) => x.space === 'f1');
|
||||
const before = { ...c._pos(d) };
|
||||
c._pointerDown({ preventDefault(){}, clientX: 10, clientY: 10, target: { setPointerCapture(){} }, pointerId: 3 }, d);
|
||||
c._pointerMove({ clientX: 100, clientY: 70 }, d);
|
||||
c._pointerUp({}, d);
|
||||
const after = { ...c._pos(d) };
|
||||
return Math.abs(after.x - before.x) + Math.abs(after.y - before.y) > 0.5;
|
||||
});
|
||||
out.devClickOpensEditor = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._drag = null;
|
||||
const d = c._devices.find((x) => x.space === 'f1');
|
||||
c._clickDevice({ stopPropagation(){} }, d);
|
||||
await c.updateComplete;
|
||||
const r = !!c._markerDialog;
|
||||
c._markerDialog = null;
|
||||
return r;
|
||||
});
|
||||
// 5) назад в view
|
||||
await page.evaluate(() => window.__card._setMode('view'));
|
||||
out.backToView = (await st()).mode;
|
||||
console.log(JSON.stringify(out, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,32 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// 1) первый запуск: базлайн засеян молча, точек нет
|
||||
out.baselineSeeded = Array.isArray(c._serverCfg.settings.known_devices) && c._serverCfg.settings.known_devices.length > 0;
|
||||
out.noDotsOnFirstRun = sr().querySelectorAll('.newdot').length === 0;
|
||||
// 2) в HA появилось новое устройство
|
||||
const h = { ...c.hass };
|
||||
h.devices = { ...h.devices, d_new: { id: 'd_new', name: 'Brand new plug', model: 'Smart Plug', area_id: 'kitchen', identifiers: [['demo', 'd_new']] } };
|
||||
h.entities = { ...h.entities, 'switch.newplug': { entity_id: 'switch.newplug', device_id: 'd_new', platform: 'demo' } };
|
||||
h.states = { ...h.states, 'switch.newplug': { state: 'off', attributes: { friendly_name: 'Brand new plug' } } };
|
||||
c.hass = h; await c.updateComplete; c.requestUpdate(); await c.updateComplete;
|
||||
out.flagged = c._serverCfg.settings.new_device_ids?.includes('d_new');
|
||||
out.dotShown = sr().querySelectorAll('.newdot').length === 1;
|
||||
// 3) существующие не флагуются
|
||||
out.onlyOne = (c._serverCfg.settings.new_device_ids || []).length === 1;
|
||||
// 4) открытие редактора снимает флаг
|
||||
const dev = c._devices.find((d) => d.id === 'd_new');
|
||||
c._setMode('devices');
|
||||
c._openMarkerDialog(dev); await c.updateComplete;
|
||||
c._markerDialog = null; c._setMode('view'); await c.updateComplete;
|
||||
out.ackCleared = !(c._serverCfg.settings.new_device_ids || []).includes('d_new');
|
||||
out.dotGone = sr().querySelectorAll('.newdot').length === 0;
|
||||
// 5) known растёт, повторно не флагуется
|
||||
out.stillKnown = c._serverCfg.settings.known_devices.includes('d_new');
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,43 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('plan'); c._tool = 'opening'; await c.updateComplete;
|
||||
const rooms = c._spaceModel().rooms;
|
||||
const r = rooms.find((x) => x.id === 'r1');
|
||||
const poly = r.poly || [[r.x, r.y], [r.x + r.w, r.y], [r.x + r.w, r.y + r.h], [r.x, r.y + r.h]];
|
||||
const wallMid = [(poly[0][0] + poly[1][0]) / 2, (poly[0][1] + poly[1][1]) / 2];
|
||||
// 1) курсор возле стены → пунктирный призрак есть
|
||||
c._cursorPt = [wallMid[0], wallMid[1] + c._gridPitch * 0.5]; c.requestUpdate(); await c.updateComplete;
|
||||
const ghost = sr().querySelector('.opghost');
|
||||
out.ghostNearWall = !!ghost;
|
||||
if (ghost) {
|
||||
const cs = getComputedStyle(ghost);
|
||||
out.dashed = cs.strokeDasharray !== 'none' && cs.strokeDasharray !== '';
|
||||
// призрак лежит на стене (y координаты концов совпадают с y стены)
|
||||
out.onWall = Math.abs(Number(ghost.getAttribute('y1')) - poly[0][1]) < 0.5
|
||||
&& Math.abs(Number(ghost.getAttribute('y2')) - poly[0][1]) < 0.5;
|
||||
// длина = 90 см в рендер-единицах
|
||||
const len = Math.hypot(ghost.getAttribute('x2') - ghost.getAttribute('x1'), ghost.getAttribute('y2') - ghost.getAttribute('y1'));
|
||||
out.len90cm = Math.abs(len - c._cmToUnits(90)) < 0.5;
|
||||
}
|
||||
// 2) курсор далеко от стен → призрака нет
|
||||
const center = c._roomCenter(r);
|
||||
c._cursorPt = center; c.requestUpdate(); await c.updateComplete;
|
||||
out.noGhostFarFromWall = !sr().querySelector('.opghost');
|
||||
// 3) в других инструментах призрака нет
|
||||
c._tool = 'draw'; c._cursorPt = [wallMid[0], wallMid[1] + 2]; c.requestUpdate(); await c.updateComplete;
|
||||
out.noGhostOtherTool = !sr().querySelector('.opghost');
|
||||
// 4) над существующим проёмом призрак не рисуется (там клик = редактирование)
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
|
||||
...s, openings: [{ id: 'op9', type: 'door', x: wallMid[0] / 1000, y: wallMid[1] / (1000 / (c._curSpaceCfg.aspect || 1)) * (c._curSpaceCfg.aspect ? 1 : 1), angle: 0, length: 0.09 }] })) };
|
||||
c._tool = 'opening'; c.requestUpdate(); await c.updateComplete;
|
||||
const op = c._openingsR[0];
|
||||
c._cursorPt = [op.rx, op.ry]; c.requestUpdate(); await c.updateComplete;
|
||||
out.noGhostOverExisting = !sr().querySelector('.opghost');
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,32 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// RGB-лампа: ceiling on + rgb_color
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'light.ceiling': { entity_id: 'light.ceiling', state: 'on', attributes: { friendly_name: 'Ceiling light', rgb_color: [255, 0, 128] } } } };
|
||||
await c.updateComplete;
|
||||
const rgbDev = sr().querySelector('.dev.rgb');
|
||||
out.rgbClass = !!rgbDev;
|
||||
out.rgbVar = rgbDev?.getAttribute('style')?.includes('--light-color:rgb(255, 0, 128)');
|
||||
// тревога: датчик протечки on
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'binary_sensor.sink_leak': { entity_id: 'binary_sensor.sink_leak', state: 'on', attributes: { friendly_name: 'Leak', device_class: 'moisture' } } } };
|
||||
await c.updateComplete;
|
||||
out.alarmCount = sr().querySelectorAll('.dev.alarm').length;
|
||||
// выключили — тревога ушла
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'binary_sensor.sink_leak': { entity_id: 'binary_sensor.sink_leak', state: 'off', attributes: { friendly_name: 'Leak', device_class: 'moisture' } } } };
|
||||
await c.updateComplete;
|
||||
out.alarmCleared = sr().querySelectorAll('.dev.alarm').length === 0;
|
||||
// unavailable не тревожит
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'binary_sensor.sink_leak': { entity_id: 'binary_sensor.sink_leak', state: 'unavailable', attributes: { device_class: 'moisture' } } } };
|
||||
await c.updateComplete;
|
||||
out.outageSafe = sr().querySelectorAll('.dev.alarm').length === 0;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,61 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// включить имена и все метрики у текущего пространства
|
||||
const spId = c._space;
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||||
...s, settings: { ...(s.settings || {}), show_names: true,
|
||||
label_temp: true, label_hum: true, label_lqi: true, label_light: true } })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const labels = [...sr().querySelectorAll('.roomlabel')];
|
||||
out.labels = labels.length;
|
||||
const cards = labels.filter((l) => l.classList.contains('card'));
|
||||
out.cardsWithMetrics = cards.length;
|
||||
out.hasName = !!labels[0]?.querySelector('.rlname');
|
||||
const metrics = cards[0] ? [...cards[0].querySelectorAll('.rlm')].map((m) => m.textContent.trim()) : [];
|
||||
out.sampleMetrics = metrics;
|
||||
// "N из M": включить одну из ламп зоны первой карточки
|
||||
const someLightRoom = c._spaceModel().rooms.find((r) => r.area && c._devices.some((d) => d.area === r.area && d.entities.some((e) => e.startsWith('light.'))));
|
||||
out.partialTested = false;
|
||||
if (someLightRoom) {
|
||||
const lights = [...new Set(c._devices.filter((d) => d.area === someLightRoom.area).flatMap((d) => d.entities).filter((e) => e.startsWith('light.')))];
|
||||
if (lights.length >= 2) {
|
||||
const st = { ...c.hass.states };
|
||||
lights.forEach((e, i) => { st[e] = { ...st[e], state: i === 0 ? 'on' : 'off' }; });
|
||||
c.hass = { ...c.hass, states: st }; await c.updateComplete;
|
||||
const lbl = [...sr().querySelectorAll('.roomlabel.card')].find((l) => l.textContent.includes(someLightRoom.name));
|
||||
out.partialText = lbl?.querySelector('.rlm.lit')?.textContent.trim();
|
||||
out.partialTested = /1\D+' + lights.length + '/.test(out.partialText || '') || (out.partialText || '').includes('1');
|
||||
}
|
||||
}
|
||||
// метки resize: в Просмотре нет, в Плане при наведении есть (проверим наличие в DOM)
|
||||
out.noHandlesInView = sr().querySelectorAll('.rlhandle').length === 0;
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
const planLbl = sr().querySelector('.roomlabel');
|
||||
out.handlesInPlan = planLbl?.querySelectorAll('.rlhandle').length === 4;
|
||||
// метрики скрыты в редакторе плана (карточка = имя, чтобы не мешать разметке)
|
||||
out.plainInPlan = sr().querySelectorAll('.roomlabel.card').length === 0;
|
||||
// масштаб: сымитируем resize через прямой вызов
|
||||
const room = c._spaceModel().rooms.find((r) => r.name);
|
||||
c._rlResize = { id: 'rl_' + room.id, space: spId, k0: 1, cx: 100, cy: 100, d0: 50 };
|
||||
c._rlResizeMove({ clientX: 200, clientY: 100, stopPropagation() {} });
|
||||
c._rlResizeUp();
|
||||
await c.updateComplete;
|
||||
out.scaleSaved = Math.abs((c._layout['rl_' + room.id]?.k || 0) - 2) < 0.01;
|
||||
const lblEl = sr().querySelector('.roomlabel');
|
||||
out.scaleApplied = getComputedStyle(lblEl).getPropertyValue('--rl-scale').trim() === '2';
|
||||
// клампы
|
||||
c._rlResize = { id: 'rl_' + room.id, space: spId, k0: 2, cx: 100, cy: 100, d0: 50 };
|
||||
c._rlResizeMove({ clientX: 1000, clientY: 100, stopPropagation() {} });
|
||||
c._rlResizeUp();
|
||||
out.clamped = c._layout['rl_' + room.id].k <= 3;
|
||||
// drag сохраняет масштаб
|
||||
c._savePos({ id: 'rl_' + room.id, space: spId }, 300, 300);
|
||||
out.dragKeepsScale = c._layout['rl_' + room.id].k <= 3 && c._layout['rl_' + room.id].k >= 2.9;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,53 @@
|
||||
// Smoke: houseplan-space-card renders a static, non-interactive schematic + deep-link button.
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const hass = window.__card.hass;
|
||||
const spaceId = window.__card._model[0].id;
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
||||
const mk = (cfg) => { const el = document.createElement('houseplan-space-card'); el.setConfig(cfg); el.hass = hass; host.appendChild(el); return el; };
|
||||
const card = mk({ type: 'custom:houseplan-space-card', space: spaceId, button_target: '/plan-doma' });
|
||||
// wait for the static stage to render (config loads via WS)
|
||||
const t0 = Date.now();
|
||||
while (!card.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) { await new Promise(r => setTimeout(r, 80)); }
|
||||
await card.updateComplete;
|
||||
|
||||
const stage = card.renderRoot.querySelector('.hp-static-stage');
|
||||
const pe = stage ? getComputedStyle(stage).pointerEvents : null;
|
||||
const markers = card.renderRoot.querySelectorAll('.hp-static-stage .devlayer .dev').length;
|
||||
const btn = card.renderRoot.querySelector('.hp-static-btn');
|
||||
|
||||
// deep-link: clicking the button pushes #space=<id>
|
||||
let pushed = null;
|
||||
const orig = history.pushState;
|
||||
history.pushState = function (a, b, url) { pushed = url; return orig.apply(this, arguments); };
|
||||
btn?.click();
|
||||
history.pushState = orig;
|
||||
|
||||
// error card for an unknown space
|
||||
const bad = mk({ type: 'custom:houseplan-space-card', space: '__nope__' });
|
||||
await bad.updateComplete;
|
||||
const errCard = bad.renderRoot.querySelector('.hp-static-error');
|
||||
|
||||
return {
|
||||
stagePointerEvents: pe,
|
||||
markers,
|
||||
hasButton: !!btn,
|
||||
deepLink: pushed,
|
||||
errorShown: !!errCard,
|
||||
errorText: errCard?.textContent?.trim() || null,
|
||||
};
|
||||
});
|
||||
await browser.close();
|
||||
const ok =
|
||||
res.stagePointerEvents === 'none' &&
|
||||
res.markers > 0 &&
|
||||
res.hasButton &&
|
||||
typeof res.deepLink === 'string' && res.deepLink.includes('#space=') &&
|
||||
res.errorShown;
|
||||
console.log(JSON.stringify(res));
|
||||
if (!ok) { console.error('FAIL space-card smoke'); process.exit(1); }
|
||||
console.log('OK space-card: static (pointer-events:none), markers rendered, deep-link button, error card');
|
||||
@@ -0,0 +1,21 @@
|
||||
// Split now works on a non-grid-aligned room (imported/legacy polygons).
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const R = (nx, ny) => page.evaluate(([nx, ny]) => {
|
||||
const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H];
|
||||
}, [nx, ny]);
|
||||
const out = {};
|
||||
await page.evaluate(()=>{const c=window.__card; if(!c._markup)c._setMode('plan'); c._tool='split';});
|
||||
// living room (r1) has walls at y=0.05 which are NOT grid nodes; click near the wall
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // pick living
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.052)); // near top wall (off grid)
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.58)); // near bottom wall
|
||||
out.pending = await page.evaluate(()=>!!window.__card._pendingSplit);
|
||||
out.dialog = await page.evaluate(()=>!!window.__card._roomDialog);
|
||||
// accidental click in the middle of the room must NOT become a wall point
|
||||
await page.evaluate(()=>{const c=window.__card; c._roomDialog=false; c._pendingSplit=null; c._splitSel=null;});
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // pick again
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // centre click = miss
|
||||
out.centreRefused = await page.evaluate(()=>window.__card._splitSel?.a == null);
|
||||
console.log(JSON.stringify(out));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,55 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const stage = () => sr().querySelector('.stage');
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
// 1) курсоры: split до выбора комнаты — pointer, после — crosshair; merge — pointer
|
||||
c._tool = 'split'; c.requestUpdate(); await c.updateComplete;
|
||||
out.splitPickCursor = getComputedStyle(stage()).cursor === 'pointer';
|
||||
const rooms = c._spaceModel().rooms;
|
||||
const room = rooms.find((r) => r.id === 'r1');
|
||||
const center = c._roomCenter(room);
|
||||
c._splitClick(center); await c.updateComplete;
|
||||
out.roomPicked = c._splitSel?.roomId === 'r1';
|
||||
out.splitCutCursor = getComputedStyle(stage()).cursor === 'crosshair';
|
||||
c._tool = 'merge'; c._splitSel = null; c.requestUpdate(); await c.updateComplete;
|
||||
out.mergeCursor = getComputedStyle(stage()).cursor === 'pointer';
|
||||
// 2) полилинейный разрез: r1 = прямоугольник? возьмём его poly и построим Г-разрез
|
||||
c._tool = 'split'; await c.updateComplete;
|
||||
c._splitClick(center);
|
||||
const poly = (() => { const r = rooms.find((x) => x.id === 'r1');
|
||||
return r.poly ? r.poly : [[r.x, r.y], [r.x + r.w, r.y], [r.x + r.w, r.y + r.h], [r.x, r.y + r.h]]; })();
|
||||
// старт на середине верхней стены, промежуточная внутри, конец на правой стене
|
||||
const [A, B, C2, D] = [poly[0], poly[1], poly[2], poly[3]];
|
||||
const top = [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
|
||||
const right = [(B[0] + C2[0]) / 2, (B[1] + C2[1]) / 2];
|
||||
const mid = [(top[0] + right[0]) / 2 - 20, (top[1] + right[1]) / 2 + 20];
|
||||
c._splitClick(top);
|
||||
out.firstOnWall = c._splitSel?.pts.length === 1;
|
||||
c._splitClick(mid);
|
||||
out.midAdded = c._splitSel?.pts.length === 2;
|
||||
c._splitClick(right);
|
||||
out.dialogAfterCut = !!c._pendingSplit && c._roomDialog;
|
||||
out.partsPolys = c._pendingSplit ? [c._pendingSplit.mainPoly.length, c._pendingSplit.newPoly.length] : null;
|
||||
// отмена — комната целая
|
||||
c._roomDialogCancel(); await c.updateComplete;
|
||||
out.cancelKeepsRoom = !c._pendingSplit && rooms.length === c._spaceModel().rooms.length;
|
||||
// 3) Esc: пошаговый выход
|
||||
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
|
||||
c._tool = 'split'; c._splitSel = null;
|
||||
c._splitClick(center); c._splitClick(top); c._splitClick(mid); await c.updateComplete;
|
||||
await esc(); out.escDropsPoint = c._splitSel?.pts.length === 1;
|
||||
await esc(); out.escDropsFirst = c._splitSel?.pts.length === 0;
|
||||
await esc(); out.escDropsRoom = c._splitSel === null && c._tool === 'split';
|
||||
await esc(); out.escExitsTool = c._tool === 'draw';
|
||||
// merge: выбор → Esc снимает, ещё Esc — выход
|
||||
c._tool = 'merge'; c._mergeSel = 'r1'; await c.updateComplete;
|
||||
await esc(); out.escClearsMerge = c._mergeSel === null && c._tool === 'merge';
|
||||
await esc(); out.escExitsMerge = c._tool === 'draw';
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,34 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const devIcon = (id) => {
|
||||
const idx = c._devices.filter((x) => x.space === c._space).findIndex((x) => x.id === id);
|
||||
const el = sr().querySelectorAll('.dev')[idx];
|
||||
return el?.querySelector('ha-icon')?.getAttribute('icon');
|
||||
};
|
||||
// 1) state-иконки: замок locked → mdi:lock; unlocked → lock-open-variant
|
||||
out.lockLocked = devIcon('d_lock');
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'lock.front_door': { ...c.hass.states['lock.front_door'], state: 'unlocked' } } };
|
||||
await c.updateComplete;
|
||||
out.lockUnlocked = devIcon('d_lock');
|
||||
// окно: on → window-open
|
||||
out.windowOpen = devIcon('d_window');
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'binary_sensor.window': { ...c.hass.states['binary_sensor.window'], state: 'off' } } };
|
||||
await c.updateComplete;
|
||||
out.windowClosed = devIcon('d_window');
|
||||
// лампа on → lightbulb-on
|
||||
out.bulbOn = devIcon('d_light1');
|
||||
// 2) display:value у термометра
|
||||
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'd_temp', binding: 'device:d_temp', display: 'value' }] };
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
const vd = [...sr().querySelectorAll('.dev.valonly')];
|
||||
out.valonly = vd.length;
|
||||
out.valText = vd[0]?.querySelector('.valtext')?.textContent.trim();
|
||||
out.noSmallBadge = !vd[0]?.querySelector('.tval');
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,35 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
// комната без зоны на f1
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({
|
||||
...s, rooms: [...s.rooms, { id: 'rc', name: 'Cupboard', area: null, poly: [[0.05,0.62],[0.2,0.62],[0.2,0.9],[0.05,0.9]] }],
|
||||
})) };
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
|
||||
// список комнат в диалоге содержит подзону
|
||||
c._setMode('devices');
|
||||
c._openMarkerDialog(); await c.updateComplete;
|
||||
const rooms = c._allRoomsFlat();
|
||||
out.subareaListed = rooms.some((r) => r.value === 'f1#@rc');
|
||||
// создать виртуальный маркер в подзону
|
||||
c._markerDialog = { ...c._markerDialog, name: 'Vacuum dock', binding: 'virtual', room: 'f1#@rc' };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const m = c._serverCfg.markers.find((x) => x.name === 'Vacuum dock');
|
||||
out.markerRoomId = m?.room_id;
|
||||
out.markerAreaNull = m ? m.area === null : null;
|
||||
// позиция — в центре подзоны (норм. центр rc: x=0.125, y=0.76)
|
||||
const pos = c._layout[m.id];
|
||||
out.posInRoom = pos && Math.abs(pos.x - 0.125) < 0.03 && Math.abs(pos.y - 0.76) < 0.03;
|
||||
// девайс отрисован
|
||||
const dev = c._devices.find((d) => d.id === m.id);
|
||||
out.deviceBuilt = !!dev && dev.space === 'f1';
|
||||
// reopen диалога восстанавливает выбор
|
||||
c._openMarkerDialog(dev);
|
||||
out.reopenRoom = c._markerDialog?.room;
|
||||
c._markerDialog = null;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
+918
-118
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+916
-116
File diff suppressed because one or more lines are too long
+84
-3
@@ -86,6 +86,15 @@ Built from the registries (`_buildDevices`), rules carried over 1-to-1 from the
|
||||
|
||||
## Device markers (v1.6.0+)
|
||||
|
||||
Per-marker appearance (v1.22.0): `display: badge|ripple|icon_ripple` (+`ripple_color`,
|
||||
`ripple_size`) draws presence-style pulsing rings gated by the pure `isActiveState` —
|
||||
deliberately independent of the card-wide `live_states` toggle, with `unavailable` counting as
|
||||
idle. `size` (icon multiplier via the `--dev-size` CSS var — value badges scale along) and
|
||||
`angle` rotate/scale a single icon. Room drawing shows a live **ruler** (`segmentCm` +
|
||||
`formatLength`, metres or feet+inches by `hass.config.unit_system`); the scale is per-space
|
||||
`cell_cm` (default 5 cm per grid cell).
|
||||
|
||||
|
||||
`config.markers[]`: `{id, binding:'device:<id>'|'entity:<eid>'|'virtual', space?, area?, hidden?,
|
||||
name?, icon?, model?, link?, description?, pdfs:[{name,url}]}`. A hybrid: auto-discovered HA devices
|
||||
appear on their own; a marker with `binding=device:<id>` overrides them (metadata/rebinding/hiding),
|
||||
@@ -109,14 +118,56 @@ Plan files: `<config>/houseplan/plans/<space>.<ext>` → URL `/houseplan_files/p
|
||||
If the server config is empty, the card falls back to the legacy bundle (the dacha) and shows a
|
||||
"To server" migration button in edit mode. The dacha was migrated on 2026-07-04.
|
||||
|
||||
## Room geometry rules (v1.19–v1.21)
|
||||
|
||||
A **line is never an entity of its own**: walls are *derived* from room outlines
|
||||
(`roomEdges`, deduped by `segKey`), so an abandoned outline leaves nothing behind and deleting
|
||||
a room keeps the walls its neighbours still contribute. Rooms may not overlap
|
||||
(`pointStrictlyInside` + `roomsOverlap`; being ON a shared wall is legal — real neighbouring
|
||||
walls overlap collinearly rather than match exactly). **Merge/Split** use boolean geometry from
|
||||
**polyclip-ts** (chosen over `polygon-clipping`, whose ESM build exports only a default while
|
||||
its types declare named exports — breaking either tsc or the runtime): merge accepts a pair only
|
||||
when the union collapses into one hole-free outline; split cuts wall-to-wall with a chord, the
|
||||
bigger part keeps the room identity (name/area/devices).
|
||||
|
||||
## Markup editor (v1.4.0+)
|
||||
|
||||
State inside the card: `_markup` (mode), `_tool` (draw/erase/delroom), `_path` (the current outline,
|
||||
vertices on the GRID_N=60 grid). Clicks on the stage → `_svgPoint`→`_snap`. Each pair of points adds a
|
||||
segment to `space.segments` (dedup by key, saved via config/set with debounce). The outline is closed
|
||||
State inside the card: `_markup` (mode), `_tool` (draw/delroom), `_path` (the current outline,
|
||||
vertices on the GRID_N=240 grid). Clicks on the stage → `_svgPoint`→`_snap`. The outline is closed
|
||||
= a click on the first vertex → area select (hass.areas) + name → room {poly}. Polygon rooms and
|
||||
rectangles are rendered uniformly (hit-test: point-in-polygon / rect).
|
||||
|
||||
**A line is never an entity of its own (v1.19.0).** Nothing is persisted while you draw: an
|
||||
outline you never close leaves no trace. Walls are *derived* from the room outlines by
|
||||
`roomEdges(rooms)` (logic.ts) and deduped by `segKey`, so a wall shared by two rooms is emitted
|
||||
once — deleting a room therefore keeps the borders its neighbours still contribute and drops the
|
||||
rest, with no bookkeeping. The legacy `space.segments` array is stripped on every save (validation
|
||||
still tolerates it on read; see CHANGELOG v1.19.0).
|
||||
|
||||
While drawing, the length of the current segment follows the cursor (`_fmtLen` → `segmentCm`/
|
||||
`formatLength`): metres, or feet+inches when `hass.config.unit_system` is imperial. The scale is
|
||||
per-space `cell_cm` — cm represented by one grid cell (default 5, so 240 cells ≈ 12 m).
|
||||
|
||||
## Doors & windows (v1.23.0+)
|
||||
|
||||
`space.openings[]` — plan geometry, **not** markers: an opening needs an angle, a length and a
|
||||
wall, while markers are free points whose positions live in the layout store. Model:
|
||||
`{id, type: door|window, x, y, angle, length, contact?, lock?, invert?, flip_h?, flip_v?}`
|
||||
(normalized coords; `length` normalized by plan width). Placement snaps onto the nearest
|
||||
**derived** wall via `snapToWall` (logic.ts) — the angle is normalized to [-90, 90) because two
|
||||
rooms share a wall with opposite edge directions, and without that a drag across segment
|
||||
boundaries flips the hinge. The opening then keeps **absolute coordinates**, so editing, merging
|
||||
or deleting rooms never breaks it.
|
||||
|
||||
Rendering (after easy-floorplan, MIT): SVG symbol at the origin (jambs + hinged leaf + a
|
||||
quarter-circle arc revealed via `stroke-dashoffset`), translated/rotated onto the wall; windows
|
||||
are two casement leaves. `openingAmount` (pure) maps the contact state to 0..1: no sensor →
|
||||
door drawn open / window closed (static-plan convention); `unavailable`/`unknown` freeze that
|
||||
default. The lock renders as an HTML padlock badge (`.oplock`) in the device layer; a lock is
|
||||
**never** toggled from the plan (TOGGLE_FORBIDDEN_DOMAINS rule). View-mode UX: hover outline,
|
||||
drag along walls (continuous re-snap, saved on release), click → status card (250 ms timer),
|
||||
double click → properties dialog. In markup mode the "Opening" tool handles clicks instead.
|
||||
|
||||
## Integration WS API
|
||||
|
||||
| Command | Parameters | Response |
|
||||
@@ -131,3 +182,33 @@ rectangles are rendered uniformly (hit-test: point-in-polygon / rect).
|
||||
|
||||
**File uploads go over HTTP** (not WS, which has a message-size limit): `POST /api/houseplan/upload`
|
||||
(multipart: marker_id + file), HomeAssistantView, requires_auth. Served from `/houseplan_files/files/`.
|
||||
|
||||
|
||||
## Second card: houseplan-space-card (read-only, v1.16.0)
|
||||
|
||||
The bundle registers **two** custom elements from one entry (`src/houseplan-card.ts`
|
||||
imports `./space-card`):
|
||||
|
||||
- `houseplan-card` — the full interactive card.
|
||||
- `houseplan-space-card` — a static, read-only schematic of ONE space for embedding.
|
||||
|
||||
Shared, framework-light modules keep the two views from diverging:
|
||||
|
||||
- `src/space-geometry.ts` — pure model/position math (`spaceModels`, `roomBounds`,
|
||||
`roomCenter`, `defaultPositions`, `markerPos`, `labelPos`; no Lit import) — unit-tested,
|
||||
mirrors the full card's private geometry.
|
||||
- `src/space-render.ts` — `renderSpaceStatic()` draws the plan + configured room
|
||||
borders/names + device markers (via `buildDevices`, same curation) with NO handlers,
|
||||
NO live states, NO status/temperature fills. Uses the same CSS classes as the full card
|
||||
(the space-card imports `cardStyles`) for visual parity.
|
||||
- `src/config-store.ts` — module-level `{config, rev, layout}` cache shared by all embedded
|
||||
cards (dedupes `houseplan/config/get`), seeded synchronously from the full card's
|
||||
localStorage snapshot (`houseplan_card_cfg_v1`) and invalidated on `houseplan_config_updated`.
|
||||
|
||||
**Static contract:** the schematic layer (`.hp-static-stage`) is `pointer-events:none`; the
|
||||
footer button lives outside it and stays clickable.
|
||||
|
||||
**Deep-link contract:** the footer button calls `navigate(button_target + "#space=<id>")`
|
||||
(default target `/plan-doma`). The full card reads `#space=<id>` on load (a valid id wins over
|
||||
`default_floor`) and on `hashchange`, without blocking manual space switching; an invalid/absent
|
||||
hash falls back to the default.
|
||||
|
||||
@@ -1,5 +1,444 @@
|
||||
# Changelog
|
||||
|
||||
## v1.34.0 — 2026-07-22 (island rooms)
|
||||
- **Nested rooms are now legal**: draw a contour fully inside an existing room
|
||||
(or around one) — a column in a ring-shaped room, an inner room, a wardrobe
|
||||
island. The parent room's fill is rendered with an evenodd hole, so a ring
|
||||
paints as a ring; the island itself stays clickable and can carry its own
|
||||
area, fill and devices. Partial overlaps and duplicate outlines are still
|
||||
rejected (`roomsOverlap` reworked; `polyContainsPoly`/`islandsOf` helpers,
|
||||
unit-tested). The per-click "point inside a room" rejection is gone —
|
||||
validation happens once, when the outline closes.
|
||||
|
||||
## v1.33.5 — 2026-07-22
|
||||
- Editor tabs got extended tooltips explaining what each editor is for (plan
|
||||
geometry vs device icons vs visual decor).
|
||||
|
||||
## v1.33.4 — 2026-07-22
|
||||
- Editing a device no longer makes its icon jump. Changing the HA binding
|
||||
(which changes the marker id) migrates the saved position to the new id;
|
||||
changing the room within the same space keeps the icon exactly where it
|
||||
stands (previously it re-centered in the new room). Only a brand-new icon,
|
||||
or a move to a room in a different space, is centered.
|
||||
|
||||
## v1.33.3 — 2026-07-22
|
||||
- Device dialog: when no icon is set explicitly, the icon picker no longer
|
||||
looks empty — it shows the **auto-derived icon** (from the icon rules /
|
||||
device class) as a placeholder, with an "Auto: mdi:…" hint line underneath.
|
||||
Picking an explicit icon replaces it as before; clearing returns to auto.
|
||||
|
||||
## v1.33.2 — 2026-07-22
|
||||
- Removed the **Reset** button from the Device editor. It wiped the entire
|
||||
layout — positions of all devices, room cards and their scales across every
|
||||
space — behind a single confirm. Low value, high blast radius.
|
||||
|
||||
## v1.33.1 — 2026-07-22
|
||||
- The dot grid is now shown in **every editor** (Plan, Devices, Background),
|
||||
not just Plan — an instant visual cue that you are editing.
|
||||
- In the Background editor, rooms, devices, openings and labels fade to 35%
|
||||
opacity so the decor you are drawing stands out; decor itself stays fully
|
||||
opaque. Other modes are unaffected.
|
||||
|
||||
## v1.33.0 — 2026-07-22 (background editor)
|
||||
- New third mode: **Background editor**. Draw purely visual decor on the plan —
|
||||
lines, rectangles, ovals and text labels that never interact with rooms,
|
||||
devices or fills. Shapes are drag-drawn with grid snap and a live preview;
|
||||
text is placed via a small dialog (size S/M/L, color; double-click to edit).
|
||||
Toolbar: Select (move, Delete key), Erase, color, three line widths and an
|
||||
optional 25% fill for rects/ovals. Esc walks back: draft → selection →
|
||||
Select tool → View.
|
||||
- The decor layer renders **under rooms** (a true underlay), is visible in all
|
||||
modes and completely click-transparent outside the editor. Stored per space
|
||||
in the server config (`space.decor`, validated on the backend, shared across
|
||||
clients with the usual rev/optimistic locking).
|
||||
|
||||
## v1.32.1 — 2026-07-22
|
||||
- Opening tool: hovering near a wall now shows a **dashed preview** of where
|
||||
the opening would land — snapped onto the wall, default door length (90 cm),
|
||||
with a center dot. No preview far from walls or over an existing opening
|
||||
(a click there edits it instead).
|
||||
|
||||
## v1.32.0 — 2026-07-22 (split polyline, tool cursors, Esc)
|
||||
- **Split can now cut along a polyline**, not just a straight chord: start on
|
||||
a wall, click intermediate points inside the room, finish on another wall.
|
||||
The path is validated (no wall crossings, no self-intersection) and drawn
|
||||
live with vertices and a preview segment. Two clicks still work as before.
|
||||
- **Tool cursors**: Merge and delete-room show a pointer; Split shows a
|
||||
pointer while picking the room, then a crosshair while cutting.
|
||||
- **Esc walks back out of Merge/Split** step by step: last cut point → first
|
||||
point → room selection → back to the Draw tool. Merge: selection → tool.
|
||||
|
||||
## v1.31.2 — 2026-07-22
|
||||
- Plan editor: the room picked with the **Merge** tool (and the room selected
|
||||
for **Split**) is highlighted amber again. The `.outlined` markup style,
|
||||
added later in the stylesheet, was silently overriding the `.picked`
|
||||
highlight at equal specificity (source-order gotcha #4 — rule order fixed
|
||||
and documented in the stylesheet).
|
||||
|
||||
## v1.31.1 — 2026-07-22
|
||||
- Plan editor: interacting with a room card (drag, corner-resize or a plain
|
||||
click) no longer leaks into the active markup tool — previously the click
|
||||
after a resize could add an outline point, pick a merge/split room or even
|
||||
prompt to delete the room under the card.
|
||||
|
||||
## v1.31.0 — 2026-07-22 (room cards)
|
||||
- Room labels grew into **room cards**: the name on top, and an optional
|
||||
smaller metrics line below — temperature, humidity, average Zigbee signal
|
||||
and lights, each behind its own checkbox in the space settings (all off by
|
||||
default). Lights show On/Off, or **"1 of 3"** when only part of the room is
|
||||
lit. Rooms without an HA area keep showing just the name.
|
||||
- Cards are **resizable** in the Plan editor: hovering shows corner handles;
|
||||
dragging one scales the whole card uniformly (0.5×–3×). The scale is stored
|
||||
in the layout next to the card position, and dragging keeps it.
|
||||
- Fixed a latent v1.25 regression: room labels were not rendered as draggable
|
||||
HTML in the Plan editor at all (only a static SVG name), so moving them was
|
||||
impossible. The Plan editor now renders real cards (name only) that can be
|
||||
dragged and resized.
|
||||
- Two markup smokes (merge/split) still called a method removed in v1.25 —
|
||||
repaired.
|
||||
|
||||
## v1.30.4 — 2026-07-22
|
||||
- **Escape now closes every dialog** (general settings, icon rules, device
|
||||
editor, space dialog, opening editor, info cards), topmost first when
|
||||
stacked. Closing the space dialog with Esc abandons a floor-import queue,
|
||||
same as its Cancel button. Esc while drawing an outline still undoes the
|
||||
last point (dialogs take priority).
|
||||
|
||||
## v1.30.3 — 2026-07-22
|
||||
- The **General settings** (fill palette) gear in the header is now visible in
|
||||
every mode for users who can edit, not just in the Plan editor.
|
||||
|
||||
## v1.30.2 — 2026-07-22 (editor tabs redesign)
|
||||
- Mode tabs renamed and reduced to two: **"Plan editor"** and **"Device
|
||||
editor"**. View is no longer a tab — it is the implicit default state.
|
||||
- The Device editor now has its own bottom toolbar (add / show all / reset
|
||||
layout / icon rules moved out of the header), mirroring the Plan toolbar.
|
||||
- Both toolbars and the active tab itself got an **X** button that closes the
|
||||
editor and returns to View. Re-clicking the active tab does nothing;
|
||||
switching Plan↔Devices is direct.
|
||||
|
||||
## v1.30.1 — 2026-07-22 (space gear polish)
|
||||
- The gear icon next to the space name is now visible in **every mode** (not
|
||||
just Plan) for users who can edit, so space settings are always one click
|
||||
away. The "+" (add space) tab remains Plan-only.
|
||||
- Fixed the gear's vertical alignment — it sat noticeably lower than the space
|
||||
name (baseline-aligned web component); tabs are now flex-centered.
|
||||
|
||||
## v1.30.0 — 2026-07-22 (lock action in the opening info card)
|
||||
- The door/window info card (View mode) now offers an explicit **Unlock/Lock
|
||||
button** when a lock entity is bound and available. Unlock is styled red as a
|
||||
security-sensitive action; the button is disabled during locking/unlocking and
|
||||
hidden when the lock is unavailable.
|
||||
- The security rule is untouched: **tapping a lock icon on the plan still never
|
||||
toggles it** — the action lives only behind a deliberate, clearly labeled
|
||||
button, same interaction contract as HA's more-info dialog.
|
||||
|
||||
## v1.29.0 — 2026-07-22 (the "new device" flag)
|
||||
- **Devices that appear in HA after installation no longer show up silently**:
|
||||
an auto-placed device (or light group) gets a big red dot at the top-right of
|
||||
its icon. The flag is stored server-side (`settings.new_device_ids`), so every
|
||||
client sees it — and it disappears everywhere the first time someone opens
|
||||
that device's editor.
|
||||
- The baseline of known devices is seeded silently on the first run after the
|
||||
update, so **existing devices never flood the plan with dots**. Hand-made
|
||||
markers (virtual/rebindings) are never flagged — the user just created them.
|
||||
(Pure `diffNewDevices`, unit-tested; backend schema for the two new settings.)
|
||||
|
||||
## v1.28.1 — 2026-07-22 (View-mode polish: cursors and inert openings)
|
||||
- **Device icons in View no longer show the grab cursor** (drag lives in the
|
||||
Devices mode); they show a pointer — clicking still works exactly as before.
|
||||
- **Doors and windows in View are pure drawings**: no grab cursor, no hover
|
||||
outline, no hit target, no click — regardless of what is bound to them.
|
||||
- **The lock badge is the one exception**: when a lock is bound, the badge is
|
||||
shown and clickable in View (pointer cursor, click opens the door/lock info
|
||||
card). It stays inert in Plan so it does not fight editing.
|
||||
- In Plan an opening remains fully interactive: grab cursor + hover outline,
|
||||
dragging along walls, and a click with ANY tool opens its properties dialog.
|
||||
|
||||
## v1.28.0 — 2026-07-21 (sub-area rooms: manual placement without an HA area; issue #3)
|
||||
- **Devices can now be placed into rooms that have no Home Assistant area.**
|
||||
The marker dialog's room list includes area-less rooms (marked "no area,
|
||||
manual"); pick one and the device/virtual marker lands at its centre. The
|
||||
marker stores `room_id`, so the choice survives edits and re-opens. The
|
||||
laundry-cupboard case from issue #3: a decorative room inside a larger area
|
||||
can now hold its own door sensor and light.
|
||||
- Room reference parsing extracted as pure `parseRoomRef` (`space#area` /
|
||||
`space#@roomId`), unit-tested; backend schema for `room_id`.
|
||||
|
||||
## v1.27.0 — 2026-07-21 (RGB light colors + alarm pulse; issue #3)
|
||||
- **RGB lights show their actual color**: an "on" light with a color tints its
|
||||
bulb icon, glow and — unless a custom ripple color is set — its presence ripple.
|
||||
Brightness is deliberately ignored (a dim red bulb still reads red); off,
|
||||
white-only and unavailable lights look as before. (Pure `lightColorOf`.)
|
||||
- **Emergencies pulse red**: leak / smoke / gas / CO / safety / tamper / problem
|
||||
binary sensors and sirens in `on` get a red pulsing ring over any display mode.
|
||||
`unavailable`/`unknown` never alarm — an outage is not a fire. Honours
|
||||
`prefers-reduced-motion`. (Pure `isAlarmState`.)
|
||||
|
||||
## v1.26.0 — 2026-07-21 (state-reflecting icons + value display; issue #3)
|
||||
- **Auto icons now reflect live state**, like core HA: door/window/garage sensors
|
||||
swap open↔closed variants, locks show locked/unlocked, a bulb lights up as
|
||||
`lightbulb-on`. Conservative by design: only well-known pairs, never when the
|
||||
user picked a custom icon, and `unavailable`/`unknown` keep the base icon.
|
||||
Gated by the existing "live states" card option. (Pure `stateIcon`, unit-tested.)
|
||||
- **New marker display mode "Value instead of an icon"**: the measurement itself
|
||||
(temperature °, humidity %, or any numeric state with its unit) becomes the
|
||||
marker body — the small corner badges disappear for such markers. Direct
|
||||
request from issue #3. Non-numeric entities fall back to the icon.
|
||||
|
||||
## v1.25.0 — 2026-07-21 (three interaction modes: View / Plan / Devices)
|
||||
The approved UX redesign (docs/UX-MODES.md), confirmed by user feedback (#3):
|
||||
|
||||
- **View** (default, and the only mode after every load): display and device
|
||||
interaction only — tap/long-press/tooltips/pan/zoom. Nothing can be dragged or
|
||||
edited; panning may start on top of an icon and never displaces it. The header
|
||||
carries only space tabs, the counter, zoom and the mode switcher.
|
||||
- **Plan**: everything about geometry and appearance — room outline/delete/merge/
|
||||
split tools, openings (placement, drag along walls, click-to-edit), room-label
|
||||
dragging, per-space gear dialog, add space, the ⚙ fill palette. Orange stage frame.
|
||||
- **Devices**: marker work — icon dragging lives ONLY here, a click opens the
|
||||
editor directly; add device, show-all curation, reset layout, icon rules. Accent frame.
|
||||
- The mode switcher is a segmented control shown to administrators; the standalone
|
||||
markup toggle button is gone, "drag anywhere" (v1.9) is consciously reversed,
|
||||
and the v1.23.1 view-mode opening drag/double-click moved into Plan.
|
||||
|
||||
## v1.24.2 — 2026-07-16 (lights fill: a color for rooms with no light sources)
|
||||
- The "Fill: lights" group in General settings gained a third color — **"No light
|
||||
sources"**. Its default opacity is 0, so rooms without any lights stay unfilled
|
||||
exactly as before; give it an opacity and such rooms get their own tint,
|
||||
distinguishable from "all lights off".
|
||||
|
||||
## v1.24.1 — 2026-07-16 (space tab: gear instead of pencil)
|
||||
- The small icon next to a space name in the tabs is now a gear (was a pencil) —
|
||||
the dialog it opens is space *settings* (plan, display, scale), not just renaming.
|
||||
|
||||
## v1.24.0 — 2026-07-16 (general settings: fill palette; per-space LQI toggle)
|
||||
- **New "General settings" dialog** (⚙ in the header): the fill colors used by every
|
||||
space, grouped by mode — lights (on / all off), temperature (cold / comfortable /
|
||||
hot) and zigbee signal (weak / strong endpoints of the gradient). Every color has
|
||||
its **own opacity slider**; the zigbee fill interpolates between the two configured
|
||||
endpoint colors. Stored server-side in `settings.fill_colors` (defaults are not
|
||||
persisted); the static `houseplan-space-card` uses the same palette.
|
||||
- **Per-space "Show zigbee signal (LQI)" toggle** in the space dialog: hides or shows
|
||||
the LQI badges next to zigbee devices (and the signal line in room tooltips) for
|
||||
that space; when never touched, the card-level `show_signal` option applies as before.
|
||||
- Fill opacity is now governed by the per-color setting; the space "Opacity" slider
|
||||
keeps controlling borders and names.
|
||||
- New pure helpers `fillColorsOf` / `lerpColor` / `roomFillStyle` (+4 tests: 77 → 81);
|
||||
backend schema for `fill_colors` and `show_lqi`; smoke `smoke_general_settings`.
|
||||
|
||||
## v1.23.2 — 2026-07-16 (manual upload limit raised to 50 MB)
|
||||
- The per-file limit for attached manuals (PDF etc.) is now **50 MB** (was 25).
|
||||
The limit is still enforced while the multipart body streams in, so an oversized
|
||||
upload is cut off early rather than buffered whole; the error toast reads the
|
||||
actual limit from the server response.
|
||||
|
||||
## v1.23.1 — 2026-07-17 (openings: hover, drag along walls, double-click properties)
|
||||
- **Hover affordance**: an accent outline hugs the opening's wall strip on hover, with a
|
||||
grab cursor — placed openings now look grabbable.
|
||||
- **Drag along walls** (view mode): an opening re-snaps continuously to the nearest derived
|
||||
wall while dragged; too far from any wall → it stays put. `snapToWall` now normalizes the
|
||||
angle to [-90, 90) — two rooms share a wall with OPPOSITE edge directions, and without this
|
||||
a drag across segment boundaries flipped the hinge side back and forth. Saved on release.
|
||||
- **Click / double click**: a single click still opens the status card (now via a 250 ms
|
||||
timer); a **double click opens the properties dialog** right from view mode. The markup
|
||||
"Opening" tool behaves as before. Hit zone made slightly thicker and is now a strip along
|
||||
the wall (previously it covered the whole swing square, causing accidental hovers).
|
||||
- (+1 test: 76 → 77.)
|
||||
|
||||
## v1.23.0 — 2026-07-17 (doors & windows with live open/lock state)
|
||||
Visual language after easy-floorplan (MIT); the placement model is ours.
|
||||
- **New markup tool "Opening"**: click next to a wall → the opening snaps onto the nearest
|
||||
DERIVED room wall (walls have no independent existence — v1.19.0) and takes its angle, then a
|
||||
dialog asks for type (door/window), **length in real cm** (defaults: door 90, window 120 —
|
||||
the per-space scale makes this honest), an open/close sensor (door/window-class
|
||||
`binary_sensor`/`cover`, invertible) and, for doors, a **lock entity**. The opening keeps
|
||||
absolute coordinates, so editing/merging/deleting rooms never breaks it. Click an existing
|
||||
opening with the tool to edit or delete it.
|
||||
- **Live rendering**: a door is a leaf hinged at the jamb with a quarter-circle swing arc that
|
||||
"draws on" (stroke-dashoffset) as it opens; a window is two casement leaves meeting in the
|
||||
middle. Открыто → the moving parts take the accent colour and animate (CSS transitions,
|
||||
`prefers-reduced-motion` honoured). No sensor → the classic static plan: doors drawn open,
|
||||
windows closed. `unavailable`/`unknown` freeze that default — an outage must not fake motion
|
||||
(pure `openingAmount`, unit-tested). Hinge side / swing side via flip toggles.
|
||||
- **Locks**: a padlock badge beside the door — green closed when `locked`, orange open when
|
||||
unlocked, grey question when unknown. The lock is NEVER toggled from the plan (the card's
|
||||
standing security rule); clicking the opening or the badge shows an **info card** with both
|
||||
states.
|
||||
- New pure helpers `snapToWall` (projection + wall angle over derived edges) and
|
||||
`openingAmount`; `space.openings[]` validated server-side. (+2 tests: 74 → 76.)
|
||||
|
||||
## v1.22.0 — 2026-07-17 (presence ripples, per-device icon size/rotation, one-click install)
|
||||
Ideas borrowed from [easy-floorplan](https://github.com/nicosandller/easy-floorplan) — the visuals,
|
||||
not the model.
|
||||
- **Presence ripples**: `display: badge | ripple | icon_ripple` per marker, with `ripple_color`
|
||||
and `ripple_size`. Active → pulsing rings; idle → a faint dot. Gated by the pure
|
||||
`isActiveState` — independent of the card-wide live_states toggle, and `unavailable`/`unknown`
|
||||
count as idle so an outage never leaves a ring pulsing. Honours `prefers-reduced-motion`.
|
||||
- **Per-device icon `size` (×0.5–3) and `angle`** — sizing now hangs off `--dev-size`, so value
|
||||
badges scale with the device.
|
||||
- **One-click install badge** (My Home Assistant → HACS) in both READMEs.
|
||||
- (+1 test.)
|
||||
|
||||
## v1.21.3 — 2026-07-16 (room labels: no text shadow)
|
||||
- Room name labels no longer carry a text shadow — crisper look on both the white
|
||||
hand-drawn canvas and plan images.
|
||||
|
||||
## v1.21.2 — 2026-07-16 (space-dialog polish)
|
||||
- The **"Scale (cm per cell)" input is compact** again — a generic `width:100%`
|
||||
dialog rule was stretching it across the row.
|
||||
- **A space with no background image now gets a white "paper" canvas** instead of
|
||||
the dark stage, so hand-drawn rooms read like a floor plan on paper.
|
||||
|
||||
## v1.21.1 — 2026-07-16 (audit: split snaps to the wall, docs for merge/split)
|
||||
- **Fix (found in audit):** Split required each click to land on a grid node, so it
|
||||
silently refused rooms whose walls are not grid-aligned (imported or older polygons)
|
||||
— the "pick a wall" toast fired no matter where you clicked. The click now snaps to
|
||||
the room's nearest wall (`closestPointOnBoundary`) instead of the grid, with the pull
|
||||
capped at ~6 grid cells so an accidental click in the middle of a room stays a miss
|
||||
rather than becoming a wall point the user never meant; `splitRoom()` still rejects
|
||||
a cut that is not a clean wall-to-wall chord. Also makes aiming easier on the fine
|
||||
(240-cell) grid.
|
||||
- **Docs:** README (en + ru) now documents room Merge, Split, the drawing ruler and the
|
||||
per-space scale — these v1.18–v1.21 features were shipped without user-facing docs.
|
||||
`docs/TESTING.md` gained merge/split rows and a fresh self-run record.
|
||||
- New smokes `demo/smoke_merge_split.mjs` and `smoke_split_nonsnap.mjs`;
|
||||
`closestPointOnBoundary` unit-tested. (+1 test: 72 → 73.)
|
||||
|
||||
## v1.21.0 — 2026-07-16 (merge and split rooms)
|
||||
- **Merge** (toolbar "Merge"): click a room, then a neighbour. Only rooms that **share a wall**
|
||||
can merge — and that is decided by the result rather than a heuristic: `mergeRooms` unions the
|
||||
outlines and accepts the pair only when they collapse into ONE hole-free outline. A corner
|
||||
touch, rooms apart, or a union enclosing a hole are refused. A dialog picks which name and
|
||||
area survive; the kept room keeps its id, so its label position and devices stay put. The
|
||||
dialog warns that the other area is released.
|
||||
- **Split** (toolbar "Split"): click the room, then two points on its walls — the chord cuts it
|
||||
in two, with the live ruler on the cut. **The bigger part stays the room it was** (name, area,
|
||||
devices); the smaller becomes a new room and its dialog asks for name/area. Cancelling the
|
||||
dialog leaves the room whole — the cut is applied only on confirm. Cuts that do not run
|
||||
wall-to-wall inside the room (ends off the wall, chord leaving a concave room, a chord along a
|
||||
wall) are refused.
|
||||
- Boolean geometry via **polyclip-ts** (proper ESM + native types; `polygon-clipping` ships named
|
||||
types but a default-only ESM build, which breaks either tsc or the runtime). Verified against
|
||||
the real plan, where neighbouring walls overlap collinearly instead of matching exactly —
|
||||
the case a hand-rolled union gets wrong. Bundle: 151 KB → 202 KB.
|
||||
- New pure helpers: `polygonArea`, `mergeRooms`, `splitRoom`. (+5 tests: 67 → 72.)
|
||||
|
||||
## v1.20.0 — 2026-07-16 (rooms may not overlap)
|
||||
- **A click strictly inside an existing room is refused** while drawing (toast names the room).
|
||||
Being *on* a wall stays legal — neighbouring rooms share walls, and real walls overlap
|
||||
collinearly rather than match exactly, so new vertices land on existing outlines mid-span
|
||||
all the time. `pointStrictlyInside` excludes the boundary explicitly (ray casting alone is
|
||||
unreliable exactly on an edge).
|
||||
- **Closing an outline that overlaps an existing room is refused** — vertex checks alone are not
|
||||
enough: an outline drawn *around* a room has every vertex outside it. The outline stays open
|
||||
so it can be corrected. Nesting one room inside another counts as an overlap.
|
||||
- New pure geometry in logic.ts: `roomPoly`, `pointOnBoundary`, `pointStrictlyInside`,
|
||||
`segmentsProperlyCross` (touching/collinear is deliberately not a crossing), `roomsOverlap`
|
||||
(edge crossings + containment probe, which also catches duplicate outlines). (+4 tests: 63 → 67.)
|
||||
|
||||
## v1.19.0 — 2026-07-16 (a line is never a thing of its own)
|
||||
**Model change.** A wall can only exist as an edge of a closed room. Consequences:
|
||||
- **Walls are derived from room outlines** (`roomEdges` in logic.ts), not stored. A wall
|
||||
shared by two rooms is emitted once, so **deleting a room keeps the borders its neighbours
|
||||
still contribute** — and drops the walls nobody else uses. This falls out of the model
|
||||
instead of needing bookkeeping.
|
||||
- **An abandoned outline leaves nothing behind.** Previously every click pair was written to
|
||||
`space.segments` immediately, so a contour you never closed left orphan lines on the plan.
|
||||
Now nothing is persisted until the room is saved.
|
||||
- **The "Erase line" tool is gone** — there is no standalone line to erase. Mis-clicks are
|
||||
undone with Esc / Ctrl+Z as before.
|
||||
- **`space.segments` is dropped on every save** (legacy configs shed it on first write).
|
||||
Validation still tolerates the field so a stale browser tab cannot fail a save; diagnostics
|
||||
no longer reports it. Lines that belonged to no room disappear on upgrade — by design.
|
||||
- Dead code removed: `_addSegment`, `_removeSegmentByKey`, `_distToSeg`, `_pathSegs`, `_segKey`.
|
||||
`segKey(a, b, prec)` gained a precision argument (normalized coords need more than render
|
||||
units). (+2 tests: 61 → 63.)
|
||||
|
||||
## v1.18.1 — 2026-07-16 (fix: the drawing ruler was invisible)
|
||||
- Fix on top of v1.18.0: the length badge never showed up while drawing. It was rendered
|
||||
inside `.devlayer`, and `.stage.markup .devlayer { display: none }` hides that whole layer
|
||||
in markup mode (so icons do not get in the way) — the badge was in the DOM but invisible.
|
||||
It now lives in its own `.measurelayer` (absolute, `pointer-events: none`), which markup
|
||||
mode does not hide. Verified visually on a real drawn segment ("3.60 m" on screen).
|
||||
- Testing lesson (see docs/TESTING.md): asserting on `textContent` is not enough — a DOM
|
||||
query passes on elements hidden by an ancestor. Check `offsetParent`/rect or look at a
|
||||
screenshot.
|
||||
|
||||
## v1.18.0 — 2026-07-14 (live measurements while drawing rooms + per-space scale)
|
||||
- **Ruler while drawing.** In room-markup "draw" mode, a badge follows the cursor showing the
|
||||
length of the current segment (last placed vertex → cursor). Units come from the HA unit
|
||||
system: metric → metres ("1.25 m"), imperial → feet+inches ("4′ 1″").
|
||||
- **Per-space scale.** New "Scale (grid cell size)" field in the space dialog — cm represented
|
||||
by one grid cell (default **5 cm**, i.e. 240 cells ≈ 12 m). Stored as `space.cell_cm`; each
|
||||
plan can have its own real-world size.
|
||||
- Pure helpers `segmentCm` / `formatLength` in logic.ts (unit-tested); `_fmtLen` +
|
||||
`_renderMeasureLabel` in the card; `.measurelabel` style; i18n `space.scale_label`/`scale_unit`.
|
||||
(+3 tests: 58 → 61 frontend.)
|
||||
|
||||
## v1.17.2 — 2026-07-11 (humidity badge: gate on the sensor, not the icon)
|
||||
- Fix on top of v1.17.1: the humidity `%` badge is now shown whenever the marker's primary
|
||||
entity is a humidity sensor (`device_class: humidity`), regardless of the resolved icon.
|
||||
Previously it required the `mdi:water-percent` icon, so a humidity sensor whose name matched
|
||||
another icon rule (e.g. a "Myheat Влажность …" sensor → boiler icon) showed no value.
|
||||
Verified live (45.2 → 45%). (+1 test: 57 → 58.)
|
||||
|
||||
## v1.17.1 — 2026-07-11 (humidity value next to the icon, like temperature)
|
||||
- **Humidity sensors now show their value (%) next to the icon**, mirroring the temperature
|
||||
badge. Any marker resolved to the humidity icon (`mdi:water-percent`) — a humidity device or a
|
||||
humidity entity placed on its own (v1.17.0) — gets an integer `%` badge and the value in its
|
||||
tooltip. New `isHumEntity`/`humFor` (diagnostic entities excluded), `DevItem.hum`, `.hval`
|
||||
badge, gated by the same "sensor values" option as temperature. (+2 tests: 55 → 57.)
|
||||
|
||||
## v1.17.0 — 2026-07-11 (place individual entities, not just whole devices — issue #1)
|
||||
- **You can now put a single entity on the plan as its own icon** — e.g. a climate sensor
|
||||
exposes temperature AND humidity; add the device (shows temperature) and separately add the
|
||||
humidity entity as a second icon. In the "add device" dialog, start typing in the binding
|
||||
search and individual entities now appear alongside devices/helpers (surfaced only while
|
||||
searching, so the default list stays clean); the sub-label shows the domain and parent device.
|
||||
- Entity markers now get a **sensible auto icon** (name rules → `device_class` → chip) instead of
|
||||
the generic shape, and a **temperature value** when the entity is a thermometer/air-monitor.
|
||||
The `entity:<eid>` binding already existed (used by helpers/light groups); this exposes it for
|
||||
any entity. (+1 test: 54 → 55.)
|
||||
|
||||
## v1.16.2 — 2026-07-11 (docs+log: correct card resource URL — fixes "Custom element doesn't exist")
|
||||
- **Support issue #2**: users adding a Lovelace resource that points at the on-disk path
|
||||
`/custom_components/houseplan/frontend/houseplan-card.js` get a `text/plain` MIME error and
|
||||
"Custom element doesn't exist: houseplan-card" — HA does not serve `custom_components/` over
|
||||
HTTP. The integration serves the card at **`/houseplan_files/houseplan-card.js`** (verified:
|
||||
`200 text/javascript`) and auto-registers it as a Lovelace resource in storage mode.
|
||||
- README (en+ru) now documents the correct URL and the common mistake, incl. a YAML-mode
|
||||
`resources:` snippet.
|
||||
- On setup the integration logs (INFO) the exact served URL and, when Lovelace resources are
|
||||
YAML-managed, how to add it manually — so the fix is discoverable from the logs.
|
||||
|
||||
## v1.16.1 — 2026-07-08 (space-card shows room fills as configured)
|
||||
- **The static `houseplan-space-card` now renders room fills exactly as configured on the
|
||||
full card** (temperature / signal / lights coloring), as a snapshot of the states passed
|
||||
in via `hass` — reverting the v1.16.0 omission. The card still does not subscribe to state
|
||||
changes itself and stays non-interactive (`pointer-events:none`); fills refresh when HA
|
||||
hands the card an updated `hass`. Added shared `areaLqi()` in devices.ts.
|
||||
|
||||
## v1.16.0 — 2026-07-08 (new: houseplan-space-card + deep-link)
|
||||
- **New second card `custom:houseplan-space-card`** — a READ-ONLY, static schematic of a
|
||||
single space, embeddable on any dashboard. Draws the configured plan + room borders/names +
|
||||
device markers at their saved positions, with **no interactivity** (the schematic layer is
|
||||
`pointer-events:none` — no clicks/hover/tooltips/drag/more-info) and **no live states**
|
||||
(no state subscription, no status/temperature fills). A footer button opens the space in the
|
||||
full component via a **deep-link** (`#space=<id>`). Config: `space` (required), `title`,
|
||||
`show_button`, `button_label`, `button_target`, `aspect_ratio`, `icon_size`; unknown space → a
|
||||
tidy error card. GUI editor with a space dropdown from the integration config.
|
||||
- **Deep-link in the full card**: on load it reads `#space=<id>` (valid id wins over
|
||||
`default_floor`) and listens to `hashchange`, without blocking manual space switching.
|
||||
- **Shared rendering**: pure geometry in `space-geometry.ts` (unit-tested), the static drawer in
|
||||
`space-render.ts`, and a **module-level config cache** (`config-store.ts`, rev-keyed, seeded from
|
||||
the full card's localStorage snapshot, invalidated on `houseplan_config_updated`) so N embedded
|
||||
cards on one board share a single WS request. Both cards ship in the one bundle.
|
||||
- Tests: 48 → 54 frontend (space geometry) + demo smokes `smoke_space_card` and `smoke_deeplink`.
|
||||
- Note: status/temperature fills are intentionally omitted from the static card (they are live);
|
||||
it shows configured room borders/names + neutral icons. Marker/position edits reflect after the
|
||||
config event or a reload.
|
||||
|
||||
## v1.15.6 — 2026-07-08 (room hover also reveals the border)
|
||||
- Hovering a room now **shows its outline** even when borders are turned off. The stroke
|
||||
colour (`--room-stroke`) is now always set to the room colour and only hidden via
|
||||
|
||||
@@ -69,6 +69,15 @@ cp dist/houseplan-card.js custom_components/houseplan/frontend/
|
||||
("InvalidStateError: Transition was aborted", hui-view is not created for 1–2 min).
|
||||
Cured by repeating the SPA navigation: pushState + a location-changed event, or just waiting.
|
||||
|
||||
## Dependency and cache gotchas
|
||||
|
||||
- **polygon-clipping is a trap**: its `.d.ts` declares named exports but the ESM build has only
|
||||
a default export — tsc or the runtime breaks, whichever you appease. Use **polyclip-ts**
|
||||
(proper ESM + native types; same results, +~50 KB bundle via bignumber.js).
|
||||
- **Redeploying the same version keeps the resource URL** (`/houseplan_files/houseplan-card.js?v=X`),
|
||||
so browsers may serve the previous bundle from cache. Bump the version for anything users must
|
||||
pick up, or hard-refresh (Ctrl+Shift+R) when testing a hotfix redeploy.
|
||||
|
||||
## Release
|
||||
|
||||
Tag `vX.Y.Z` + GitHub Release → the workflow `.github/workflows/release.yml` builds and attaches
|
||||
|
||||
+15
-8
@@ -14,27 +14,27 @@ This roadmap replaces them.
|
||||
|
||||
Track progress in `custom_components/houseplan/quality_scale.yaml` (done/exempt + comment).
|
||||
|
||||
- [ ] `runtime-data`: move `hass.data[DOMAIN]` (stores, entry, write lock) to typed
|
||||
- [x] `runtime-data`: move `hass.data[DOMAIN]` (stores, entry, write lock) to typed
|
||||
`entry.runtime_data` (`type HouseplanConfigEntry = ConfigEntry[HouseplanData]`).
|
||||
Keep WS command registration in `async_setup` (commands must survive entry reloads).
|
||||
- [ ] `config-entry-unloading` (Silver, do it now): real `async_unload_entry` — unregister
|
||||
- [x] `config-entry-unloading` (Silver, do it now): real `async_unload_entry` — unregister
|
||||
update listeners via `entry.async_on_unload`; document what stays (static paths cannot
|
||||
be unregistered — exempt comment).
|
||||
- [ ] `test-before-setup`: verify storage is loadable/writable in `async_setup_entry`,
|
||||
- [x] `test-before-setup`: verify storage is loadable/writable in `async_setup_entry`,
|
||||
raise `ConfigEntryNotReady` on failure.
|
||||
- [ ] `unique-config-entry`: replace the manual `_async_current_entries()` check with
|
||||
- [x] `unique-config-entry`: replace the manual `_async_current_entries()` check with
|
||||
`single_config_entry: true` in manifest.
|
||||
- [ ] `config-flow-test-coverage` + backend tests for websocket_api (pytest-homeassistant-custom-component:
|
||||
- [x] `config-flow-test-coverage` + backend tests for websocket_api (pytest-homeassistant-custom-component:
|
||||
conflict/rev, admin_only enforcement, layout point ops, upload limits).
|
||||
- [ ] Storage versioning: `Store(minor_version=...)` + `_async_migrate_func` skeleton **now**,
|
||||
- [x] Storage versioning: `Store(minor_version=...)` + `_async_migrate_func` skeleton **now**,
|
||||
before the config schema evolves further (Bronze-adjacent, cheap while schema is stable).
|
||||
- [ ] `quality_scale.yaml` with honest exempt list (no entities ⇒ entity rules N/A, no polling, no deps).
|
||||
- [x] `quality_scale.yaml` with honest exempt list (no entities ⇒ entity rules N/A, no polling, no deps).
|
||||
|
||||
## Phase 8 — Quality Scale: Silver + selected Gold
|
||||
|
||||
- [ ] `test-coverage` ≥95% backend; frontend: extract remaining pure logic (view math,
|
||||
marker resolution) into `logic.ts`/`devices.ts` and cover with node:test.
|
||||
- [ ] `diagnostics.py`: config + layout dump with `async_redact_data` (redact names/links/PDF paths).
|
||||
- [x] `diagnostics.py`: config + layout dump with `async_redact_data` (redact names/links/PDF paths).
|
||||
- [ ] `reconfiguration-flow` + richer **options flow**: admin_only, curation defaults
|
||||
(exclude domains — UI editable, replacing the hardcoded EXCLUDED_DOMAINS fallback),
|
||||
LQI thresholds, group_lights default.
|
||||
@@ -74,6 +74,13 @@ Track progress in `custom_components/houseplan/quality_scale.yaml` (done/exempt
|
||||
- [ ] GitHub: issue templates, discussions on, CONTRIBUTING.md (build/test in 5 minutes).
|
||||
- [ ] Semantic versioning discipline; keep release notes user-facing (they show in HACS).
|
||||
|
||||
## Phase 11 — UX modes redesign ✅ DONE (v1.25.0–v1.28.0, see docs/UX-MODES.md)
|
||||
|
||||
Shipped: the three-tab interaction model (View / Plan / Devices, v1.25.0),
|
||||
state-reflecting icons + value display (v1.26.0), RGB light colors + alarm pulse
|
||||
(v1.27.0), sub-area rooms (v1.28.0). Remaining backlog from issue #3: music
|
||||
notes for players, directional TV ripples (not planned).
|
||||
|
||||
## Explicit non-goals (for now)
|
||||
|
||||
- 3D plans (.glb) — separate market (3Dash), heavy dependencies.
|
||||
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
# Product scope — what House Plan is and is not
|
||||
|
||||
*Fixed with the owner on 2026-07-22. This document is a guard rail: features are
|
||||
built, improved and accepted **only** if they serve a job listed here. When a new
|
||||
idea appears, first find its row in this file; if there is none — it belongs to
|
||||
HA core, to another card, or nowhere. Companion documents: PRODUCT.md (market),
|
||||
ROADMAP.md (order of work), UX-MODES.md (interaction model).*
|
||||
|
||||
## Mission
|
||||
|
||||
House Plan is the **spatial "at a glance + quick act" layer** for a Home
|
||||
Assistant home. Upload or draw a floor plan, outline rooms bound to HA areas,
|
||||
and the home's devices appear on a live, tappable map: states, climate, alerts,
|
||||
guarded quick actions. Setup is GUI-only — no Inkscape, no YAML, no external
|
||||
editors. Everything that is not "look at the home spatially and act on the
|
||||
obvious" is somebody else's job.
|
||||
|
||||
## Target audience
|
||||
|
||||
| Persona | Role | Surface |
|
||||
|---|---|---|
|
||||
| **Home admin** (primary) | HA enthusiast, house/large flat, 20–200 devices, several floors; sets up and maintains the plan | Desktop browser (both editors live here) |
|
||||
| **Household members** | Non-technical; consume the plan daily, never edit | Wall tablet (kiosk), phone (companion app) |
|
||||
| **Guests / kiosk** | View-only glance at the home | Wall tablet |
|
||||
|
||||
Design consequence: **View mode is the product** for two of the three personas.
|
||||
Editors are admin-only tools and must never leak interactions into View
|
||||
(established by UX-MODES; lock guard, inert openings, no drag in View).
|
||||
|
||||
## Core user jobs — the component must close these
|
||||
|
||||
| # | Job (user's words) | Status |
|
||||
|---|---|---|
|
||||
| J1 | "Show the whole home and what's happening right now" — live spatial overview: device states, room fills (light/temp/LQI), values, multi-floor tabs | **Closed** |
|
||||
| J2 | "Something is wrong — show me *where*" — leak/smoke/gas pulse, open doors/windows, unlocked locks, red dot on devices HA added silently | **Closed** |
|
||||
| J3 | "Let me act on the obvious right from the plan" — tap-to-toggle for safe domains, info cards, guarded lock action (explicit button only, never a plan tap) | **Closed** |
|
||||
| J4 | "From zero to a working plan in one evening, no Inkscape/YAML" — image/PDF/draw, floors-import wizard, room polygons bound to areas, curated auto-placement, editable icon rules | **Closed**; onboarding polish is *partial* (no registry-driven room suggestions) |
|
||||
| J5 | "Room climate at a glance" — per-room temperature/humidity, comfort-range fills, room-card metrics | **Closed** |
|
||||
| J6 | "Keep the plan true as the home evolves" — new-device flag, two editors, drag/resize, merge/split, multi-client live sync, optimistic locking | **Closed** |
|
||||
| J7 | "Is my Zigbee mesh healthy *here*?" — LQI badges, per-room average, LQI fill | **Closed** (kept deliberately: cheap, spatial by nature, no in-plan competitor) |
|
||||
|
||||
## Partially covered — improvement backlog stays inside these
|
||||
|
||||
- **Touch ergonomics of the editors**: corner handles and grid clicks are small on
|
||||
tablets; editors are desktop-first today. Improve, don't redesign.
|
||||
- **Value display**: single current value per device; units/precision follow HA
|
||||
formatting only.
|
||||
- **Accessibility**: `prefers-reduced-motion` only; no keyboard navigation in
|
||||
editors, no ARIA labelling of the plan.
|
||||
- **Docs/screenshots**: README predates the two-editor redesign.
|
||||
|
||||
## Known gaps that fit the mission (build only on owner's request)
|
||||
|
||||
- Person/presence shown in rooms (classic floorplan ask; pure J1).
|
||||
- Plan-level "security glance": one badge for "all locked / N open" (J2).
|
||||
- Threshold colouring for room-card metrics (J5).
|
||||
|
||||
## Out of scope — never build, point users to the right tool
|
||||
|
||||
- Automations, scenes, scripts, notifications → HA core.
|
||||
- Device/entity administration (rename, reassign area, disable) → HA registry
|
||||
UIs. We *read* the registry, we never manage it.
|
||||
- History, graphs, statistics → recorder/history cards. We show now, not then.
|
||||
- Camera streams, media controls → their own cards; our more-info opens HA's.
|
||||
- Energy monitoring/analytics → HA Energy.
|
||||
- 3D / isometric / photorealistic rendering, furniture drawing → niche tools
|
||||
(easy-floorplan et al.); we are a schematic live map, not an interior editor.
|
||||
- A general dashboard framework (menus, popups, theming engine) → Bubble Card,
|
||||
Dwains and friends.
|
||||
|
||||
## Excess-functionality audit (2026-07-22)
|
||||
|
||||
- **Device metadata: links & PDF manuals** — not spatial, but tiny, server-side,
|
||||
and used in real installs. Verdict: **keep, frozen** (no growth).
|
||||
- **Virtual devices** — placeholders for not-yet-installed hardware; serves J6.
|
||||
Verdict: **keep, frozen**.
|
||||
- **LQI diagnostics** — promoted to J7 (differentiator), not excess.
|
||||
- Nothing found that warrants removal; the mode redesign already moved every
|
||||
interaction to where it belongs.
|
||||
|
||||
## The component in one breath (README/HACS copy)
|
||||
|
||||
> **House Plan** turns your Home Assistant into a live map of your home. Upload
|
||||
> a plan image (or draw one), outline rooms and bind them to HA areas — your
|
||||
> devices appear in place, automatically, with live states. Glance at the wall
|
||||
> tablet: what's on, what's open, what's too cold, what's leaking, what's new.
|
||||
> Tap to act — safely: locks never toggle by accident. Two built-in editors
|
||||
> (plan and devices) mean no Inkscape, no YAML, no external tools — ever.
|
||||
|
||||
**Tasks it closes:** whole-home live overview · spatial alerts (leak/smoke/open/
|
||||
unlocked/new device) · safe quick actions · per-room climate · Zigbee mesh
|
||||
health · zero-to-plan GUI onboarding · keeping the plan true over years.
|
||||
|
||||
**Pains it removes:** hand-crafted SVG + YAML floorplans · entity-list
|
||||
dashboards that hide *where* things happen · silent device sprawl · accidental
|
||||
toggles of security devices · per-device dashboards that non-technical family
|
||||
members can't read.
|
||||
+37
-5
@@ -9,18 +9,18 @@
|
||||
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
|
||||
> ARCHITECTURE.md for design changes, ROADMAP.md when plans move.
|
||||
|
||||
## Snapshot (2026-07-05)
|
||||
## Snapshot (2026-07-17)
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.15.6** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…v1.11.2 |
|
||||
| Version | **v1.27.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…**v1.23.1** (latest published 2026-07-17, bundle auto-attached by release.yml) |
|
||||
| CI | `.github/workflows/validate.yml` (hacs + hassfest + frontend + backend) — **fully green** since v1.11.1; `release.yml` auto-attaches the card bundle (needs `permissions: contents: write`, fixed) |
|
||||
| HACS | Works as custom repository (id 1290210112 on the home instance). **Inclusion PR: https://github.com/hacs/default/pull/9004** (queue ≈2 months as of 2026-07). Lesson: #8995 was auto-closed by hacs-bot — the PR body MUST be their exact template with every checkbox ticked and all 3 links (release, HACS action run, hassfest run); a custom body gets closed without discussion |
|
||||
| Brands | Ships **inside the integration**: `custom_components/houseplan/brand/{icon,icon@2x,logo,logo@2x}.png` (HA ≥2026.3 local-brands mechanism). home-assistant/brands PR #10700 was auto-closed — that repo no longer accepts custom integrations |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed v1.11.2, installed *via HACS* (custom repo) — updates flow through HACS now |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.23.1**, installed *via HACS* (custom repo). The user has real rooms, 12 openings (doors/windows with TTLock locks and contact sensors) and live fills configured |
|
||||
| Localization | UI en/ru (`src/i18n.ts`), auto by `hass.locale` + `language` card option; codebase and docs are English-first (`README.ru.md` is the Russian copy) |
|
||||
| Tests | 41 frontend (node:test, incl. a 12-test buildDevices suite on a fake hass) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13; skipped locally — sandbox has py3.10) |
|
||||
| Tests | 77 frontend (node:test, incl. a 12-test buildDevices suite on a fake hass) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13; skipped locally — sandbox has py3.10) |
|
||||
|
||||
## Recent milestones (details in CHANGELOG.md)
|
||||
|
||||
@@ -62,6 +62,32 @@
|
||||
with `:not(.styled)` so filled rooms darken their fill, unfilled ones grey.
|
||||
- **v1.15.6** — room hover also reveals the border (stroke colour kept, hidden via
|
||||
opacity) even when borders are off.
|
||||
- **v1.16.0** — NEW read-only `houseplan-space-card` (static single-space schematic,
|
||||
pointer-events:none, deep-link button) + `#space=<id>` deep-link in the full card; shared
|
||||
space-geometry/space-render + module-level config cache (config-store).
|
||||
- **v1.16.1** — space-card renders room fills as configured on the full card (snapshot),
|
||||
no longer omitted; +shared areaLqi().
|
||||
- **v1.17.x** — entity markers get auto icon/temp (issue #1); correct resource URL documented
|
||||
(issue #2); humidity badge (gated on device_class, not the icon).
|
||||
- **v1.18.x** — live ruler while drawing rooms (metres / feet+inches) + per-space scale
|
||||
`cell_cm`; visibility fix (the badge lived in the markup-hidden devlayer).
|
||||
- **v1.19.0** — a line is never an entity of its own: walls derived from room outlines
|
||||
(`roomEdges`), unfinished contours persist nothing, Erase tool removed, `space.segments`
|
||||
stripped on save.
|
||||
- **v1.20.0** — rooms may not overlap (strictly-inside clicks refused, overlapping contours
|
||||
refused at close; shared walls stay legal).
|
||||
- **v1.21.x** — merge & split rooms (boolean geometry via polyclip-ts; merge = union collapses
|
||||
to one hole-free outline; split = wall-to-wall chord, bigger part keeps identity) + UX fixes.
|
||||
- **v1.22.0** — presence ripples (badge/ripple/icon_ripple + colour/size, `isActiveState`),
|
||||
per-device icon size/rotation (`--dev-size`), one-click HACS badge. NOTE: sources were lost
|
||||
in a sandbox reset after deploy and restored from conversation patches — push immediately
|
||||
after building, never wait for verification.
|
||||
- **v1.23.0** — doors & windows: "Opening" markup tool (snap onto derived walls, absolute
|
||||
coords), length in real cm, contact sensor + lock, animated leaf/arc, padlock badge, status
|
||||
card; lock never toggled from the plan.
|
||||
- **v1.23.1** — openings UX: hover outline + grab cursor, drag along walls (angle normalized
|
||||
to [-90,90) so the hinge never flips), click=status / double-click=properties, thicker hit
|
||||
strip. Release v1.23.1 published on GitHub (covers v1.22.0–v1.23.1).
|
||||
|
||||
## Where things live
|
||||
|
||||
@@ -103,3 +129,9 @@
|
||||
md5-verify after every deploy, restart HA via
|
||||
`nohup ha core restart >/dev/null 2>&1 </dev/null &` (otherwise the SSH session hangs).
|
||||
5. GitHub pushes need a PAT (create via the user's Chrome: settings/tokens, repo+workflow scope).
|
||||
|
||||
## Product scope
|
||||
|
||||
docs/SCOPE.md (fixed 2026-07-22) is the guard rail for all feature work: mission,
|
||||
personas, jobs J1–J7, partial/out-of-scope lists, excess audit. Check it before
|
||||
accepting or proposing any feature.
|
||||
|
||||
+187
-4
@@ -29,6 +29,28 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
- [ ] Removal: delete entry → Lovelace resource entry disappears; `.storage/houseplan.*` survives; reinstall picks the old config up
|
||||
- [ ] Diagnostics download works; personal fields (name/link/description/pdfs) are `**REDACTED**` [auto]
|
||||
|
||||
## Modes (v1.25.0) ★
|
||||
|
||||
- [ ] The card always loads in **View**; edit modes are never restored [auto]
|
||||
- [ ] View: pan/zoom/space-switch/tap/long-press/tooltips only — dragging an icon,
|
||||
label or opening does nothing; panning may start on top of an icon [auto]
|
||||
- [ ] View header: space tabs + count + zoom + mode tabs, ZERO edit buttons [auto]
|
||||
- [ ] Plan: markup toolbar, space gears, +space, ⚙ palette; device icons hidden,
|
||||
labels/openings draggable; orange stage frame [auto]
|
||||
- [ ] Devices: icon drag works, click opens the marker editor directly; +/👁/↺/⬡
|
||||
buttons; accent stage frame [auto]
|
||||
- [ ] Mode tabs hidden for non-admin users; segmented control highlights the active mode
|
||||
- [ ] Openings in View (v1.28.1): the door/window itself is a pure drawing — no
|
||||
cursor change, no hover outline, no hit target, no click, regardless of
|
||||
bindings [auto]
|
||||
- [ ] The LOCK BADGE is the one exception: when a lock is bound it is shown and
|
||||
clickable in View (pointer cursor, click → door/lock info card); inert in
|
||||
Plan so it does not fight editing [auto]
|
||||
- [ ] Device icons in View show a pointer cursor (no grab); grab only in the
|
||||
Devices mode [auto]
|
||||
- [ ] In Plan an opening is interactive: grab cursor, hover outline, drag along
|
||||
walls, click (any tool) opens its properties [auto]
|
||||
|
||||
## Onboarding ★
|
||||
|
||||
- [ ] Empty config, HA has floors → floors-import wizard offers them sorted by level [auto]
|
||||
@@ -43,7 +65,7 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
- [ ] Create with an image (SVG, PNG, JPG, WebP) → correct aspect, crisp at zoom (SVG)
|
||||
- [ ] Oversized plan (>8 MB) → readable error toast, dialog stays open
|
||||
- [ ] Create with "No image — I'll outline rooms by hand": orientation landscape/portrait/square respected [auto]; borders+names default ON [auto]
|
||||
- [ ] Draw-space renders an empty canvas (no black hole), markup works on it
|
||||
- [ ] Draw-space (no background) renders a WHITE canvas (paper-like), markup works on it; room borders/names stay legible on white [auto]
|
||||
- [ ] Edit: rename; replace image; **switch image→draw detaches the plan** [auto]
|
||||
- [ ] Delete space with rooms/devices → tab disappears, layout of other spaces untouched
|
||||
- [ ] Display settings: borders toggle, names toggle, color picker + opacity slider live-preview after save, fill selector [auto]
|
||||
@@ -56,6 +78,14 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
- [ ] Average room temperature counts ONLY thermometer/air-monitor devices — fridges, TRV heads,
|
||||
smart-plug chip temperatures (`*_device_temperature`) and diagnostic-category temps are excluded [auto]
|
||||
- [ ] Space dialog is 500 px wide; the comfort-bounds inputs are compact (56 px)
|
||||
- [ ] The scale (cm per cell) input is compact (72 px), not full-width [auto]
|
||||
- [ ] General settings (⚙ in the header): fill colors grouped by mode (lights on/off/none,
|
||||
temp cold/comfy/hot, LQI weak/strong), each with its own opacity slider [auto];
|
||||
Reset restores defaults; saving defaults stores nothing [auto]
|
||||
- [ ] Custom fill colors apply to the full card AND the static space-card
|
||||
- [ ] LQI gradient interpolates between the configured weak/strong colors [auto]
|
||||
- [ ] Per-space "Show zigbee signal (LQI)" toggle hides/shows the badges next to
|
||||
devices and the signal line in room tooltips for that space only [auto]
|
||||
- [ ] Device icon badge is centred exactly on its point (no 1 px down-right drift) [auto]
|
||||
- [ ] Device glyph is centred within its badge (no vertical drift — real ha-icon is block+line-height) [auto]
|
||||
- [ ] Room hover highlight still works when custom borders/fills are on
|
||||
@@ -63,7 +93,26 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
|
||||
## Room markup editor ★
|
||||
|
||||
- [ ] Grid appears; dots snap; segments draw pair-by-pair; shared walls reused
|
||||
- [ ] Grid appears; dots snap; the outline draws pair-by-pair; shared walls reused
|
||||
- [ ] Ruler: while drawing, the length of the current segment follows the cursor
|
||||
(metres, or feet+inches on an imperial HA); scale = space "cm per cell" (default 5)
|
||||
- [ ] A line cannot exist on its own: start an outline, do NOT close it, leave markup —
|
||||
no lines are left behind (nothing was written to the config)
|
||||
- [ ] Deleting a room removes its walls, EXCEPT those shared with a neighbouring room
|
||||
(the neighbour still yields them); deleting the neighbour too removes them as well
|
||||
- [ ] There is no "Erase" tool in the markup toolbar (removed in v1.19.0)
|
||||
- [ ] Rooms never overlap (v1.20.0): a click strictly inside an existing room is refused with a
|
||||
toast; a click ON a shared wall (including mid-span of a longer neighbour wall) still works
|
||||
- [ ] Closing an outline drawn AROUND an existing room is refused; the outline stays open
|
||||
- [ ] Merge (v1.21.0): two rooms sharing a wall merge into one; the dialog picks the surviving
|
||||
name/area; rooms touching only at a corner or apart are refused with a toast
|
||||
- [ ] Split (v1.21.0): click a room, then two points on its walls — the bigger part keeps the
|
||||
name/area/devices, the smaller opens the new-room dialog; Cancel leaves the room whole
|
||||
- [ ] Split: a cut with an end off the wall, or along a wall, is refused with a toast
|
||||
- [ ] Split: the click snaps to the nearest wall, so it works on non-grid-aligned rooms
|
||||
(imported/legacy polygons), not only on rooms drawn on the current grid [auto]
|
||||
- [ ] Split: a click far from any wall (middle of the room) is a miss with a toast —
|
||||
the wall-snap pull is capped, accidental clicks do not pick a wall [auto]
|
||||
- [ ] Esc / Ctrl+Z removes the last dot (and its line); Reset clears the path
|
||||
- [ ] Closing the contour (click the first dot, ≥4 points) opens the room dialog
|
||||
- [ ] Room dialog: area list shows only unassigned areas; picking an area prefills the name
|
||||
@@ -83,6 +132,84 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
- [ ] ↺ reset restores auto layout after confirm
|
||||
- [ ] Temperature badge on thermometers; LQI value under zigbee icons with red→green color
|
||||
- [ ] Live states: light on = yellow, open cover/lock/door = orange, unavailable = faded
|
||||
- [ ] State icons (v1.26.0): auto icons morph with state — door/window/garage open↔closed,
|
||||
lock locked↔unlocked, bulb on; custom icons and unavailable states never morph [auto]
|
||||
- [ ] display "Value instead of an icon": the marker shows the measurement (°/%/unit)
|
||||
as its body, small badges hidden; non-numeric fallback keeps the icon [auto]
|
||||
- [ ] RGB lights (v1.27.0): an on light with a color tints its icon/glow and the ripple
|
||||
(explicit ripple color still wins); off/white lights unchanged [auto]
|
||||
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
|
||||
display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static
|
||||
- [ ] Island rooms (v1.34.0): a contour drawn fully inside an existing room
|
||||
(or around one) saves as a nested room — column in a ring, inner room;
|
||||
the parent's fill renders with an evenodd hole so the ring paints
|
||||
correctly; the island stays clickable; partial overlaps and duplicate
|
||||
outlines are still rejected at closing [auto]
|
||||
- [ ] Icon stays on edit (v1.33.4): rebinding a device (HA device/entity) or
|
||||
changing its room within the same space never moves the icon — the saved
|
||||
or auto position migrates to the new marker id; only a brand-new icon or
|
||||
a move to another space centers it in the target room [auto]
|
||||
- [ ] Icon picker placeholder (v1.33.3): with no explicit icon the device
|
||||
dialog's icon picker shows the auto-derived icon as its placeholder, plus
|
||||
an "Auto: mdi:..." hint line with the icon preview; the hint disappears
|
||||
once an explicit icon is picked [auto]
|
||||
- [ ] No Reset button (v1.33.2): the Device editor toolbar has three tools —
|
||||
add, show all, icon rules; the layout-wiping Reset is gone [auto]
|
||||
- [ ] Grid in all editors + decor fade (v1.33.1): the dot grid shows in the
|
||||
Device and Background editors too (instant "I'm editing" cue), not in
|
||||
View; in the Background editor rooms/devices/openings/labels fade to 35%
|
||||
while decor shapes stay fully opaque; no fade in the other editors [auto]
|
||||
- [ ] Background editor (v1.33.0): third tab with its own toolbar (select /
|
||||
line / rect / oval / text / erase + color, width, fill, X); shapes drag-
|
||||
drawn with grid snap and live preview; degenerate shapes dropped; text
|
||||
via dialog (S/M/L, color; dblclick re-edits); Select moves (snap), Delete
|
||||
removes, Erase deletes on click; Esc: draft → selection → select tool →
|
||||
View; decor renders under rooms, visible everywhere, inert outside the
|
||||
editor; stored in space.decor (rev/lock, backend schema) [auto]
|
||||
- [ ] Opening hover preview (v1.32.1): with the Opening tool, hovering near a
|
||||
wall shows a dashed 90 cm ghost snapped onto the wall (with a center
|
||||
dot); no ghost far from walls, over an existing opening (click = edit),
|
||||
or in other tools [auto]
|
||||
- [ ] Split polyline + cursors + Esc (v1.32.0): Merge shows a pointer cursor,
|
||||
Split shows pointer until a room is picked then crosshair; the cut can be
|
||||
a polyline — start on a wall, intermediate clicks inside the room, finish
|
||||
on a wall (path drawn live, walls/self-crossing rejected); Esc walks back:
|
||||
last cut point → room pick → back to the Draw tool (same for Merge:
|
||||
selection → tool) [auto]
|
||||
- [ ] Merge/split pick highlight (v1.31.2): the first room clicked with the
|
||||
Merge tool (and the split-selected room) gets an amber outline + fill;
|
||||
visible over the blue markup outlines [auto]
|
||||
- [ ] Card vs tool conflict (v1.31.1): in the Plan editor, dragging/resizing or
|
||||
clicking a room card never feeds the active tool (no draw point, no
|
||||
delete-room confirm, no merge/split pick); clicks past the card work [auto]
|
||||
- [ ] Room cards (v1.31.0): with metrics enabled in space settings (4
|
||||
checkboxes: temperature, humidity, avg Zigbee, lights) the room name gets
|
||||
a smaller metrics line under it; lights show On/Off or "1 of 3" when
|
||||
partially lit; rooms without an HA area show the name only; in the Plan
|
||||
editor cards show the name only, are draggable and resizable via corner
|
||||
handles on hover (uniform scale 0.5–3, stored in layout, survives drag);
|
||||
View mode has no handles/hover [auto]
|
||||
- [ ] Esc closes dialogs (v1.30.4): Escape closes the topmost dialog (opening
|
||||
info, device info, icon rules, general settings, device editor, opening
|
||||
editor, space dialog incl. abandoning an import queue); stacked dialogs
|
||||
close one per press; Esc while drawing still undoes the last point [auto]
|
||||
- [ ] General settings gear (v1.30.3): the header cog is visible in every mode
|
||||
(admins), opens the palette dialog from View too [auto]
|
||||
- [ ] Editor tabs (v1.30.2): only two tabs — "Plan editor" / "Device editor"
|
||||
(no View button; View is the default state); clicking a tab opens its
|
||||
bottom toolbar (Devices got its own bar with add/show-all/reset/rules);
|
||||
the bar and the active tab both show an X that returns to View; re-click
|
||||
on the active tab does nothing; Plan↔Devices switches directly [auto]
|
||||
- [ ] Space gear (v1.30.1): the cog next to the space name is visible in every
|
||||
mode (admins only), vertically centered with the tab text; clicking it
|
||||
opens space settings without switching the tab; "+" tab stays Plan-only [auto]
|
||||
- [ ] Lock action (v1.30.0): opening info card (View) shows Unlock (red) when
|
||||
locked / Lock when unlocked; button calls the lock service; disabled while
|
||||
locking/unlocking; hidden when unavailable; plan-icon tap still never
|
||||
toggles a lock [auto]
|
||||
- [ ] New-device flag (v1.29.0): a device added to HA after install gets a big red
|
||||
dot top-right of its icon (all clients); opening its editor clears it
|
||||
everywhere; upgrade/first-run seeds the baseline silently — no dot flood [auto]
|
||||
- [ ] No devices at all in HA (fresh instance) → plan renders, "0 dev.", no console errors [auto]
|
||||
|
||||
## Device dialog (markers) ★
|
||||
@@ -90,9 +217,12 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
- [ ] Open via info card → Edit; all fields persist (name, icon, model, link, description)
|
||||
- [ ] Rebind to another device/entity/helper: search filters; already-placed candidates excluded; old position cleaned up [auto backend]
|
||||
- [ ] Virtual device: requires name; room required; renders dashed
|
||||
- [ ] Sub-area rooms (v1.28.0): a room WITHOUT an HA area appears in the marker
|
||||
room list ("no area, manual"); a device placed there lands at its centre,
|
||||
the marker stores room_id, reopening the dialog restores the choice [auto]
|
||||
- [ ] Room override moves the icon to the room center
|
||||
- [ ] Tap-action override select (default/info/more-info/toggle) saves and applies
|
||||
- [ ] PDF/manual upload: ok path; >25 MB → readable error; .exe → bad-ext error [auto backend]; traversal names sanitized [auto backend]
|
||||
- [ ] PDF/manual upload: ok path; >50 MB → readable error; .exe → bad-ext error [auto backend]; traversal names sanitized [auto backend]
|
||||
- [ ] `javascript:` in the link field is not rendered as a clickable link [auto]
|
||||
- [ ] Remove: auto device → hidden marker (reappears via dialog "show all"? no — stays hidden until re-added); virtual → gone incl. its layout entry [auto backend]
|
||||
|
||||
@@ -120,7 +250,7 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
- [ ] Zoom level persists per space (localStorage), restored on reload
|
||||
- [ ] Window resize / sidebar collapse refits without distortion
|
||||
- [ ] Room name labels: default at room center; dragging moves and persists (server layout, `rl_*`) [auto]; hidden in markup mode
|
||||
- [ ] Labels legible on light plans (text shadow) at min/max zoom
|
||||
- [ ] Labels legible on light and dark plans (no text shadow) at min/max zoom
|
||||
|
||||
## Multi-client & concurrency ★
|
||||
|
||||
@@ -155,6 +285,16 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
|
||||
## Last self-run
|
||||
|
||||
**v1.21.1 (2026-07-16), full audit of v1.16–v1.21.** All `[auto]` items pass (73 frontend
|
||||
tests, 12 backend). New smokes on the synthetic home: `smoke_merge_split` (merge fuses
|
||||
adjacent rooms keeping the survivor's id; non-adjacent refused with a toast; split creates
|
||||
the new room, cancel keeps the room whole, along-wall cut refused) and `smoke_split_nonsnap`.
|
||||
Finding turned into a fix (shipped this release): **Split required the click to land on a grid
|
||||
node**, so it silently failed on rooms whose walls are not grid-aligned (imported/legacy
|
||||
polygons) — the click now snaps to the nearest wall instead of the grid, and `splitRoom()`
|
||||
still rejects a bad cut. README (en+ru) gained the merge/split/ruler/scale documentation it
|
||||
was missing. The earlier self-run record follows.
|
||||
|
||||
**v1.14.0 (2026-07-06), headless demo harness + unit suites.** All `[auto]` items pass
|
||||
(43 frontend tests, 11 pure + 12 HA-harness backend tests, `smoke_space_settings`,
|
||||
tap/hold/wizard/rules smokes). Bugs found during the run, fixed in the same release:
|
||||
@@ -168,3 +308,46 @@ tap/hold/wizard/rules smokes). Bugs found during the run, fixed in the same rele
|
||||
conflict without resync — fixed in v1.13.2.
|
||||
Unchecked boxes above (real browsers/devices, multi-tab live sync, Companion apps)
|
||||
require hands on real hardware — they remain for the human pass.
|
||||
|
||||
## houseplan-space-card (read-only embedded)
|
||||
- [ ] `type: custom:houseplan-space-card, space: <id>` renders the space identical to the full
|
||||
card's plan (background + configured borders/names + room fills + icons), no header/controls [auto]
|
||||
- [ ] The schematic is fully non-interactive: click/hover anywhere does nothing — no more-info,
|
||||
no tooltip, no drag (`.hp-static-stage` is pointer-events:none) [auto]
|
||||
- [ ] Footer button opens the full component already showing that space (deep-link `#space=<id>`) [auto]
|
||||
- [ ] Several cards with different `space` coexist on one board; one shared config WS request
|
||||
- [ ] Unknown `space` → tidy error card [auto]
|
||||
- [ ] `show_button: false` hides the footer
|
||||
- [ ] Full card honours `#space=<id>` on load and on hashchange; invalid id ignored [auto]
|
||||
|
||||
## Presence ripples / per-device icon (v1.22.0)
|
||||
|
||||
- [ ] Marker dialog → Display = "Ripple only": the icon badge disappears, rings pulse while the
|
||||
entity is on, and collapse to a faint dot when it goes off
|
||||
- [ ] Display = "Icon + ripple": both the icon and the rings are drawn
|
||||
- [ ] Ripple colour and size (×2..×8) apply per device
|
||||
- [ ] An entity going `unavailable` stops the pulsing (idle dot), never leaves it running
|
||||
- [ ] Icon size ×0.5..×3 and rotation 0..350° apply per device; the temp/humidity badges
|
||||
scale with the icon
|
||||
- [ ] Ripples still work with the card-wide "live states" toggle OFF (they are opt-in per device)
|
||||
- [ ] With OS "reduce motion" enabled, rings do not animate
|
||||
|
||||
## Doors & windows (v1.23.0)
|
||||
|
||||
- [ ] Markup → "Opening": a click away from any wall shows a toast; near a wall — the dialog
|
||||
- [ ] A door placed on a wall renders jambs + leaf + swing arc at the wall's angle; length in cm
|
||||
matches the ruler/scale of the space
|
||||
- [ ] Bind a contact sensor: open → leaf swings and the arc draws on in the accent colour;
|
||||
closed → leaf lies along the wall, arc hidden; invert flips this
|
||||
- [ ] Sensor unavailable → the opening freezes at its static default (door open / window closed)
|
||||
- [ ] A door with a lock shows the padlock badge: green locked / orange unlocked / grey unknown
|
||||
- [ ] Clicking an opening (or the padlock) in view mode opens the info card with both states;
|
||||
the lock can NOT be toggled from the plan
|
||||
- [ ] Flip toggles mirror the hinge side and the swing side
|
||||
- [ ] Click an existing opening with the tool → edit dialog; Delete removes it
|
||||
- [ ] (v1.23.1) Hovering an opening in view mode shows the accent outline + grab cursor
|
||||
- [ ] Dragging an opening slides it along walls (re-snapping, incl. around corners) and saves
|
||||
on release; dragging far away from walls leaves it in place; hinge does not flip while
|
||||
crossing wall-segment boundaries
|
||||
- [ ] Single click still opens the status card; double click opens the properties dialog;
|
||||
a drag does NOT open either
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# UX redesign: three modes (approved 2026-07-21)
|
||||
|
||||
> Approved design for reorganizing all card interactions into three tab-like modes.
|
||||
> Driven by the owner's mandate and confirmed by real user feedback
|
||||
> ([issue #3](https://github.com/Matysh/houseplan-card/issues/3): *"When moving the
|
||||
> map around, I sometimes move the doors/sensors around"*). This document is the
|
||||
> source of truth for the implementation iterations below. No code has been changed yet.
|
||||
|
||||
## Principle
|
||||
|
||||
A segmented control in the card header with three tabs; the active one is visually
|
||||
highlighted, and edit modes add a colored frame around the stage so the mode is
|
||||
obvious at a glance:
|
||||
|
||||
**[ 📐 Plan editor ] [ 🔧 Device editor ] [ ✏️ Background editor ]** — View has
|
||||
NO tab (since v1.30.2). The Background editor (v1.33.0) manages a purely visual
|
||||
decor layer (lines/rects/ovals/text in `space.decor`, drawn under the rooms,
|
||||
inert everywhere outside its editor).
|
||||
|
||||
- **View** is the implicit default state: no editor tab is active. It is the only
|
||||
state after every load (edit modes are never restored across reloads).
|
||||
- Activating an editor tab highlights it and opens that editor's bottom toolbar
|
||||
(both editors have one since v1.30.2). The toolbar and the active tab each
|
||||
carry an **X** that closes the editor back to View; re-clicking the active tab
|
||||
does nothing; Plan↔Devices switches directly.
|
||||
- **Plan editor** and **Device editor** are shown only to admins when
|
||||
`admin_only` is on.
|
||||
|
||||
## View — display and device interaction only
|
||||
|
||||
Allowed: pan/zoom (wheel, pinch, buttons), switching spaces, device tap
|
||||
(info / more-info / toggle per settings), long-press → info card, opening tap →
|
||||
door/lock info card (with an explicit Unlock/Lock button when a lock is bound —
|
||||
the only way to operate a lock from the card; plan-icon taps never toggle locks),
|
||||
room tap → HA area, hover tooltips (name, temperature, signal).
|
||||
|
||||
Removed from this mode (they move, not die):
|
||||
- icon dragging ("drag anywhere", v1.9 — consciously reversed),
|
||||
- room-label dragging,
|
||||
- opening dragging along walls and double-click properties (v1.23.1),
|
||||
- every edit button in the header (+device, 👁 show-all, ↺ reset, ⬡ rules, ⚙ general,
|
||||
per-space gear, markup toggle).
|
||||
|
||||
Header in View: space tabs, device count, zoom cluster. Nothing else.
|
||||
|
||||
## Plan — geometry and appearance of the space
|
||||
|
||||
- Toolbar tools: Outline room, Delete room, Merge, Split, Opening (place / drag along
|
||||
walls / properties), Room labels (drag positions — labels are part of the plan).
|
||||
- Space gear dialog (title, plan image / hand-drawn, scale, Display section, show_lqi),
|
||||
add space, floors import, delete space.
|
||||
- ⚙ General settings (fill palette) lives here — it is about the plan's appearance.
|
||||
|
||||
## Devices — placement and marker configuration
|
||||
|
||||
- Icon dragging (ONLY here). Click on a device opens the **edit dialog directly**
|
||||
(binding, name, icon, size/angle, display badge/ripple + colors, tap override,
|
||||
model/link/description/PDFs, room).
|
||||
- + add device/entity/virtual, hide device, ↺ reset layout, 👁 show-all (curation
|
||||
tool), ⬡ icon rules.
|
||||
|
||||
## Deprecations decided
|
||||
|
||||
1. "Drag anywhere" (v1.9) — reversed by this design.
|
||||
2. Opening drag / dbl-click properties in view (v1.23.1) — moved into Plan.
|
||||
3. The markup toggle button — replaced by the Plan tab.
|
||||
4. Legacy localStorage mode (card without the integration) — candidate for removal
|
||||
in the next major; adds branching for a half-working scenario.
|
||||
|
||||
## Approved follow-up features (from issue #3, by priority)
|
||||
|
||||
1. State-reflecting icons (open/closed door variants etc., like core HA).
|
||||
2. `display: value` — show the measurement instead of an icon.
|
||||
3. Light color in the icon/ripple (RGB lights).
|
||||
4. Alarm visual (leak/smoke/doorbell): red pulse overlay.
|
||||
5. Rooms as sub-areas without an HA area + manual device placement by room id.
|
||||
6. Backlog (not planned): music notes for players, directional TV ripples.
|
||||
|
||||
## Implementation iterations
|
||||
|
||||
- **It.1 — mode shell:** the segmented control, mode state, View-mode gating of all
|
||||
edit interactions/buttons (biggest UX win, smallest surface).
|
||||
- **It.2 — Plan tab:** move markup tools + space dialogs + labels drag + openings
|
||||
editing under Plan; colored frame indicator.
|
||||
- **It.3 — Devices tab:** drag + direct-edit click + curation tools under Devices.
|
||||
- **It.4+:** follow-up features 1–5 above, each its own release.
|
||||
Generated
+29
-3
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.15.0",
|
||||
"version": "1.23.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.15.0",
|
||||
"version": "1.23.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3"
|
||||
"lit": "^3.1.3",
|
||||
"polyclip-ts": "^0.16.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
@@ -597,6 +598,15 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bignumber.js": {
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
||||
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
@@ -797,6 +807,16 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/polyclip-ts": {
|
||||
"version": "0.16.8",
|
||||
"resolved": "https://registry.npmjs.org/polyclip-ts/-/polyclip-ts-0.16.8.tgz",
|
||||
"integrity": "sha512-JPtKbDRuPEuAjuTdhR62Gph7Is2BS1Szx69CFOO3g71lpJDFo78k4tFyi+qFOMVPePEzdSKkpGU3NBXPHHjvKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bignumber.js": "^9.1.0",
|
||||
"splaytree-ts": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/randombytes": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||
@@ -936,6 +956,12 @@
|
||||
"source-map": "^0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/splaytree-ts": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/splaytree-ts/-/splaytree-ts-1.0.2.tgz",
|
||||
"integrity": "sha512-0kGecIZNIReCSiznK3uheYB8sbstLjCZLiwcQwbmLhgHJj2gz6OnSPkVzJQCMnmEz1BQ4gPK59ylhBoEWOhGNA==",
|
||||
"license": "BDS-3-Clause"
|
||||
},
|
||||
"node_modules/supports-preserve-symlinks-flag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.15.6",
|
||||
"version": "1.34.0",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -22,6 +22,7 @@
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3"
|
||||
"lit": "^3.1.3",
|
||||
"polyclip-ts": "^0.16.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Module-level houseplan config cache shared by every embedded card on a board,
|
||||
* so N cards do NOT issue N identical `houseplan/config/get` requests. The cache
|
||||
* is invalidated when the integration emits `houseplan_config_updated`
|
||||
* (fired on config/set); subscribers are then notified to reload.
|
||||
*
|
||||
* The full `houseplan-card` already persists a `{config, rev, layout}` snapshot in
|
||||
* localStorage (`houseplan_card_cfg_v1`) for its own instant start — we seed from it
|
||||
* so embedded cards paint immediately, then refresh from the server in the background.
|
||||
*/
|
||||
const LS_CFG = 'houseplan_card_cfg_v1';
|
||||
|
||||
export interface HpConfigSnapshot {
|
||||
config: any | null;
|
||||
rev: number;
|
||||
layout: Record<string, any>;
|
||||
}
|
||||
|
||||
let cache: HpConfigSnapshot | null = null;
|
||||
let inflight: Promise<HpConfigSnapshot> | null = null;
|
||||
let subscribed = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
/** Instant, synchronous best-effort snapshot from the full card's localStorage cache. */
|
||||
export function cachedSnapshot(): HpConfigSnapshot | null {
|
||||
if (cache) return cache;
|
||||
try {
|
||||
const c = JSON.parse(localStorage.getItem(LS_CFG) || 'null');
|
||||
if (c && c.config && Array.isArray(c.config.spaces)) {
|
||||
return { config: c.config, rev: c.rev || 0, layout: c.layout || {} };
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchFresh(hass: any): Promise<HpConfigSnapshot> {
|
||||
const [cfgResp, layResp] = await Promise.all([
|
||||
hass.callWS({ type: 'houseplan/config/get' }),
|
||||
hass.callWS({ type: 'houseplan/layout/get' }),
|
||||
]);
|
||||
cache = {
|
||||
config: cfgResp?.config ?? null,
|
||||
rev: cfgResp?.rev ?? 0,
|
||||
layout: layResp?.layout ?? {},
|
||||
};
|
||||
if (!subscribed && hass.connection?.subscribeEvents) {
|
||||
subscribed = true;
|
||||
try {
|
||||
await hass.connection.subscribeEvents(() => {
|
||||
cache = null; // invalidate; listeners reload
|
||||
listeners.forEach((l) => l());
|
||||
}, 'houseplan_config_updated');
|
||||
} catch {
|
||||
subscribed = false;
|
||||
}
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
/** Get the shared config snapshot (cached, deduped across cards). */
|
||||
export function getConfig(hass: any): Promise<HpConfigSnapshot> {
|
||||
if (cache) return Promise.resolve(cache);
|
||||
if (inflight) return inflight;
|
||||
inflight = fetchFresh(hass).finally(() => {
|
||||
inflight = null;
|
||||
});
|
||||
return inflight;
|
||||
}
|
||||
|
||||
/** Subscribe to config-changed notifications; returns an unsubscribe function. */
|
||||
export function onConfigChange(cb: () => void): () => void {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
}
|
||||
+79
-2
@@ -104,6 +104,27 @@ export function tempFor(hass: any, entIds: string[]): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A humidity-measuring entity (device_class humidity or *_humidity), excluding diagnostics. */
|
||||
export function isHumEntity(hass: any, eid: string): boolean {
|
||||
if (hass.entities?.[eid]?.entity_category) return false;
|
||||
const st = hass.states[eid];
|
||||
if (!st) return /_humidity$/.test(eid);
|
||||
const a = st.attributes || {};
|
||||
return a.device_class === 'humidity' || (a.unit_of_measurement === '%' && /_humidity$/.test(eid)) || /_humidity$/.test(eid);
|
||||
}
|
||||
|
||||
/** First readable humidity value (integer %) among the entities, or null. */
|
||||
export function humFor(hass: any, entIds: string[]): number | null {
|
||||
for (const eid of entIds) {
|
||||
if (!isHumEntity(hass, eid)) continue;
|
||||
const st = hass.states[eid];
|
||||
if (!st) continue;
|
||||
const v = parseFloat(st.state);
|
||||
if (!isNaN(v)) return Math.round(v);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Group light entities: HA light-group (platform=group) and Z2M groups (device model=Group). */
|
||||
export function lightGroups(hass: any, enabled: boolean): { eid: string; name: string; area: string }[] {
|
||||
if (!enabled) return [];
|
||||
@@ -206,6 +227,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
};
|
||||
item.primary = primaryEntity(h, entIds, icon);
|
||||
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, entIds);
|
||||
if (item.primary && isHumEntity(h, item.primary)) item.hum = humFor(h, entIds);
|
||||
rest.push(item);
|
||||
}
|
||||
|
||||
@@ -254,6 +276,8 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
};
|
||||
item.primary = primaryEntity(h, entIds, icon);
|
||||
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, entIds);
|
||||
if (item.primary && isHumEntity(h, item.primary)) item.hum = humFor(h, entIds);
|
||||
if (item.primary && isHumEntity(h, item.primary)) item.hum = humFor(h, entIds);
|
||||
applyMarker(item, m);
|
||||
rest.push(item);
|
||||
} else if (kind === 'entity') {
|
||||
@@ -261,18 +285,23 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
const area = m.area || reg?.area_id || (reg?.device_id && h.devices[reg.device_id]?.area_id) || '';
|
||||
const space = (area && areaToSpace[area]) || m.space || firstSpaceId;
|
||||
const st = h.states[ref];
|
||||
const nm = reg?.name || st?.attributes?.friendly_name || ref;
|
||||
let icon = resolveIcon(h, nm, '', [ref], iconRules);
|
||||
if (ref.startsWith('lock.')) icon = 'mdi:lock';
|
||||
const item: DevItem = {
|
||||
id: m.id,
|
||||
name: reg?.name || st?.attributes?.friendly_name || ref,
|
||||
name: nm,
|
||||
model: '',
|
||||
area,
|
||||
space,
|
||||
icon: 'mdi:shape-outline',
|
||||
icon,
|
||||
entities: [ref],
|
||||
primary: ref,
|
||||
bindingKind: 'entity',
|
||||
bindingRef: ref,
|
||||
};
|
||||
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, [ref]);
|
||||
if (isHumEntity(h, ref)) item.hum = humFor(h, [ref]);
|
||||
applyMarker(item, m);
|
||||
rest.push(item);
|
||||
} else {
|
||||
@@ -314,7 +343,55 @@ export function areaLights(hass: any, devices: { area: string; entities: string[
|
||||
return seen ? 'off' : 'none';
|
||||
}
|
||||
|
||||
/** Average humidity across the area's climate-ish devices (integer %, or null). */
|
||||
export function areaHum(
|
||||
hass: any,
|
||||
devices: { area: string; icon?: string; entities: string[] }[],
|
||||
area: string,
|
||||
): number | null {
|
||||
const vals: number[] = [];
|
||||
for (const dv of devices) {
|
||||
if (dv.area !== area) continue;
|
||||
// same curation idea as areaTemp: climate sensors only, not fridges/plugs
|
||||
if (dv.icon !== 'mdi:thermometer' && dv.icon !== 'mdi:air-filter' && dv.icon !== 'mdi:water-percent') continue;
|
||||
const h = humFor(hass, dv.entities);
|
||||
if (h != null) vals.push(h);
|
||||
}
|
||||
if (!vals.length) return null;
|
||||
return Math.round(vals.reduce((a, b) => a + b, 0) / vals.length);
|
||||
}
|
||||
|
||||
/** 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[] }[],
|
||||
area: string,
|
||||
): { on: number; total: number } | null {
|
||||
const seen = new Set<string>();
|
||||
let on = 0;
|
||||
for (const dv of devices) {
|
||||
if (dv.area !== area) continue;
|
||||
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;
|
||||
}
|
||||
|
||||
/** Average temperature across the area's devices (null when nothing reports one). */
|
||||
/** Average zigbee signal (LQI) across an area's non-virtual devices, or null. */
|
||||
export function areaLqi(hass: any, devices: { area: string; virtual?: boolean; entities: string[] }[], area: string): number | null {
|
||||
const vals: number[] = [];
|
||||
for (const d of devices) {
|
||||
if (d.area !== area || d.virtual) continue;
|
||||
const l = lqiFor(hass, d.entities);
|
||||
if (l != null) vals.push(l);
|
||||
}
|
||||
return averageLqi(vals);
|
||||
}
|
||||
|
||||
export function areaTemp(
|
||||
hass: any,
|
||||
devices: { area: string; icon?: string; entities: string[] }[],
|
||||
|
||||
+1693
-185
File diff suppressed because it is too large
Load Diff
+115
-5
@@ -22,18 +22,40 @@
|
||||
"title.zoom_reset": "Reset zoom",
|
||||
"title.add_device": "Add a device to the plan",
|
||||
"title.show_all": "Show all area devices (no curation)",
|
||||
"title.reset_layout": "Reset icon positions to auto layout",
|
||||
"title.markup": "Room markup: grid, lines, outlines",
|
||||
"title.configure_space": "Configure space",
|
||||
"title.add_space": "Add space",
|
||||
"title.markup_add": "Add a room: connect grid dots with lines until the outline closes",
|
||||
"title.markup_erase": "Erase a line: click the line",
|
||||
"title.markup_merge": "Merge rooms: click one room, then the neighbour it shares a wall with",
|
||||
"title.markup_split": "Split a room: click the room, then two points on its walls",
|
||||
"title.markup_delroom": "Delete a room: click inside the room",
|
||||
"title.no_area_room": "Decorative room without an HA area (e.g. a hallway)",
|
||||
"title.choose_area": "Select a Home Assistant area",
|
||||
"title.need_plan": "Upload a floor-plan image",
|
||||
"markup.add": "Add",
|
||||
"markup.erase": "Erase",
|
||||
"markup.merge": "Merge",
|
||||
"markup.split": "Split",
|
||||
"markup.opening": "Opening",
|
||||
"title.markup_opening": "Doors & windows: click a wall to place, click an opening to edit",
|
||||
"opening.new": "New opening",
|
||||
"opening.edit": "Door / window",
|
||||
"opening.door": "Door",
|
||||
"opening.window": "Window",
|
||||
"opening.type_label": "Type",
|
||||
"opening.length_label": "Length, cm",
|
||||
"opening.contact_label": "Open/close sensor",
|
||||
"opening.lock_label": "Lock",
|
||||
"opening.none": "— none —",
|
||||
"opening.invert": "Invert open/closed",
|
||||
"opening.flip_h": "Hinge on the other jamb",
|
||||
"opening.flip_v": "Opens to the other side",
|
||||
"opening.open": "Open",
|
||||
"opening.closed": "Closed",
|
||||
"opening.locked": "Locked",
|
||||
"opening.unlocked": "Unlocked",
|
||||
"opening.state_unknown": "unavailable",
|
||||
"opening.no_entities": "No sensors bound — a static symbol on the plan.",
|
||||
"toast.opening_no_wall": "Click next to a room wall — openings sit on walls",
|
||||
"markup.delete": "Delete",
|
||||
"markup.hint_points": "points: {n} · Esc/Ctrl+Z — undo a dot · close the outline by clicking the first one",
|
||||
"markup.hint_start": "click a grid dot to start the outline",
|
||||
@@ -58,6 +80,13 @@
|
||||
"marker.room_auto": "— by device area (auto) —",
|
||||
"marker.icon_label": "Icon",
|
||||
"marker.icon_ph": "mdi:… (empty = auto)",
|
||||
"marker.display_label": "Display",
|
||||
"display.badge": "Icon badge",
|
||||
"display.ripple": "Ripple only",
|
||||
"display.icon_ripple": "Icon + ripple",
|
||||
"marker.ripple_size": "Ripple size",
|
||||
"marker.size_label": "Icon size / rotation",
|
||||
"marker.angle_label": "Rotate",
|
||||
"marker.model_label": "Model",
|
||||
"marker.model_ph": "e.g. Aqara T&H",
|
||||
"marker.link_label": "Link",
|
||||
@@ -85,7 +114,6 @@
|
||||
"device.light_group": "light group",
|
||||
"device.fallback": "device",
|
||||
"device.virtual": "virtual device",
|
||||
"confirm.reset_layout": "Reset all icon positions to the auto layout?",
|
||||
"confirm.delete_room": "Delete room \"{name}\"?",
|
||||
"confirm.remove_marker": "Remove \"{name}\" from the plan?",
|
||||
"confirm.delete_space": "Delete space \"{title}\" with all its rooms and markup?",
|
||||
@@ -94,6 +122,16 @@
|
||||
"toast.markup_needs_server": "Markup is available after the config is moved to the server",
|
||||
"toast.conflict": "Config was changed in another window — data refreshed, repeat your last action",
|
||||
"toast.cfg_save_failed": "Failed to save config: {err}",
|
||||
"toast.room_overlap": "The outline overlaps room “{name}” — rooms must not overlap",
|
||||
"toast.merge_not_adjacent": "Only rooms that share a wall can be merged",
|
||||
"toast.rooms_merged": "Rooms merged into “{name}”",
|
||||
"toast.split_pick_wall": "Start the cut on the room’s wall",
|
||||
"toast.split_bad_cut": "The cut must run wall to wall inside the room, without crossing walls or itself",
|
||||
"merge.header": "Merge rooms",
|
||||
"merge.hint": "The merged room keeps one name and one area. The other area is released — its devices leave the plan until another room claims it.",
|
||||
"merge.keep": "Keep",
|
||||
"merge.no_area": "no area",
|
||||
"room.split_header": "New room from the split",
|
||||
"toast.room_saved": "Room saved ({n}). Devices added: {added}. Outline the next one or exit markup.",
|
||||
"toast.room_saved_no_area": "Room saved ({n}, no area). Outline the next one or exit markup.",
|
||||
"toast.marker_needs_server": "Device editing is available after the config is moved to the server",
|
||||
@@ -152,6 +190,8 @@
|
||||
"import.progress": "Floor {i} of {n}",
|
||||
"import.done": "Spaces created. Outline the rooms: click grid dots and close the contour.",
|
||||
"btn.skip": "Skip",
|
||||
"space.scale_label": "Scale (grid cell size)",
|
||||
"space.scale_unit": "cm per cell",
|
||||
"space.display_section": "Display",
|
||||
"space.show_borders": "Always show room borders",
|
||||
"space.show_names": "Show room names (drag to move)",
|
||||
@@ -170,5 +210,75 @@
|
||||
"fill.temp": "Temperature",
|
||||
"space.temp_min": "Comfort from",
|
||||
"space.temp_max": "to",
|
||||
"tip.temp_avg": "average temperature:"
|
||||
"tip.temp_avg": "average temperature:",
|
||||
"space_card.button": "Open the space plan",
|
||||
"space_card.not_found": "Space “{id}” not found",
|
||||
"space_card.loading": "Loading…",
|
||||
"editor.space": "Space",
|
||||
"editor.show_button": "Show button",
|
||||
"editor.button_label": "Button label",
|
||||
"editor.button_target": "Target dashboard path",
|
||||
"editor.aspect_ratio": "Aspect ratio (e.g. 16:9 or auto)",
|
||||
"marker.sub_entity": "entity",
|
||||
"title.general_settings": "General settings",
|
||||
"gs.title": "General settings",
|
||||
"gs.hint": "Fill colors apply to every space; each color has its own opacity. Which fill mode a space uses is set in that space's dialog.",
|
||||
"gs.light_group": "Fill: lights",
|
||||
"gs.light_on": "Lights on",
|
||||
"gs.light_off": "All lights off",
|
||||
"gs.temp_group": "Fill: temperature",
|
||||
"gs.temp_cold": "Cold",
|
||||
"gs.temp_ok": "Comfortable",
|
||||
"gs.temp_hot": "Hot",
|
||||
"gs.lqi_group": "Fill: zigbee signal",
|
||||
"gs.lqi_low": "Weak signal",
|
||||
"gs.lqi_high": "Strong signal",
|
||||
"gs.reset": "Reset to defaults",
|
||||
"gs.saved": "General settings saved",
|
||||
"space.show_lqi": "Show zigbee signal (LQI) next to devices",
|
||||
"gs.light_none": "No light sources",
|
||||
"mode.plan": "Plan editor",
|
||||
"mode.devices": "Device editor",
|
||||
"display.value": "Value instead of an icon",
|
||||
"marker.subarea": "no area, manual",
|
||||
"device.new": "New device — open its editor to dismiss",
|
||||
"opening.unlock_action": "Unlock",
|
||||
"opening.lock_action": "Lock",
|
||||
"opening.lock_pending": "Working…",
|
||||
"title.close_editor": "Close editor (back to view)",
|
||||
"devbar.add": "Add",
|
||||
"devbar.show_all": "Show all",
|
||||
"devbar.rules": "Icon rules",
|
||||
"space.roomcard_section": "Room card shows:",
|
||||
"space.label_temp": "Temperature",
|
||||
"space.label_hum": "Humidity",
|
||||
"space.label_lqi": "Average Zigbee signal",
|
||||
"space.label_light": "Lights on/off",
|
||||
"roomcard.light_on": "On",
|
||||
"roomcard.light_off": "Off",
|
||||
"roomcard.light_partial": "{on} of {total}",
|
||||
"toast.split_pick_inside": "Intermediate cut points must be inside the room",
|
||||
"mode.decor": "Background editor",
|
||||
"decor.select": "Select",
|
||||
"decor.line": "Line",
|
||||
"decor.rect": "Rectangle",
|
||||
"decor.ellipse": "Oval",
|
||||
"decor.text": "Text",
|
||||
"decor.erase": "Erase",
|
||||
"decor.color": "Color",
|
||||
"decor.width": "Line width",
|
||||
"decor.w_thin": "Thin",
|
||||
"decor.w_mid": "Medium",
|
||||
"decor.w_thick": "Thick",
|
||||
"decor.fill": "Fill",
|
||||
"decor.text_title": "Text label",
|
||||
"decor.text_label": "Text",
|
||||
"decor.text_size": "Size",
|
||||
"decor.size_s": "Small",
|
||||
"decor.size_m": "Medium",
|
||||
"decor.size_l": "Large",
|
||||
"marker.icon_auto": "Auto: {icon} (by icon rules; pick one to override)",
|
||||
"mode.plan_tip": "Plan editor — the geometry of the home: draw and split/merge rooms, bind them to HA areas, place doors and windows, move room cards, set the scale",
|
||||
"mode.devices_tip": "Device editor — everything about icons: drag to position, click to edit binding/icon/display, add virtual devices, icon rules",
|
||||
"mode.decor_tip": "Background editor — purely visual decor under the plan: lines, rectangles, ovals and text labels that never react to clicks"
|
||||
}
|
||||
|
||||
+115
-5
@@ -22,18 +22,40 @@
|
||||
"title.zoom_reset": "Сбросить масштаб",
|
||||
"title.add_device": "Добавить устройство на план",
|
||||
"title.show_all": "Показывать все устройства зоны (без курирования)",
|
||||
"title.reset_layout": "Сбросить позиции значков к авто-раскладке",
|
||||
"title.markup": "Разметка комнат: сетка, линии, контуры",
|
||||
"title.configure_space": "Настроить пространство",
|
||||
"title.add_space": "Добавить пространство",
|
||||
"title.markup_add": "Добавить комнату: соединяйте точки сетки линиями до замкнутого контура",
|
||||
"title.markup_erase": "Стереть линию: клик по линии",
|
||||
"title.markup_merge": "Объединить комнаты: клик по одной, затем по соседней с общей стеной",
|
||||
"title.markup_split": "Разделить комнату: клик по комнате, затем две точки на её стенах",
|
||||
"title.markup_delroom": "Удалить комнату: клик внутри комнаты",
|
||||
"title.no_area_room": "Декоративная комната без привязки к зоне (например, холл)",
|
||||
"title.choose_area": "Выберите зону Home Assistant",
|
||||
"title.need_plan": "Загрузите подложку (план этажа)",
|
||||
"markup.add": "Добавить",
|
||||
"markup.erase": "Стереть",
|
||||
"markup.merge": "Объединить",
|
||||
"markup.split": "Разделить",
|
||||
"markup.opening": "Проём",
|
||||
"title.markup_opening": "Двери и окна: клик по стене — добавить, клик по проёму — редактировать",
|
||||
"opening.new": "Новый проём",
|
||||
"opening.edit": "Дверь / окно",
|
||||
"opening.door": "Дверь",
|
||||
"opening.window": "Окно",
|
||||
"opening.type_label": "Тип",
|
||||
"opening.length_label": "Длина, см",
|
||||
"opening.contact_label": "Датчик открытия",
|
||||
"opening.lock_label": "Замок",
|
||||
"opening.none": "— нет —",
|
||||
"opening.invert": "Инвертировать открыто/закрыто",
|
||||
"opening.flip_h": "Петли с другой стороны",
|
||||
"opening.flip_v": "Открывается в другую сторону",
|
||||
"opening.open": "Открыто",
|
||||
"opening.closed": "Закрыто",
|
||||
"opening.locked": "Заперто",
|
||||
"opening.unlocked": "Не заперто",
|
||||
"opening.state_unknown": "недоступно",
|
||||
"opening.no_entities": "Датчики не привязаны — статичный символ на плане.",
|
||||
"toast.opening_no_wall": "Кликните рядом со стеной комнаты — проёмы ставятся на стены",
|
||||
"markup.delete": "Удалить",
|
||||
"markup.hint_points": "точек: {n} · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой",
|
||||
"markup.hint_start": "кликните точку сетки, чтобы начать контур",
|
||||
@@ -58,6 +80,13 @@
|
||||
"marker.room_auto": "— по зоне устройства (авто) —",
|
||||
"marker.icon_label": "Иконка",
|
||||
"marker.icon_ph": "mdi:… (пусто = авто)",
|
||||
"marker.display_label": "Отображение",
|
||||
"display.badge": "Значок",
|
||||
"display.ripple": "Только пульсация",
|
||||
"display.icon_ripple": "Значок + пульсация",
|
||||
"marker.ripple_size": "Размер пульсации",
|
||||
"marker.size_label": "Размер / поворот значка",
|
||||
"marker.angle_label": "Поворот",
|
||||
"marker.model_label": "Модель",
|
||||
"marker.model_ph": "напр. Aqara T&H",
|
||||
"marker.link_label": "Ссылка",
|
||||
@@ -85,7 +114,6 @@
|
||||
"device.light_group": "группа света",
|
||||
"device.fallback": "устройство",
|
||||
"device.virtual": "виртуальное устройство",
|
||||
"confirm.reset_layout": "Сбросить позиции всех иконок к авто-раскладке?",
|
||||
"confirm.delete_room": "Удалить комнату «{name}»?",
|
||||
"confirm.remove_marker": "Убрать «{name}» с плана?",
|
||||
"confirm.delete_space": "Удалить пространство «{title}» со всеми комнатами и разметкой?",
|
||||
@@ -94,6 +122,16 @@
|
||||
"toast.markup_needs_server": "Разметка доступна после переноса конфига на сервер",
|
||||
"toast.conflict": "Конфиг изменён в другом окне — данные обновлены, повторите последнее действие",
|
||||
"toast.cfg_save_failed": "Не удалось сохранить конфиг: {err}",
|
||||
"toast.room_overlap": "Контур накладывается на комнату «{name}» — комнаты не должны накладываться",
|
||||
"toast.merge_not_adjacent": "Объединять можно только комнаты с общей стеной",
|
||||
"toast.rooms_merged": "Комнаты объединены в «{name}»",
|
||||
"toast.split_pick_wall": "Начните разрез на стене комнаты",
|
||||
"toast.split_bad_cut": "Разрез — от стены до стены внутри комнаты, без пересечения стен и самого себя",
|
||||
"merge.header": "Объединение комнат",
|
||||
"merge.hint": "У объединённой комнаты одно имя и одна зона. Вторая зона освобождается — её устройства уйдут с плана, пока их не заберёт другая комната.",
|
||||
"merge.keep": "Оставить",
|
||||
"merge.no_area": "без зоны",
|
||||
"room.split_header": "Новая комната после разделения",
|
||||
"toast.room_saved": "Комната сохранена ({n}). Устройств добавлено: {added}. Обведите следующую или выйдите из разметки.",
|
||||
"toast.room_saved_no_area": "Комната сохранена ({n}, без зоны). Обведите следующую или выйдите из разметки.",
|
||||
"toast.marker_needs_server": "Редактирование устройств доступно после переноса конфига на сервер",
|
||||
@@ -152,6 +190,8 @@
|
||||
"import.progress": "Этаж {i} из {n}",
|
||||
"import.done": "Пространства созданы. Обведите комнаты: кликайте по точкам сетки и замкните контур.",
|
||||
"btn.skip": "Пропустить",
|
||||
"space.scale_label": "Масштаб (размер клетки сетки)",
|
||||
"space.scale_unit": "см на клетку",
|
||||
"space.display_section": "Отображение",
|
||||
"space.show_borders": "Всегда отображать границы комнат",
|
||||
"space.show_names": "Отображать названия комнат (перетаскиваются)",
|
||||
@@ -170,5 +210,75 @@
|
||||
"fill.temp": "По температуре",
|
||||
"space.temp_min": "Комфорт от",
|
||||
"space.temp_max": "до",
|
||||
"tip.temp_avg": "средняя температура:"
|
||||
"tip.temp_avg": "средняя температура:",
|
||||
"space_card.button": "Перейти к пространству",
|
||||
"space_card.not_found": "Пространство «{id}» не найдено",
|
||||
"space_card.loading": "Загрузка…",
|
||||
"editor.space": "Пространство",
|
||||
"editor.show_button": "Показывать кнопку",
|
||||
"editor.button_label": "Текст кнопки",
|
||||
"editor.button_target": "Путь дашборда (куда вести)",
|
||||
"editor.aspect_ratio": "Соотношение сторон (напр. 16:9 или auto)",
|
||||
"marker.sub_entity": "сущность",
|
||||
"title.general_settings": "Общие настройки",
|
||||
"gs.title": "Общие настройки",
|
||||
"gs.hint": "Цвета заливок действуют на все пространства; у каждого цвета своя прозрачность. Какой режим заливки использует пространство — задаётся в его диалоге.",
|
||||
"gs.light_group": "Заливка: освещение",
|
||||
"gs.light_on": "Свет включён",
|
||||
"gs.light_off": "Весь свет выключен",
|
||||
"gs.temp_group": "Заливка: температура",
|
||||
"gs.temp_cold": "Холодно",
|
||||
"gs.temp_ok": "Комфорт",
|
||||
"gs.temp_hot": "Жарко",
|
||||
"gs.lqi_group": "Заливка: зигби-сигнал",
|
||||
"gs.lqi_low": "Слабый сигнал",
|
||||
"gs.lqi_high": "Сильный сигнал",
|
||||
"gs.reset": "Сбросить к умолчаниям",
|
||||
"gs.saved": "Общие настройки сохранены",
|
||||
"space.show_lqi": "Показывать зигби-сигнал (LQI) у устройств",
|
||||
"gs.light_none": "Нет источников света",
|
||||
"mode.plan": "Редактор плана",
|
||||
"mode.devices": "Редактор устройств",
|
||||
"display.value": "Значение вместо иконки",
|
||||
"marker.subarea": "без зоны, вручную",
|
||||
"device.new": "Новое устройство — откройте его редактор, чтобы снять отметку",
|
||||
"opening.unlock_action": "Открыть замок",
|
||||
"opening.lock_action": "Закрыть замок",
|
||||
"opening.lock_pending": "Выполняется…",
|
||||
"title.close_editor": "Закрыть редактор (вернуться к просмотру)",
|
||||
"devbar.add": "Добавить",
|
||||
"devbar.show_all": "Показать все",
|
||||
"devbar.rules": "Правила иконок",
|
||||
"space.roomcard_section": "В карточке комнаты:",
|
||||
"space.label_temp": "Температура",
|
||||
"space.label_hum": "Влажность",
|
||||
"space.label_lqi": "Средний Zigbee-сигнал",
|
||||
"space.label_light": "Свет вкл/выкл",
|
||||
"roomcard.light_on": "Вкл",
|
||||
"roomcard.light_off": "Выкл",
|
||||
"roomcard.light_partial": "{on} из {total}",
|
||||
"toast.split_pick_inside": "Промежуточные точки разреза — внутри комнаты",
|
||||
"mode.decor": "Редактор подложки",
|
||||
"decor.select": "Выбрать",
|
||||
"decor.line": "Линия",
|
||||
"decor.rect": "Прямоугольник",
|
||||
"decor.ellipse": "Овал",
|
||||
"decor.text": "Надпись",
|
||||
"decor.erase": "Стереть",
|
||||
"decor.color": "Цвет",
|
||||
"decor.width": "Толщина линии",
|
||||
"decor.w_thin": "Тонкая",
|
||||
"decor.w_mid": "Средняя",
|
||||
"decor.w_thick": "Толстая",
|
||||
"decor.fill": "Залить",
|
||||
"decor.text_title": "Надпись",
|
||||
"decor.text_label": "Текст",
|
||||
"decor.text_size": "Размер",
|
||||
"decor.size_s": "Мелкий",
|
||||
"decor.size_m": "Средний",
|
||||
"decor.size_l": "Крупный",
|
||||
"marker.icon_auto": "Авто: {icon} (по правилам иконок; выберите свою, чтобы заменить)",
|
||||
"mode.plan_tip": "Редактор плана — геометрия дома: рисование и объединение/разделение комнат, привязка к зонам HA, двери и окна, карточки комнат, масштаб",
|
||||
"mode.devices_tip": "Редактор устройств — всё про значки: перетаскивание, клик — настройка привязки/иконки/отображения, виртуальные устройства, правила иконок",
|
||||
"mode.decor_tip": "Редактор подложки — чисто визуальный декор под планом: линии, прямоугольники, овалы и надписи, не реагирующие на клики"
|
||||
}
|
||||
|
||||
+564
-3
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Pure functions with no Lit/DOM dependencies — easy to cover with unit tests.
|
||||
*/
|
||||
import { union } from 'polyclip-ts';
|
||||
|
||||
/** Zigbee LQI color: ≤40 — red, ≥180 — green, in between — an hsl gradient. */
|
||||
export function lqiColor(lqi: number): string {
|
||||
@@ -13,10 +14,124 @@ export function snapToGrid(v: number, pitch: number): number {
|
||||
return Math.round(v / pitch) * pitch;
|
||||
}
|
||||
|
||||
/** Canonical key of a segment (independent of direction). */
|
||||
export function segKey(a: number[], b: number[]): string {
|
||||
/** Real-world length (cm) of a segment given the grid pitch (render units per cell) and cm per cell. */
|
||||
export function segmentCm(a: number[], b: number[], gridPitch: number, cellCm: number): number {
|
||||
const cells = Math.hypot(b[0] - a[0], b[1] - a[1]) / gridPitch;
|
||||
return cells * cellCm;
|
||||
}
|
||||
|
||||
/** Format a length (cm) for display: metric metres ("1.25 m") or imperial feet+inches ("4′ 1″"). */
|
||||
export function formatLength(cm: number, imperial: boolean): string {
|
||||
if (imperial) {
|
||||
const totalIn = cm / 2.54;
|
||||
let ft = Math.floor(totalIn / 12);
|
||||
let inch = Math.round(totalIn - ft * 12);
|
||||
if (inch === 12) { ft += 1; inch = 0; }
|
||||
return `${ft}′ ${inch}″`;
|
||||
}
|
||||
return `${(cm / 100).toFixed(2)} m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical key of a segment (independent of direction).
|
||||
* `prec` = decimals used to compare coordinates: the default 1 suits render units,
|
||||
* normalized (0..1) coordinates need more (see roomEdges).
|
||||
*/
|
||||
export function segKey(a: number[], b: number[], prec = 1): string {
|
||||
const [p, q] = a[0] < b[0] || (a[0] === b[0] && a[1] <= b[1]) ? [a, b] : [b, a];
|
||||
return `${p[0].toFixed(1)},${p[1].toFixed(1)}-${q[0].toFixed(1)},${q[1].toFixed(1)}`;
|
||||
return `${p[0].toFixed(prec)},${p[1].toFixed(prec)}-${q[0].toFixed(prec)},${q[1].toFixed(prec)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wall segments derived from room outlines (normalized coordinates in and out).
|
||||
*
|
||||
* A line has no independent existence on the plan: it can only be an edge of a closed
|
||||
* room. Shared walls are emitted once, which is what makes deleting a room keep the
|
||||
* borders its neighbours still contribute — the neighbour's polygon still yields them.
|
||||
*/
|
||||
export function roomPoly(r: any): number[][] | null {
|
||||
if (r?.poly?.length >= 3) return r.poly;
|
||||
if (r && r.x != null && r.y != null && r.w != null && r.h != null)
|
||||
return [[r.x, r.y], [r.x + r.w, r.y], [r.x + r.w, r.y + r.h], [r.x, r.y + r.h]];
|
||||
return null;
|
||||
}
|
||||
|
||||
export function roomEdges(rooms: any[]): number[][] {
|
||||
const out: number[][] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const r of rooms || []) {
|
||||
const pts = roomPoly(r);
|
||||
if (!pts) continue;
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const a = pts[i];
|
||||
const b = pts[(i + 1) % pts.length];
|
||||
const k = segKey(a, b, 5);
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push([a[0], a[1], b[0], b[1]]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a point onto the nearest wall of any room, returning the snapped point and
|
||||
* the wall's angle (degrees), or null when no wall is within maxDist. Walls are the
|
||||
* DERIVED room edges (a line has no independent existence on the plan), so an opening
|
||||
* placed with this stays valid however rooms are later edited — it keeps absolute
|
||||
* coordinates and is not tied to a room id or edge index.
|
||||
*/
|
||||
export function snapToWall(
|
||||
p: number[], rooms: any[], maxDist: number,
|
||||
): { x: number; y: number; angle: number } | null {
|
||||
let best: { x: number; y: number; angle: number } | null = null;
|
||||
let bestD = maxDist;
|
||||
for (const e of roomEdges(rooms)) {
|
||||
const [x1, y1, x2, y2] = e;
|
||||
const dx = x2 - x1, dy = y2 - y1;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (!len2) continue;
|
||||
let t = ((p[0] - x1) * dx + (p[1] - y1) * dy) / len2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const q = [x1 + t * dx, y1 + t * dy];
|
||||
const d = Math.hypot(p[0] - q[0], p[1] - q[1]);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
// normalize to [-90, 90): two rooms sharing a wall yield the same edge in
|
||||
// OPPOSITE directions — without this, dragging an opening across segment
|
||||
// boundaries would flip its hinge side back and forth
|
||||
let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||
if (angle >= 90) angle -= 180;
|
||||
else if (angle < -90) angle += 180;
|
||||
best = { x: q[0], y: q[1], angle };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far open an opening is drawn, 0..1, from its contact sensor state.
|
||||
* No sensor bound → doors default to open (the familiar swing symbol), windows to
|
||||
* closed (intact glass) — same convention as a static architectural plan.
|
||||
* `unavailable`/`unknown` freeze the default too: an outage must not fake motion.
|
||||
*/
|
||||
export function openingAmount(
|
||||
type: 'door' | 'window', state: string | null | undefined, invert = false,
|
||||
): number {
|
||||
if (state == null || state === 'unavailable' || state === 'unknown')
|
||||
return type === 'door' ? 1 : 0;
|
||||
const open = isActiveState(state) !== !!invert;
|
||||
return open ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is an entity "active / detected"? Used by presence ripples, which are opted into per
|
||||
* device and therefore must not depend on the card-wide live_states toggle.
|
||||
* Anything unknown — including `unavailable` — counts as idle: a sensor outage should
|
||||
* calm the plan down, never leave a ring pulsing forever.
|
||||
*/
|
||||
export function isActiveState(state?: string | null): boolean {
|
||||
return ['on', 'open', 'home', 'detected', 'playing', 'cleaning'].includes(String(state));
|
||||
}
|
||||
|
||||
/** Point equality within a tolerance. */
|
||||
@@ -35,6 +150,243 @@ export function pointInPolygon(p: number[], poly: number[][]): boolean {
|
||||
return inside;
|
||||
}
|
||||
|
||||
/** Distance from p to segment ab. */
|
||||
function distToSeg(p: number[], a: number[], b: number[]): number {
|
||||
const dx = b[0] - a[0];
|
||||
const dy = b[1] - a[1];
|
||||
const len2 = dx * dx + dy * dy;
|
||||
let t = len2 ? ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2 : 0;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
|
||||
}
|
||||
|
||||
/**
|
||||
* Is p on the outline itself (within eps)? This is the normal case, not an anomaly:
|
||||
* neighbouring rooms share walls, so their vertices sit on each other's outlines —
|
||||
* including mid-span, since real walls overlap collinearly rather than match exactly.
|
||||
*/
|
||||
/**
|
||||
* Project a point onto the nearest edge of a polygon and return that point,
|
||||
* or null when the polygon has no edges. Used to snap a Split click onto the
|
||||
* actual wall (rooms may not be grid-aligned — imported polygons, older configs),
|
||||
* so cutting no longer requires hitting a grid node exactly on the outline.
|
||||
*/
|
||||
export function closestPointOnBoundary(p: number[], poly: number[][]): number[] | null {
|
||||
if (!poly || poly.length < 2) return null;
|
||||
let best: number[] | null = null;
|
||||
let bestD = Infinity;
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const a = poly[i], b = poly[(i + 1) % poly.length];
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const len2 = dx * dx + dy * dy;
|
||||
let t = len2 ? ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2 : 0;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const q = [a[0] + t * dx, a[1] + t * dy];
|
||||
const d = Math.hypot(p[0] - q[0], p[1] - q[1]);
|
||||
if (d < bestD) { bestD = d; best = q; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function pointOnBoundary(p: number[], poly: number[][], eps = 1e-6): boolean {
|
||||
if (!poly || poly.length < 2) return false;
|
||||
for (let i = 0; i < poly.length; i++)
|
||||
if (distToSeg(p, poly[i], poly[(i + 1) % poly.length]) <= eps) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Inside the outline AND not on it — a point on a shared wall is not "inside". */
|
||||
export function pointStrictlyInside(p: number[], poly: number[][], eps = 1e-6): boolean {
|
||||
if (!poly || poly.length < 3) return false;
|
||||
if (pointOnBoundary(p, poly, eps)) return false;
|
||||
return pointInPolygon(p, poly);
|
||||
}
|
||||
|
||||
function cross3(a: number[], b: number[], c: number[]): number {
|
||||
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Do two segments cross transversally? Touching at an endpoint and collinear overlap
|
||||
* deliberately do NOT count — that is what sharing a wall looks like.
|
||||
*/
|
||||
export function segmentsProperlyCross(
|
||||
p1: number[], p2: number[], p3: number[], p4: number[], eps = 1e-9,
|
||||
): boolean {
|
||||
const d1 = cross3(p3, p4, p1);
|
||||
const d2 = cross3(p3, p4, p2);
|
||||
const d3 = cross3(p1, p2, p3);
|
||||
const d4 = cross3(p1, p2, p4);
|
||||
return (
|
||||
((d1 > eps && d2 < -eps) || (d1 < -eps && d2 > eps)) &&
|
||||
((d3 > eps && d4 < -eps) || (d3 < -eps && d4 > eps))
|
||||
);
|
||||
}
|
||||
|
||||
/** Is any area of outline `a` strictly inside `b`? Also catches nested and duplicate outlines. */
|
||||
function coversArea(a: number[][], b: number[][], eps: number): boolean {
|
||||
let allOnBoundary = true;
|
||||
for (const v of a) {
|
||||
if (pointStrictlyInside(v, b, eps)) return true;
|
||||
if (!pointOnBoundary(v, b, eps)) allOnBoundary = false;
|
||||
}
|
||||
// every vertex sits on b's outline → a duplicate or traced outline: probe the middle
|
||||
if (allOnBoundary) {
|
||||
const c = [
|
||||
a.reduce((s, p) => s + p[0], 0) / a.length,
|
||||
a.reduce((s, p) => s + p[1], 0) / a.length,
|
||||
];
|
||||
return pointStrictlyInside(c, b, eps);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Is `inner` fully contained in `outer` (edges may touch, never cross)? */
|
||||
export function polyContainsPoly(outer: number[][], inner: number[][], eps = 1e-6): boolean {
|
||||
if (!outer || !inner || outer.length < 3 || inner.length < 3) return false;
|
||||
for (let i = 0; i < inner.length; i++)
|
||||
for (let j = 0; j < outer.length; j++)
|
||||
if (segmentsProperlyCross(inner[i], inner[(i + 1) % inner.length], outer[j], outer[(j + 1) % outer.length]))
|
||||
return false;
|
||||
for (const v of inner)
|
||||
if (!pointStrictlyInside(v, outer, eps) && !pointOnBoundary(v, outer, eps)) return false;
|
||||
// identical/traced outlines are NOT containment — probe the centroid strictness both ways
|
||||
const c = [
|
||||
inner.reduce((s, p) => s + p[0], 0) / inner.length,
|
||||
inner.reduce((s, p) => s + p[1], 0) / inner.length,
|
||||
];
|
||||
return pointStrictlyInside(c, outer, eps) && polygonArea(inner) < polygonArea(outer) - eps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do two room outlines ILLEGALLY share floor area? Sharing a wall (fully or
|
||||
* partially) and touching at a corner are normal. Since v1.34.0 full nesting is
|
||||
* legal too (island rooms: a column inside a ring, an inner room) — only edge
|
||||
* crossings and PARTIAL overlaps are rejected.
|
||||
*/
|
||||
export function roomsOverlap(a: number[][], b: number[][], eps = 1e-6): boolean {
|
||||
if (!a || !b || a.length < 3 || b.length < 3) return false;
|
||||
for (let i = 0; i < a.length; i++)
|
||||
for (let j = 0; j < b.length; j++)
|
||||
if (segmentsProperlyCross(a[i], a[(i + 1) % a.length], b[j], b[(j + 1) % b.length])) return true;
|
||||
if (polyContainsPoly(a, b, eps) || polyContainsPoly(b, a, eps)) return false;
|
||||
return coversArea(a, b, eps) || coversArea(b, a, eps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct islands of `poly` among `others`: outlines fully inside it that are not
|
||||
* themselves inside a bigger island (those are subtracted by their parent).
|
||||
*/
|
||||
export function islandsOf(poly: number[][], others: number[][][], eps = 1e-6): number[][][] {
|
||||
const inside = others.filter((o) => polyContainsPoly(poly, o, eps));
|
||||
return inside.filter((o) => !inside.some((p) => p !== o && polyContainsPoly(p, o, eps)));
|
||||
}
|
||||
|
||||
/** Shoelace area of an outline (absolute value). */
|
||||
export function polygonArea(poly: number[][]): number {
|
||||
if (!poly || poly.length < 3) return 0;
|
||||
let s = 0;
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const a = poly[i];
|
||||
const b = poly[(i + 1) % poly.length];
|
||||
s += a[0] * b[1] - b[0] * a[1];
|
||||
}
|
||||
return Math.abs(s) / 2;
|
||||
}
|
||||
|
||||
function closedRing(poly: number[][]): number[][][] {
|
||||
return [[...poly.map((p) => [p[0], p[1]]), [poly[0][0], poly[0][1]]]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of two room outlines, or null when they may not be merged.
|
||||
*
|
||||
* "Adjacent" is decided by the result rather than by a separate heuristic: only rooms that
|
||||
* genuinely share a wall (fully or partially — real walls overlap collinearly rather than
|
||||
* match exactly) collapse into ONE hole-free outline. Rooms that merely touch at a corner,
|
||||
* that are apart, or whose union would enclose a hole do not, and are refused.
|
||||
*/
|
||||
export function mergeRooms(a: number[][], b: number[][]): number[][] | null {
|
||||
if (!a || !b || a.length < 3 || b.length < 3) return null;
|
||||
const res = union(closedRing(a) as any, closedRing(b) as any);
|
||||
if (res.length !== 1) return null; // two pieces → not adjacent
|
||||
if (res[0].length !== 1) return null; // a ring plus holes → not a simple room
|
||||
const pts = res[0][0].slice(0, -1).map((p: number[]) => [p[0], p[1]]); // drop the closing point
|
||||
return pts.length >= 3 ? pts : null;
|
||||
}
|
||||
|
||||
/** Index of the outline edge that p sits on, or -1. */
|
||||
function edgeIndexOf(poly: number[][], p: number[], eps: number): number {
|
||||
for (let i = 0; i < poly.length; i++)
|
||||
if (distToSeg(p, poly[i], poly[(i + 1) % poly.length]) <= eps) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
function dropRepeats(pts: number[][], eps: number): number[][] {
|
||||
const out: number[][] = [];
|
||||
for (const p of pts) if (!out.length || !samePoint(out[out.length - 1], p, eps)) out.push(p);
|
||||
if (out.length > 1 && samePoint(out[0], out[out.length - 1], eps)) out.pop();
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cut a room in two with a straight chord between two points on its walls.
|
||||
* Returns the two parts, or null when the cut is not a clean wall-to-wall chord:
|
||||
* an end that is not on a wall, a chord that leaves the room (concave outlines) or that
|
||||
* runs along a wall and would carve off a zero-area sliver.
|
||||
*/
|
||||
export function splitRoom(
|
||||
poly: number[][], a: number[], b: number[], eps = 1e-6,
|
||||
): [number[][], number[][]] | null {
|
||||
return splitRoomPath(poly, [a, b], eps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a room along a polyline: first and last points on walls, intermediate
|
||||
* vertices strictly inside the room. A two-point path is the classic straight
|
||||
* chord. Returns the two parts, or null when the path is not a clean cut.
|
||||
*/
|
||||
export function splitRoomPath(
|
||||
poly: number[][], pts: number[][], eps = 1e-6,
|
||||
): [number[][], number[][]] | null {
|
||||
if (!poly || poly.length < 3 || !pts || pts.length < 2) return null;
|
||||
const a = pts[0];
|
||||
const b = pts[pts.length - 1];
|
||||
if (samePoint(a, b, eps)) return null;
|
||||
const ia = edgeIndexOf(poly, a, eps);
|
||||
const ib = edgeIndexOf(poly, b, eps);
|
||||
if (ia < 0 || ib < 0) return null; // an end is not on a wall
|
||||
const mids = pts.slice(1, -1);
|
||||
for (const m of mids) if (!pointStrictlyInside(m, poly, eps)) return null;
|
||||
// no path segment may cross a wall
|
||||
for (let sI = 0; sI < pts.length - 1; sI++)
|
||||
for (let i = 0; i < poly.length; i++)
|
||||
if (segmentsProperlyCross(pts[sI], pts[sI + 1], poly[i], poly[(i + 1) % poly.length])) return null;
|
||||
// the path may not properly self-intersect
|
||||
for (let sI = 0; sI < pts.length - 1; sI++)
|
||||
for (let t = sI + 2; t < pts.length - 1; t++)
|
||||
if (segmentsProperlyCross(pts[sI], pts[sI + 1], pts[t], pts[t + 1])) return null;
|
||||
// a straight chord lying along a wall has its midpoint ON the outline, not inside
|
||||
if (pts.length === 2 && !pointStrictlyInside([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], poly, eps))
|
||||
return null;
|
||||
const walk = (from: number[], fromIdx: number, to: number[], toIdx: number): number[][] => {
|
||||
const acc: number[][] = [from];
|
||||
let i = (fromIdx + 1) % poly.length;
|
||||
for (let guard = 0; guard <= poly.length; guard++) {
|
||||
acc.push(poly[i]);
|
||||
if (i === toIdx) break;
|
||||
i = (i + 1) % poly.length;
|
||||
}
|
||||
acc.push(to);
|
||||
return dropRepeats(acc, eps);
|
||||
};
|
||||
const p1 = dropRepeats([...walk(a, ia, b, ib), ...[...mids].reverse()], eps);
|
||||
const p2 = dropRepeats([...walk(b, ib, a, ia), ...mids], eps);
|
||||
if (p1.length < 3 || p2.length < 3) return null;
|
||||
if (polygonArea(p1) <= eps || polygonArea(p2) <= eps) return null;
|
||||
return [p1, p2];
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker id by binding: device → device_id, entity → 'lg_'+entity_id,
|
||||
* virtual → the passed-in existing (if it is already a v_ marker) or a new one via newId().
|
||||
@@ -194,6 +546,13 @@ export interface SpaceDisplay {
|
||||
fill: RoomFillMode;
|
||||
tempMin: number; // comfort range lower bound, °C
|
||||
tempMax: number; // comfort range upper bound, °C
|
||||
/** Per-space LQI badges near zigbee devices; null = follow the card option. */
|
||||
showLqi: boolean | null;
|
||||
/** Room-card metrics under the room name (all default off). */
|
||||
labelTemp: boolean;
|
||||
labelHum: boolean;
|
||||
labelLqi: boolean;
|
||||
labelLight: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_ROOM_COLOR = '#3ea6ff';
|
||||
@@ -213,9 +572,111 @@ export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
|
||||
fill: ['lqi', 'light', 'temp'].includes(s.fill_mode) ? s.fill_mode : 'none',
|
||||
tempMin: typeof s.temp_min === 'number' ? s.temp_min : DEFAULT_TEMP_MIN,
|
||||
tempMax: typeof s.temp_max === 'number' ? s.temp_max : DEFAULT_TEMP_MAX,
|
||||
showLqi: typeof s.show_lqi === 'boolean' ? s.show_lqi : null,
|
||||
labelTemp: s.label_temp === true,
|
||||
labelHum: s.label_hum === true,
|
||||
labelLqi: s.label_lqi === true,
|
||||
labelLight: s.label_light === true,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------- global fill colors ----------------
|
||||
|
||||
export interface FillColorEntry {
|
||||
c: string; // #rrggbb
|
||||
a: number; // 0..1 fill opacity
|
||||
}
|
||||
|
||||
/** Global fill palette, grouped by fill mode; stored in config.settings.fill_colors. */
|
||||
export interface FillColors {
|
||||
light_on: FillColorEntry;
|
||||
light_off: FillColorEntry;
|
||||
/** Rooms with no light sources at all; alpha 0 (default) = no fill, as before. */
|
||||
light_none: FillColorEntry;
|
||||
temp_cold: FillColorEntry;
|
||||
temp_ok: FillColorEntry;
|
||||
temp_hot: FillColorEntry;
|
||||
lqi_low: FillColorEntry;
|
||||
lqi_high: FillColorEntry;
|
||||
}
|
||||
|
||||
export const DEFAULT_FILL_COLORS: FillColors = {
|
||||
light_on: { c: '#ffd45c', a: 0.18 },
|
||||
light_off: { c: '#9aa0a6', a: 0.14 },
|
||||
light_none: { c: '#6b7480', a: 0 },
|
||||
temp_cold: { c: '#4fc3f7', a: 0.18 },
|
||||
temp_ok: { c: '#66d17a', a: 0.18 },
|
||||
temp_hot: { c: '#ffd45c', a: 0.18 },
|
||||
lqi_low: { c: '#f25a4a', a: 0.18 },
|
||||
lqi_high: { c: '#4bd28f', a: 0.18 },
|
||||
};
|
||||
|
||||
const HEX_RE = /^#[0-9a-f]{6}$/i;
|
||||
|
||||
/** Merge stored overrides over the defaults, dropping malformed entries. */
|
||||
export function fillColorsOf(settings: any): FillColors {
|
||||
const out: any = {};
|
||||
const src = settings?.fill_colors || {};
|
||||
for (const k of Object.keys(DEFAULT_FILL_COLORS) as (keyof FillColors)[]) {
|
||||
const d = DEFAULT_FILL_COLORS[k];
|
||||
const v = src[k];
|
||||
out[k] = {
|
||||
c: v && typeof v.c === 'string' && HEX_RE.test(v.c) ? v.c : d.c,
|
||||
a: v && typeof v.a === 'number' ? Math.min(1, Math.max(0, v.a)) : d.a,
|
||||
};
|
||||
}
|
||||
return out as FillColors;
|
||||
}
|
||||
|
||||
/** Linear RGB interpolation between two hex colors, t clamped to 0..1. */
|
||||
export function lerpColor(a: string, b: string, t: number): string {
|
||||
const tt = Math.min(1, Math.max(0, t));
|
||||
const pa = [1, 3, 5].map((i) => parseInt(a.slice(i, i + 2), 16));
|
||||
const pb = [1, 3, 5].map((i) => parseInt(b.slice(i, i + 2), 16));
|
||||
const mix = pa.map((v, i) => Math.round(v + (pb[i] - v) * tt));
|
||||
return '#' + mix.map((v) => v.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Room fill (color + opacity) for the selected mode, or null for "no fill",
|
||||
* using the global palette. The LQI gradient interpolates lqi_low → lqi_high
|
||||
* over the 40..180 LQI window (same thresholds as the badge color).
|
||||
*/
|
||||
export function roomFillStyle(
|
||||
mode: RoomFillMode,
|
||||
lqi: number | null,
|
||||
lights: 'on' | 'off' | 'none',
|
||||
temp: number | null | undefined,
|
||||
tempMin: number,
|
||||
tempMax: number,
|
||||
colors: FillColors,
|
||||
): FillColorEntry | null {
|
||||
if (mode === 'lqi') {
|
||||
if (lqi == null) return null;
|
||||
const t = (lqi - 40) / 140;
|
||||
return { c: lerpColor(colors.lqi_low.c, colors.lqi_high.c, t),
|
||||
a: colors.lqi_low.a + (colors.lqi_high.a - colors.lqi_low.a) * Math.min(1, Math.max(0, t)) };
|
||||
}
|
||||
if (mode === 'light') {
|
||||
if (lights === 'none') {
|
||||
// configurable "no light sources" color; alpha 0 keeps the historical
|
||||
// no-fill behavior (and the unfilled hover), so nothing changes until
|
||||
// the user assigns an opacity
|
||||
return colors.light_none.a > 0 ? colors.light_none : null;
|
||||
}
|
||||
return lights === 'on' ? colors.light_on : colors.light_off;
|
||||
}
|
||||
if (mode === 'temp') {
|
||||
if (temp == null) return null;
|
||||
const lo = Math.min(tempMin, tempMax);
|
||||
const hi = Math.max(tempMin, tempMax);
|
||||
if (temp < lo) return colors.temp_cold;
|
||||
if (temp > hi) return colors.temp_hot;
|
||||
return colors.temp_ok;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Room fill color for the selected mode, or null for "no fill".
|
||||
* - lqi: red→green gradient by the room's average zigbee signal (null LQI → no fill)
|
||||
@@ -248,3 +709,103 @@ export function roomFillColor(
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------- state-reflecting icons ----------------
|
||||
|
||||
/**
|
||||
* Swap the auto icon for a state variant (open door, unlocked lock…), like core
|
||||
* HA does. Conservative: only well-known pairs, only when the user has NOT set
|
||||
* a custom icon, and unknown/unavailable states keep the base icon.
|
||||
*/
|
||||
export function stateIcon(
|
||||
base: string,
|
||||
domain: string | null | undefined,
|
||||
deviceClass: string | null | undefined,
|
||||
state: string | null | undefined,
|
||||
hasCustomIcon: boolean,
|
||||
): string {
|
||||
if (hasCustomIcon || !state || state === 'unavailable' || state === 'unknown') return base;
|
||||
if (domain === 'binary_sensor') {
|
||||
if (deviceClass === 'door') return state === 'on' ? 'mdi:door-open' : 'mdi:door-closed';
|
||||
if (deviceClass === 'window') return state === 'on' ? 'mdi:window-open' : 'mdi:window-closed';
|
||||
if (deviceClass === 'garage_door') return state === 'on' ? 'mdi:garage-open-variant' : 'mdi:garage-variant';
|
||||
}
|
||||
if (domain === 'lock') return state === 'locked' ? 'mdi:lock' : 'mdi:lock-open-variant';
|
||||
if (domain === 'light' && base === 'mdi:lightbulb') return state === 'on' ? 'mdi:lightbulb-on' : base;
|
||||
return base;
|
||||
}
|
||||
|
||||
// ---------------- light color & alarm states ----------------
|
||||
|
||||
/**
|
||||
* The current color of a light entity as a CSS color, or null when it is off,
|
||||
* unavailable or reports no usable color. rgb_color is the source of truth
|
||||
* (HA normalizes hs/xy into it); brightness is deliberately ignored — a dim
|
||||
* red bulb should still read as red on the plan.
|
||||
*/
|
||||
export function lightColorOf(state: any): string | null {
|
||||
if (!state || state.state !== 'on') return null;
|
||||
const rgb = state.attributes?.rgb_color;
|
||||
if (Array.isArray(rgb) && rgb.length >= 3 && rgb.every((v: any) => Number.isFinite(v))) {
|
||||
return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Device classes whose active state is an emergency, not a status. */
|
||||
const ALARM_CLASSES = new Set(['smoke', 'gas', 'carbon_monoxide', 'moisture', 'safety', 'tamper', 'problem']);
|
||||
|
||||
/**
|
||||
* An alarm is firing: leak/smoke/gas/CO/safety binary sensors in `on`, or a
|
||||
* siren that is on. Unavailable/unknown never alarm (an outage is not a fire).
|
||||
*/
|
||||
export function isAlarmState(
|
||||
domain: string | null | undefined,
|
||||
deviceClass: string | null | undefined,
|
||||
state: string | null | undefined,
|
||||
): boolean {
|
||||
if (state !== 'on') return false;
|
||||
if (domain === 'siren') return true;
|
||||
return domain === 'binary_sensor' && !!deviceClass && ALARM_CLASSES.has(deviceClass);
|
||||
}
|
||||
|
||||
// ---------------- room references ----------------
|
||||
|
||||
/**
|
||||
* Parse a marker's room reference. Two shapes:
|
||||
* - `space#area` — a room bound to an HA area (historical form)
|
||||
* - `space#@roomId` — a room WITHOUT an area (sub-area rooms, issue #3):
|
||||
* devices are placed into it manually, by room id.
|
||||
*/
|
||||
export function parseRoomRef(
|
||||
v: string | null | undefined,
|
||||
): { space: string; area: string | null; roomId: string | null } | null {
|
||||
if (!v) return null;
|
||||
const i = v.indexOf('#');
|
||||
if (i <= 0) return null;
|
||||
const space = v.slice(0, i);
|
||||
const rest = v.slice(i + 1);
|
||||
if (!rest) return null;
|
||||
if (rest.startsWith('@')) {
|
||||
const roomId = rest.slice(1);
|
||||
return roomId ? { space, area: null, roomId } : null;
|
||||
}
|
||||
return { space, area: rest, roomId: null };
|
||||
}
|
||||
|
||||
// ---------------- new-device detection ----------------
|
||||
|
||||
/**
|
||||
* Which auto-appearing device ids are NEW against the known baseline.
|
||||
* No baseline yet (first run / upgrade) → nothing is new: every current id
|
||||
* becomes the baseline silently, so an update never floods the plan with dots.
|
||||
*/
|
||||
export function diffNewDevices(
|
||||
currentIds: string[],
|
||||
known: string[] | null | undefined,
|
||||
): { fresh: string[]; known: string[] } {
|
||||
if (!Array.isArray(known)) return { fresh: [], known: [...currentIds] };
|
||||
const knownSet = new Set(known);
|
||||
const fresh = currentIds.filter((id) => !knownSet.has(id));
|
||||
return { fresh, known: fresh.length ? [...known, ...fresh] : known };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* houseplan-space-card — a READ-ONLY, static schematic of a single houseplan space,
|
||||
* embeddable on any dashboard. Renders exactly what is configured (plan + configured
|
||||
* room borders/names + device markers at their saved positions) with NO interactivity
|
||||
* (no clicks/hover/tooltips/drag/more-info) and NO live states. A footer button opens
|
||||
* the space in the full component via a deep-link (`#space=<id>`).
|
||||
*/
|
||||
import { LitElement, html, nothing, css, type TemplateResult, type PropertyValues } from 'lit';
|
||||
import { cardStyles } from './styles';
|
||||
import { renderSpaceStatic, spaceModels } from './space-render';
|
||||
import { getConfig, onConfigChange, cachedSnapshot, type HpConfigSnapshot } from './config-store';
|
||||
import { t, langOf, type Lang } from './i18n';
|
||||
import './space-editor';
|
||||
|
||||
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
|
||||
const ev = new Event(type, { bubbles: true, composed: true }) as any;
|
||||
ev.detail = detail ?? {};
|
||||
node.dispatchEvent(ev);
|
||||
};
|
||||
const navigate = (path: string) => {
|
||||
history.pushState(null, '', path);
|
||||
fireEvent(window, 'location-changed', { replace: false });
|
||||
};
|
||||
|
||||
interface SpaceCardConfig {
|
||||
type: string;
|
||||
space: string;
|
||||
title?: string;
|
||||
show_button?: boolean;
|
||||
button_label?: string;
|
||||
button_target?: string;
|
||||
aspect_ratio?: string;
|
||||
icon_size?: number;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
class HouseplanSpaceCard extends LitElement {
|
||||
public hass?: any;
|
||||
private _config?: SpaceCardConfig;
|
||||
private _snap: HpConfigSnapshot | null = null;
|
||||
private _loading = false;
|
||||
private _unsub?: () => void;
|
||||
|
||||
static properties = {
|
||||
hass: { attribute: false },
|
||||
_config: { state: true },
|
||||
_snap: { state: true },
|
||||
};
|
||||
|
||||
public static getConfigElement() {
|
||||
return document.createElement('houseplan-space-card-editor');
|
||||
}
|
||||
|
||||
public static getStubConfig(hass: any): Partial<SpaceCardConfig> {
|
||||
const snap = cachedSnapshot();
|
||||
const first = spaceModels(snap?.config || null)[0]?.id || '';
|
||||
return { type: 'custom:houseplan-space-card', space: first, show_button: true };
|
||||
}
|
||||
|
||||
public setConfig(config: SpaceCardConfig): void {
|
||||
if (!config || !config.space) {
|
||||
throw new Error('houseplan-space-card: "space" is required');
|
||||
}
|
||||
this._config = { show_button: true, button_target: '/plan-doma', ...config };
|
||||
// instant paint from the full card's localStorage snapshot, refresh in the background
|
||||
this._snap = this._snap || cachedSnapshot();
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._unsub = onConfigChange(() => {
|
||||
this._loading = false;
|
||||
this._snap = null;
|
||||
this.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
this._unsub?.();
|
||||
this._unsub = undefined;
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected willUpdate(changed: PropertyValues): void {
|
||||
if (this.hass && !this._loading && (!this._snap || changed.has('hass'))) {
|
||||
if (!this._snap || !this._loadedOnce) this._load();
|
||||
}
|
||||
}
|
||||
|
||||
private _loadedOnce = false;
|
||||
private async _load(): Promise<void> {
|
||||
if (!this.hass || this._loading) return;
|
||||
this._loading = true;
|
||||
try {
|
||||
const snap = await getConfig(this.hass);
|
||||
this._snap = snap;
|
||||
this._loadedOnce = true;
|
||||
} catch {
|
||||
/* keep any localStorage snapshot */
|
||||
} finally {
|
||||
this._loading = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private get _lang(): Lang {
|
||||
return langOf(this.hass, this._config?.language);
|
||||
}
|
||||
|
||||
public getCardSize(): number {
|
||||
const models = spaceModels(this._snap?.config || null);
|
||||
const sp = models.find((s) => s.id === this._config?.space);
|
||||
if (sp) {
|
||||
const ratio = sp.vb[3] / sp.vb[2]; // h/w
|
||||
return Math.max(3, Math.round(ratio * 8)) + (this._config?.show_button === false ? 0 : 1);
|
||||
}
|
||||
return 6;
|
||||
}
|
||||
|
||||
private _errorCard(msg: string): TemplateResult {
|
||||
return html`<ha-card><div class="hp-static-error">${msg}</div></ha-card>`;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
if (!this._config) return nothing;
|
||||
const cfg = this._snap?.config;
|
||||
if (!cfg) {
|
||||
// still loading and no snapshot yet
|
||||
return html`<ha-card><div class="hp-static-error">${t(this._lang, 'space_card.loading')}</div></ha-card>`;
|
||||
}
|
||||
const spaceId = this._config.space;
|
||||
const stage = renderSpaceStatic({
|
||||
hass: this.hass,
|
||||
cfg,
|
||||
layout: this._snap?.layout || {},
|
||||
spaceId,
|
||||
iconSize: this._config.icon_size,
|
||||
lang: this._lang,
|
||||
});
|
||||
if (!stage) {
|
||||
return this._errorCard(t(this._lang, 'space_card.not_found', { id: spaceId }));
|
||||
}
|
||||
const sp = spaceModels(cfg).find((s) => s.id === spaceId);
|
||||
const title = this._config.title !== undefined ? this._config.title : sp?.title || '';
|
||||
const showButton = this._config.show_button !== false;
|
||||
const label = this._config.button_label || t(this._lang, 'space_card.button');
|
||||
return html`
|
||||
<ha-card>
|
||||
${title ? html`<div class="hp-static-title">${title}</div>` : nothing}
|
||||
${stage}
|
||||
${showButton
|
||||
? html`<div class="hp-static-foot">
|
||||
<button class="hp-static-btn" @click=${this._goToSpace}>${label}</button>
|
||||
</div>`
|
||||
: nothing}
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _goToSpace = (): void => {
|
||||
const base = (this._config?.button_target || '/plan-doma').replace(/#.*$/, '');
|
||||
navigate(`${base}#space=${encodeURIComponent(this._config!.space)}`);
|
||||
};
|
||||
|
||||
static styles = [
|
||||
cardStyles,
|
||||
css`
|
||||
.hp-static-title {
|
||||
font-weight: 700;
|
||||
padding: 10px 14px 6px;
|
||||
font-size: 16px;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.hp-static-stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
container-type: inline-size;
|
||||
overflow: hidden;
|
||||
pointer-events: none; /* kill ALL interaction on the schematic (§4) */
|
||||
background: var(--ha-card-background, var(--card-background-color, #111));
|
||||
}
|
||||
.hp-static-stage svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
.hp-static-stage .devlayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.hp-static-foot {
|
||||
padding: 8px 12px 12px;
|
||||
pointer-events: auto; /* the button stays clickable */
|
||||
}
|
||||
.hp-static-btn {
|
||||
width: 100%;
|
||||
padding: 9px 14px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: var(--primary-color, #3ea6ff);
|
||||
color: var(--text-primary-color, #fff);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hp-static-btn:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.hp-static-error {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--secondary-text-color, #9aa4ad);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
if (!customElements.get('houseplan-space-card')) {
|
||||
customElements.define('houseplan-space-card', HouseplanSpaceCard);
|
||||
}
|
||||
|
||||
(window as any).customCards = (window as any).customCards || [];
|
||||
if (!(window as any).customCards.find((c: any) => c.type === 'houseplan-space-card')) {
|
||||
(window as any).customCards.push({
|
||||
type: 'houseplan-space-card',
|
||||
name: 'House Plan — Space (static)',
|
||||
description: 'Read-only static schematic of a single houseplan space, with a deep-link button.',
|
||||
preview: false,
|
||||
documentation: 'https://github.com/Matysh/houseplan-card',
|
||||
});
|
||||
}
|
||||
|
||||
export { HouseplanSpaceCard };
|
||||
@@ -0,0 +1,85 @@
|
||||
/** Config editor (Lovelace GUI) for houseplan-space-card. */
|
||||
import { LitElement, html, nothing } from 'lit';
|
||||
import { langOf, t, type Lang } from './i18n';
|
||||
|
||||
class HouseplanSpaceCardEditor extends LitElement {
|
||||
public hass?: any;
|
||||
private _config?: any;
|
||||
private _spaces: { value: string; label: string }[] | null = null;
|
||||
private _spacesLoading = false;
|
||||
|
||||
static properties = {
|
||||
hass: { attribute: false },
|
||||
_config: { state: true },
|
||||
_spaces: { state: true },
|
||||
};
|
||||
|
||||
public setConfig(config: any): void {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private async _loadSpaces(): Promise<void> {
|
||||
if (this._spaces || this._spacesLoading || !this.hass) return;
|
||||
this._spacesLoading = true;
|
||||
try {
|
||||
const resp = await this.hass.callWS({ type: 'houseplan/config/get' });
|
||||
this._spaces = (resp?.config?.spaces || []).map((s: any) => ({ value: s.id, label: s.title || s.id }));
|
||||
} catch {
|
||||
this._spaces = [];
|
||||
} finally {
|
||||
this._spacesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private get _lang(): Lang {
|
||||
return langOf(this.hass, this._config?.language);
|
||||
}
|
||||
|
||||
private get _schema(): any[] {
|
||||
const spaces = this._spaces || [];
|
||||
return [
|
||||
spaces.length
|
||||
? { name: 'space', selector: { select: { mode: 'dropdown', options: spaces } } }
|
||||
: { name: 'space', selector: { text: {} } },
|
||||
{ name: 'title', selector: { text: {} } },
|
||||
{ name: 'show_button', selector: { boolean: {} } },
|
||||
{ name: 'button_label', selector: { text: {} } },
|
||||
{ name: 'button_target', selector: { text: {} } },
|
||||
{ name: 'aspect_ratio', selector: { text: {} } },
|
||||
{ name: 'icon_size', selector: { number: { min: 1, max: 6, step: 0.1, mode: 'box' } } },
|
||||
];
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._config) return nothing;
|
||||
this._loadSpaces();
|
||||
const L = this._lang;
|
||||
const labels: Record<string, string> = {
|
||||
space: t(L, 'editor.space'),
|
||||
title: t(L, 'editor.title'),
|
||||
show_button: t(L, 'editor.show_button'),
|
||||
button_label: t(L, 'editor.button_label'),
|
||||
button_target: t(L, 'editor.button_target'),
|
||||
aspect_ratio: t(L, 'editor.aspect_ratio'),
|
||||
icon_size: t(L, 'editor.icon_size'),
|
||||
};
|
||||
return html`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._config}
|
||||
.schema=${this._schema}
|
||||
.computeLabel=${(s: any) => labels[s.name] || s.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;
|
||||
}
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
const config = { ...this._config, ...ev.detail.value };
|
||||
const e = new Event('config-changed', { bubbles: true, composed: true }) as any;
|
||||
e.detail = { config };
|
||||
this.dispatchEvent(e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get('houseplan-space-card-editor')) {
|
||||
customElements.define('houseplan-space-card-editor', HouseplanSpaceCardEditor);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Pure geometry/model helpers for houseplan spaces — no Lit/DOM imports, so they
|
||||
* are directly unit-tested. Shared by the static renderer (space-render.ts) and
|
||||
* mirror the full card's private math.
|
||||
*/
|
||||
import { declump } from './logic';
|
||||
import type { ServerConfig, SpaceModel, RoomCfg, DevItem } from './types';
|
||||
|
||||
export const NORM_W = 1000; // width of the render space for normalized configs
|
||||
|
||||
export type Pt = { x: number; y: number };
|
||||
export type Layout = Record<string, { s?: string; x: number; y: number } | undefined>;
|
||||
|
||||
/** Build render-space models (NORM_W × NORM_W/aspect) from a server config. */
|
||||
export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
|
||||
if (!cfg || !Array.isArray(cfg.spaces)) return [];
|
||||
return cfg.spaces.map((s: any) => {
|
||||
const H = NORM_W / s.aspect;
|
||||
const scale = (r: any): RoomCfg => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
area: r.area ?? null,
|
||||
x: r.x != null ? r.x * NORM_W : undefined,
|
||||
y: r.y != null ? r.y * H : undefined,
|
||||
w: r.w != null ? r.w * NORM_W : undefined,
|
||||
h: r.h != null ? r.h * H : undefined,
|
||||
poly: r.poly ? r.poly.map((p: number[]) => [p[0] * NORM_W, p[1] * H]) : undefined,
|
||||
});
|
||||
return {
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
|
||||
bg: s.plan_url ? { href: s.plan_url, x: 0, y: 0, w: NORM_W, h: H } : null,
|
||||
rooms: (s.rooms || []).map(scale),
|
||||
} as SpaceModel;
|
||||
});
|
||||
}
|
||||
|
||||
/** Bounding rectangle of a room (rect or polygon) in render units. */
|
||||
export function roomBounds(r: RoomCfg): { x: number; y: number; w: number; h: number } {
|
||||
if (r.poly && r.poly.length) {
|
||||
const xs = r.poly.map((p) => p[0]);
|
||||
const ys = r.poly.map((p) => p[1]);
|
||||
const x = Math.min(...xs);
|
||||
const y = Math.min(...ys);
|
||||
return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
|
||||
}
|
||||
return { x: r.x ?? 0, y: r.y ?? 0, w: r.w ?? 0, h: r.h ?? 0 };
|
||||
}
|
||||
|
||||
/** Geometric centre of a room (label anchor). */
|
||||
export function roomCenter(r: RoomCfg): number[] {
|
||||
if (r.poly) {
|
||||
const n = r.poly.length;
|
||||
return [r.poly.reduce((a, p) => a + p[0], 0) / n, r.poly.reduce((a, p) => a + p[1], 0) / n];
|
||||
}
|
||||
return [r.x! + r.w! / 2, r.y! + Math.min(r.w!, r.h!) * 0.1];
|
||||
}
|
||||
|
||||
/** Auto grid positions for a single space's area devices (identical to the full card). */
|
||||
export function defaultPositions(devs: DevItem[], model: SpaceModel, iconPct: number): Record<string, Pt> {
|
||||
const map: Record<string, Pt> = {};
|
||||
const minDist = (iconPct / 100) * NORM_W * 1.3;
|
||||
for (const r of model.rooms) {
|
||||
if (!r.area) continue;
|
||||
const ds = devs.filter((d) => d.area === r.area);
|
||||
if (!ds.length) continue;
|
||||
const b = roomBounds(r);
|
||||
const pad = Math.min(b.w, b.h) * 0.1;
|
||||
const iw = b.w - pad * 2;
|
||||
const ih = b.h - pad * 2;
|
||||
const cols = Math.max(1, Math.round(Math.sqrt((ds.length * iw) / Math.max(ih, 1))));
|
||||
const cw = iw / cols;
|
||||
const ch = ih / Math.max(Math.ceil(ds.length / cols), 1);
|
||||
const pts = ds.map((_, i) => ({
|
||||
x: b.x + pad + cw * ((i % cols) + 0.5),
|
||||
y: b.y + pad + ch * (Math.floor(i / cols) + 0.5),
|
||||
}));
|
||||
declump(pts, b, minDist, pad * 0.5);
|
||||
ds.forEach((d, i) => (map[d.id] = pts[i]));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Marker position in render units: saved layout → default grid → space centre. */
|
||||
export function markerPos(d: DevItem, layout: Layout, cfg: ServerConfig, defPos: Record<string, Pt>, model: SpaceModel): Pt {
|
||||
const saved = layout[d.id];
|
||||
if (saved && saved.s === d.space) {
|
||||
const aspect = cfg.spaces.find((x: any) => x.id === d.space)?.aspect || 1;
|
||||
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
|
||||
}
|
||||
if (defPos[d.id]) return defPos[d.id];
|
||||
const vb = model.vb;
|
||||
return { x: vb[0] + vb[2] / 2, y: vb[1] + vb[3] / 2 };
|
||||
}
|
||||
|
||||
/** Saved room-label position (layout key rl_<roomId>) or the room centre. */
|
||||
export function labelPos(r: RoomCfg, spaceId: string, layout: Layout, cfg: ServerConfig): Pt {
|
||||
const saved = layout['rl_' + (r.id || '')];
|
||||
if (saved && saved.s === spaceId) {
|
||||
const aspect = cfg.spaces.find((x: any) => x.id === spaceId)?.aspect || 1;
|
||||
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
|
||||
}
|
||||
const c = roomCenter(r);
|
||||
return { x: c[0], y: c[1] };
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Shared STATIC renderer for a single houseplan space — used by the read-only
|
||||
* `houseplan-space-card`. Draws exactly what is CONFIGURED (plan background,
|
||||
* configured room borders/names, device markers at their saved positions), with NO interactivity
|
||||
* (pointer-events:none) and NO state subscription; room fills are rendered exactly as
|
||||
* configured on the full card (a snapshot of the current states passed via `hass`).
|
||||
* 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 { spaceDisplayOf, roomFillStyle, fillColorsOf } from './logic';
|
||||
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
|
||||
import { t, type Lang } from './i18n';
|
||||
import type { ServerConfig } from './types';
|
||||
import {
|
||||
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, type Layout,
|
||||
} from './space-geometry';
|
||||
|
||||
export { spaceModels } from './space-geometry';
|
||||
|
||||
export interface StaticRenderOpts {
|
||||
hass: any;
|
||||
cfg: ServerConfig;
|
||||
layout: Layout;
|
||||
spaceId: string;
|
||||
iconSize?: number;
|
||||
lang: Lang;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static schematic of one space. Returns the inner stage template (svg + marker
|
||||
* layer) or null when the space id is unknown (the caller renders an error card).
|
||||
*/
|
||||
export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
const models = spaceModels(o.cfg);
|
||||
const space = models.find((s) => s.id === o.spaceId);
|
||||
if (!space) return null;
|
||||
const vb = space.vb;
|
||||
const disp = spaceDisplayOf(o.cfg.spaces.find((s: any) => s.id === o.spaceId));
|
||||
const cfgSize = o.iconSize ?? 2.5;
|
||||
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
|
||||
|
||||
const areaToSpace: Record<string, string> = {};
|
||||
for (const s of o.cfg.spaces || []) for (const r of (s as any).rooms || []) if (r.area) areaToSpace[r.area] = (s as any).id;
|
||||
const excluded = o.cfg.settings?.exclude_integrations ? new Set(o.cfg.settings.exclude_integrations) : EXCLUDED_DOMAINS;
|
||||
const iconRules = compileIconRules(
|
||||
o.cfg.settings?.icon_rules?.length ? o.cfg.settings.icon_rules : DEFAULT_ICON_RULES,
|
||||
);
|
||||
const loc = (k: 'device.unnamed' | 'device.light_group' | 'device.fallback' | 'device.virtual') => t(o.lang, k);
|
||||
const all = buildDevices({
|
||||
hass: o.hass,
|
||||
areaToSpace,
|
||||
markers: o.cfg.markers || [],
|
||||
settings: o.cfg.settings || {},
|
||||
excluded,
|
||||
showAll: !!o.cfg.settings?.show_all,
|
||||
firstSpaceId: models[0]?.id || '',
|
||||
loc,
|
||||
iconRules,
|
||||
});
|
||||
const devs = all.filter((d) => d.space === o.spaceId);
|
||||
const defPos = defaultPositions(devs, space, iconPct);
|
||||
|
||||
const roomShapes = space.rooms
|
||||
.filter((r) => r.area || disp.showBorders)
|
||||
.map((r) => {
|
||||
let cls = 'room ' + (space.bg ? 'overlay' : 'yard');
|
||||
let style = '';
|
||||
if (disp.showBorders || disp.fill !== 'none') {
|
||||
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
|
||||
? roomFillStyle(
|
||||
disp.fill,
|
||||
disp.fill === 'lqi' ? areaLqi(o.hass, devs, r.area) : null,
|
||||
disp.fill === 'light' ? areaLights(o.hass, devs, r.area) : 'none',
|
||||
disp.fill === 'temp' ? areaTemp(o.hass, devs, r.area) : null,
|
||||
disp.tempMin,
|
||||
disp.tempMax,
|
||||
fillColorsOf(o.cfg?.settings),
|
||||
)
|
||||
: null;
|
||||
if (fillC) {
|
||||
cls += ' filled';
|
||||
parts.push(`--room-fill:${fillC.c}`, `--room-fill-op:${fillC.a.toFixed(3)}`);
|
||||
} else {
|
||||
parts.push('--room-fill:transparent', '--room-fill-op:0');
|
||||
}
|
||||
style = parts.join(';');
|
||||
}
|
||||
const svgLabel = !space.bg && !disp.showNames;
|
||||
const c = roomCenter(r);
|
||||
const shape = r.poly
|
||||
? svg`<polygon class="${cls}" style="${style}" points="${r.poly.map((p) => p.join(',')).join(' ')}"></polygon>`
|
||||
: svg`<rect class="${cls}" style="${style}" x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="${Math.min(r.w!, r.h!) * 0.03}"></rect>`;
|
||||
return svg`${shape}${svgLabel ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
|
||||
});
|
||||
|
||||
const markers = devs.map((d) => {
|
||||
const p = markerPos(d, o.layout, o.cfg, defPos, space);
|
||||
const left = ((p.x - vb[0]) / vb[2]) * 100;
|
||||
const top = ((p.y - vb[1]) / vb[3]) * 100;
|
||||
return html`<div class="dev ${d.virtual ? 'virtual' : ''}" style="left:${left}%;top:${top}%">
|
||||
<ha-icon icon="${d.icon}"></ha-icon>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
const labels = disp.showNames
|
||||
? space.rooms
|
||||
.filter((r) => r.name)
|
||||
.map((r) => {
|
||||
const p = labelPos(r, space.id, o.layout, o.cfg);
|
||||
const left = ((p.x - vb[0]) / vb[2]) * 100;
|
||||
const top = ((p.y - vb[1]) / vb[3]) * 100;
|
||||
const op = Math.min(1, disp.opacity + 0.25);
|
||||
return html`<div class="roomlabel" style="left:${left}%;top:${top}%;color:${disp.color};opacity:${op}">${r.name}</div>`;
|
||||
})
|
||||
: [];
|
||||
|
||||
return html`
|
||||
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}">
|
||||
<svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet">
|
||||
${space.bg
|
||||
? svg`<image href="${space.bg.href}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />`
|
||||
: nothing}
|
||||
${roomShapes}
|
||||
</svg>
|
||||
<div class="devlayer" style="--icon-size:${iconPct}cqw">${markers}${labels}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
+417
-15
@@ -80,6 +80,8 @@ export const cardStyles = css`
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--hp-muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 13px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
@@ -134,6 +136,9 @@ export const cardStyles = css`
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.stage.noplan {
|
||||
background: #ffffff;
|
||||
}
|
||||
.stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -222,20 +227,281 @@ export const cardStyles = css`
|
||||
fill-opacity: 0.22;
|
||||
stroke-opacity: 1;
|
||||
}
|
||||
.roomlabel {
|
||||
/* doors & windows */
|
||||
.op-leaf {
|
||||
transition: transform 0.6s ease;
|
||||
}
|
||||
.op-arc {
|
||||
stroke-width: 1.5;
|
||||
transition: stroke-dashoffset 0.6s ease;
|
||||
}
|
||||
/* hover affordance: a rounded outline hugging the wall strip + a grab cursor */
|
||||
.op-outline {
|
||||
fill: none;
|
||||
stroke: var(--hp-accent);
|
||||
stroke-width: 1.5;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.stage.markup g.opening:hover .op-outline {
|
||||
opacity: 0.9;
|
||||
}
|
||||
/* openings are pure status graphics outside Plan mode: no cursor, no hover,
|
||||
no hit target — View must not interact with them at all */
|
||||
.op-hit {
|
||||
fill: transparent;
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
.stage.markup .op-hit {
|
||||
pointer-events: auto;
|
||||
cursor: grab;
|
||||
touch-action: none; /* drags, not scrolls, on touch */
|
||||
}
|
||||
.stage.markup .op-hit:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.oplock {
|
||||
pointer-events: none; /* inert while editing; clickable in View (rule below) */
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: calc(var(--icon-size, 2.5cqw) * 0.5);
|
||||
width: calc(var(--icon-size, 2.5cqw) * 0.62);
|
||||
height: calc(var(--icon-size, 2.5cqw) * 0.62);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--hp-bg);
|
||||
border: 1px solid var(--hp-line);
|
||||
z-index: 1;
|
||||
}
|
||||
.stage.mode-view .oplock {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
.oplock ha-icon {
|
||||
--mdc-icon-size: calc(var(--icon-size, 2.5cqw) * 0.4);
|
||||
display: flex;
|
||||
line-height: 0;
|
||||
}
|
||||
.oplock.locked { color: #66d17a; border-color: #66d17a; }
|
||||
.oplock.unlocked { color: var(--hp-open); border-color: var(--hp-open); }
|
||||
.oplock.unknown { color: var(--hp-muted); }
|
||||
.btn.lockact {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.btn.lockact.warn {
|
||||
color: var(--error-color, #d33);
|
||||
border-color: var(--error-color, #d33);
|
||||
}
|
||||
.oprow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.oprow b { margin-left: auto; }
|
||||
.oprow.ok b { color: #66d17a; }
|
||||
.oprow.warn b { color: var(--hp-open); }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.op-leaf, .op-arc { transition: none; }
|
||||
}
|
||||
/* presence ripples: opted into per device, drawn around the anchor point */
|
||||
.dev.noicon {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.dev ha-icon {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.ripple {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: calc(var(--dev-size) * var(--ripple-scale, 3));
|
||||
height: calc(var(--dev-size) * var(--ripple-scale, 3));
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.ripple i {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--ripple-color, var(--hp-accent));
|
||||
opacity: 0;
|
||||
}
|
||||
.ripple.active i {
|
||||
animation: hp-ripple 2.4s ease-out infinite;
|
||||
}
|
||||
.ripple.active i:nth-child(2) { animation-delay: 0.8s; }
|
||||
.ripple.active i:nth-child(3) { animation-delay: 1.6s; }
|
||||
/* idle: a faint dot keeps the spot marked without pulling the eye */
|
||||
.ripple:not(.active) i:nth-child(n + 2) { display: none; }
|
||||
.ripple:not(.active) i {
|
||||
inset: calc(50% - 0.15 * var(--dev-size));
|
||||
opacity: 0.3;
|
||||
animation: none;
|
||||
}
|
||||
@keyframes hp-ripple {
|
||||
0% { transform: scale(0.18); opacity: 0.7; }
|
||||
70% { opacity: 0.22; }
|
||||
100% { transform: scale(1); opacity: 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ripple.active i { animation: none; opacity: 0.3; }
|
||||
.ripple.active i:nth-child(n + 2) { display: none; }
|
||||
}
|
||||
.roomlabel {
|
||||
pointer-events: none; /* draggable only in plan mode (rule below) */
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: calc(var(--icon-size, 2.5cqw) * 0.5 * var(--rl-scale, 1));
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
cursor: grab;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
text-shadow: 0 0 4px var(--card-background-color, rgba(0, 0, 0, 0.6));
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.15em;
|
||||
text-align: center;
|
||||
}
|
||||
.roomlabel .rlmetrics {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55em;
|
||||
font-size: 0.62em;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.roomlabel .rlm {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.12em;
|
||||
}
|
||||
.roomlabel .rlm ha-icon {
|
||||
--mdc-icon-size: 1.05em;
|
||||
display: inline-flex;
|
||||
}
|
||||
.roomlabel .rlm.lit { opacity: 1; }
|
||||
.iconauto {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
.iconauto ha-icon { --mdc-icon-size: 18px; }
|
||||
.rlhandle {
|
||||
display: none;
|
||||
position: absolute;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 2px;
|
||||
background: var(--hp-accent);
|
||||
border: 1px solid var(--card-background-color, #fff);
|
||||
z-index: 2;
|
||||
}
|
||||
.rlhandle.tl { left: -6px; top: -6px; cursor: nwse-resize; }
|
||||
.rlhandle.br { right: -6px; bottom: -6px; cursor: nwse-resize; }
|
||||
.rlhandle.tr { right: -6px; top: -6px; cursor: nesw-resize; }
|
||||
.rlhandle.bl { left: -6px; bottom: -6px; cursor: nesw-resize; }
|
||||
.stage.markup .roomlabel:hover .rlhandle { display: block; }
|
||||
.stage.markup .roomlabel { pointer-events: auto; }
|
||||
.roomlabel:active { cursor: grabbing; }
|
||||
.measurelayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.measurelabel {
|
||||
position: absolute;
|
||||
transform: translate(12px, -150%);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
z-index: 3;
|
||||
}
|
||||
/* decor (background) layer */
|
||||
.decorlayer .dshape { pointer-events: none; }
|
||||
.stage.mode-decor .decorlayer .dshape {
|
||||
pointer-events: visiblePainted;
|
||||
cursor: pointer;
|
||||
}
|
||||
.stage.mode-decor.dtool-select .decorlayer .dshape { cursor: move; }
|
||||
.decorlayer .dsel {
|
||||
filter: drop-shadow(0 0 3px var(--hp-accent));
|
||||
}
|
||||
.decorlayer .ddraft {
|
||||
opacity: 0.75;
|
||||
stroke-dasharray: 6 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.decorlayer text {
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
dominant-baseline: middle;
|
||||
text-anchor: middle;
|
||||
}
|
||||
.stage.mode-decor {
|
||||
outline: 2px solid #26a69a;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.stage.mode-decor.dtool-line, .stage.mode-decor.dtool-rect,
|
||||
.stage.mode-decor.dtool-ellipse, .stage.mode-decor.dtool-text {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.stage.mode-decor .room, .stage.mode-decor .devlayer { pointer-events: none; }
|
||||
.stage.mode-decor .oplock { pointer-events: none; }
|
||||
/* decor mode: everything but the decor itself fades back */
|
||||
.stage.mode-decor .room,
|
||||
.stage.mode-decor .devlayer,
|
||||
.stage.mode-decor .opening,
|
||||
.stage.mode-decor .rlabel {
|
||||
opacity: 0.35;
|
||||
}
|
||||
.decorbar .dcolor {
|
||||
width: 30px; height: 26px; padding: 0; border: none; background: none; cursor: pointer;
|
||||
}
|
||||
.decorbar .dwidth {
|
||||
font-family: inherit; font-size: 12px; border-radius: 6px;
|
||||
background: var(--hp-bg2, transparent); color: var(--hp-txt); border: 1px solid var(--hp-muted);
|
||||
padding: 3px 5px;
|
||||
}
|
||||
.decorbar .dfill {
|
||||
display: inline-flex; align-items: center; gap: 4px; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.opghost {
|
||||
stroke: var(--hp-open, #ff9800);
|
||||
stroke-width: 5;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 7 6;
|
||||
opacity: 0.85;
|
||||
pointer-events: none;
|
||||
}
|
||||
.opghost-dot {
|
||||
fill: var(--hp-open, #ff9800);
|
||||
opacity: 0.85;
|
||||
pointer-events: none;
|
||||
}
|
||||
.rlabel {
|
||||
fill: var(--hp-muted);
|
||||
font-size: 15px;
|
||||
@@ -249,16 +515,80 @@ export const cardStyles = css`
|
||||
.stage.markup {
|
||||
cursor: crosshair;
|
||||
}
|
||||
/* room-picking stages: merge (both clicks) and split before a room is chosen */
|
||||
.stage.markup.tool-merge,
|
||||
.stage.markup.tool-split.pickstage,
|
||||
.stage.markup.tool-delroom {
|
||||
cursor: pointer;
|
||||
}
|
||||
.stage.markup .room {
|
||||
pointer-events: none;
|
||||
}
|
||||
.stage.markup .devlayer {
|
||||
display: none; /* in markup mode icons must not get in the way */
|
||||
.stage.markup .devlayer .dev {
|
||||
display: none; /* in plan mode the icons do not get in the way; labels stay */
|
||||
}
|
||||
/* mode frames: the edit modes are visible at a glance */
|
||||
.stage.mode-plan {
|
||||
outline: 2px solid #ffc14d;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.stage.mode-devices {
|
||||
outline: 2px solid var(--hp-accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.modes {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
background: rgba(127, 127, 127, 0.12);
|
||||
border-radius: 10px;
|
||||
padding: 3px;
|
||||
}
|
||||
.modetab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--hp-muted);
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.modetab ha-icon { --mdc-icon-size: 15px; }
|
||||
.modetab .closex {
|
||||
--mdc-icon-size: 13px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 2px;
|
||||
opacity: 0.75;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.modetab .closex:hover { opacity: 1; }
|
||||
.editbar .barclose {
|
||||
padding: 4px 6px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.modetab.active {
|
||||
background: var(--hp-accent);
|
||||
color: var(--text-primary-color, #fff);
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.modetab .ml { display: none; }
|
||||
}
|
||||
.room.outlined {
|
||||
stroke: rgba(62, 166, 255, 0.55);
|
||||
fill: rgba(62, 166, 255, 0.06);
|
||||
}
|
||||
/* AFTER .outlined: same specificity — source order decides (gotcha x4) */
|
||||
.room.picked {
|
||||
stroke: #ffc14d;
|
||||
stroke-width: 3;
|
||||
fill: rgba(255, 193, 77, 0.25);
|
||||
}
|
||||
.griddot {
|
||||
fill: var(--hp-accent);
|
||||
opacity: 0.75;
|
||||
@@ -305,6 +635,53 @@ export const cardStyles = css`
|
||||
.namein {
|
||||
width: 130px;
|
||||
}
|
||||
.dev.valonly {
|
||||
width: auto;
|
||||
min-width: var(--dev-size, var(--icon-size, 2.5cqw));
|
||||
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.16);
|
||||
}
|
||||
.dev.valonly .valtext {
|
||||
font-size: calc(var(--icon-size, 2.5cqw) * 0.45);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* RGB lights: the bulb takes the light's actual color */
|
||||
.dev.rgb ha-icon { color: var(--light-color); }
|
||||
.dev.rgb.on {
|
||||
box-shadow: 0 0 10px var(--light-color);
|
||||
border-color: var(--light-color);
|
||||
background: var(--hp-bg);
|
||||
color: var(--light-color);
|
||||
}
|
||||
/* alarms pulse red over everything */
|
||||
.dev.alarm::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: calc(var(--icon-size, 2.5cqw) * -0.35);
|
||||
border: 3px solid #f25a4a;
|
||||
border-radius: 50%;
|
||||
animation: hp-alarm 1s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes hp-alarm {
|
||||
0% { transform: scale(0.7); opacity: 1; }
|
||||
100% { transform: scale(1.25); opacity: 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dev.alarm::after { animation: none; opacity: 0.9; }
|
||||
}
|
||||
.dev .newdot {
|
||||
position: absolute;
|
||||
top: calc(var(--icon-size, 2.5cqw) * -0.12);
|
||||
right: calc(var(--icon-size, 2.5cqw) * -0.12);
|
||||
width: calc(var(--icon-size, 2.5cqw) * 0.34);
|
||||
height: calc(var(--icon-size, 2.5cqw) * 0.34);
|
||||
border-radius: 50%;
|
||||
background: #f0301f;
|
||||
border: 2px solid var(--card-background-color, var(--hp-bg));
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.devlayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -312,10 +689,12 @@ export const cardStyles = css`
|
||||
}
|
||||
.dev {
|
||||
position: absolute;
|
||||
/* per-device multiplier on top of the card-wide icon size */
|
||||
--dev-size: calc(var(--icon-size, 2.5cqw) * var(--dev-scale, 1));
|
||||
/* центр квадрата (включая рамку 1px) точно на точке привязки: -(size/2 + border) */
|
||||
width: var(--icon-size, 2.5cqw);
|
||||
height: var(--icon-size, 2.5cqw);
|
||||
margin: calc(var(--icon-size, 2.5cqw) / -2 - 1px) 0 0 calc(var(--icon-size, 2.5cqw) / -2 - 1px);
|
||||
width: var(--dev-size);
|
||||
height: var(--dev-size);
|
||||
margin: calc(var(--dev-size) / -2 - 1px) 0 0 calc(var(--dev-size) / -2 - 1px);
|
||||
border-radius: 22%;
|
||||
background: var(--hp-bg);
|
||||
border: 1px solid var(--hp-line);
|
||||
@@ -323,7 +702,7 @@ export const cardStyles = css`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--hp-txt);
|
||||
cursor: grab;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: background 0.15s, border-color 0.15s, opacity 0.2s;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45);
|
||||
@@ -336,9 +715,8 @@ export const cardStyles = css`
|
||||
justify-content: center;
|
||||
line-height: 0;
|
||||
}
|
||||
.dev:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.stage.mode-devices .dev { cursor: grab; }
|
||||
.stage.mode-devices .dev:active { cursor: grabbing; }
|
||||
.dev:hover {
|
||||
background: var(--hp-accent);
|
||||
color: var(--text-primary-color, #fff);
|
||||
@@ -376,7 +754,24 @@ export const cardStyles = css`
|
||||
border: 1px solid var(--hp-accent);
|
||||
border-radius: calc(var(--icon-size, 2.5cqw) * 0.18);
|
||||
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14);
|
||||
font-size: calc(var(--icon-size, 2.5cqw) * 0.45);
|
||||
font-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.45);
|
||||
font-weight: 700;
|
||||
line-height: calc(var(--icon-size, 2.5cqw) * 0.68);
|
||||
color: var(--hp-txt);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dev .hval {
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
margin-left: calc(var(--icon-size, 2.5cqw) * 0.1);
|
||||
background: var(--card-background-color, var(--hp-bg));
|
||||
border: 1px solid #4fc3f7;
|
||||
border-radius: calc(var(--icon-size, 2.5cqw) * 0.18);
|
||||
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14);
|
||||
font-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.45);
|
||||
font-weight: 700;
|
||||
line-height: calc(var(--icon-size, 2.5cqw) * 0.68);
|
||||
color: var(--hp-txt);
|
||||
@@ -407,9 +802,10 @@ export const cardStyles = css`
|
||||
}
|
||||
.tab .tabedit {
|
||||
--mdc-icon-size: 13px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 6px;
|
||||
opacity: 0.4;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.tab:hover .tabedit {
|
||||
opacity: 0.9;
|
||||
@@ -461,6 +857,7 @@ export const cardStyles = css`
|
||||
}
|
||||
/* beat the generic .dialog .body .namein { width:100% } rule */
|
||||
.dialog .body .temprange .tempin { width: 56px; flex: none; padding: 3px 6px; }
|
||||
.dialog .body .colorrow .tempin { width: 72px; flex: none; }
|
||||
.srcrow { flex-wrap: nowrap; }
|
||||
.srcrow > span:first-of-type { white-space: nowrap; }
|
||||
.colorrow .opl { color: var(--hp-muted); font-size: 12px; }
|
||||
@@ -713,6 +1110,11 @@ export const cardStyles = css`
|
||||
.rrow .ract:hover { color: var(--hp-txt); }
|
||||
.rrow .ract.del:hover { color: #ff7a5c; }
|
||||
|
||||
.gsrow .gsl {
|
||||
min-width: 150px;
|
||||
font-size: 12.5px;
|
||||
color: var(--hp-muted);
|
||||
}
|
||||
.dialogwrap {
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
|
||||
@@ -38,6 +38,27 @@ export interface Marker {
|
||||
description?: string | null;
|
||||
pdfs?: PdfRef[];
|
||||
tap_action?: string | null; // per-device override: 'info' | 'more-info' | 'toggle'
|
||||
room_id?: string | null; // manual placement into a room WITHOUT an HA area (sub-area rooms)
|
||||
display?: 'badge' | 'ripple' | 'icon_ripple' | 'value' | null; // how the device is drawn
|
||||
ripple_color?: string | null;
|
||||
ripple_size?: number | null; // max ring diameter, in icon diameters (default 3)
|
||||
size?: number | null; // icon size multiplier (default 1)
|
||||
angle?: number | null; // icon rotation, degrees
|
||||
}
|
||||
|
||||
/** A door or window: plan geometry (normalized coords), optionally live via entities. */
|
||||
export interface OpeningCfg {
|
||||
id: string;
|
||||
type: 'door' | 'window';
|
||||
x: number; // center, normalized by plan width
|
||||
y: number; // center, normalized by plan height
|
||||
angle: number; // wall angle, degrees
|
||||
length: number; // along the wall, normalized by plan width
|
||||
contact?: string | null; // binary_sensor / cover driving open-closed
|
||||
lock?: string | null; // lock entity (doors only)
|
||||
invert?: boolean;
|
||||
flip_h?: boolean; // hinge on the other jamb
|
||||
flip_v?: boolean; // opens to the other side of the wall
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
@@ -61,6 +82,7 @@ export interface DevItem {
|
||||
entities: string[];
|
||||
primary?: string;
|
||||
temp?: number | null;
|
||||
hum?: number | null;
|
||||
virtual?: boolean;
|
||||
marker?: Marker; // linked config marker (metadata, overrides)
|
||||
bindingKind?: 'device' | 'entity' | 'virtual';
|
||||
|
||||
+86
-1
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, areaLights, areaTemp } from '../test-build/devices.js';
|
||||
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats } from '../test-build/devices.js';
|
||||
import { compileIconRules } from '../test-build/rules.js';
|
||||
|
||||
/** Minimal fake hass around the pieces buildDevices reads. */
|
||||
@@ -224,3 +224,88 @@ test('isTempEntity: excludes chip temperature and diagnostic-category entities',
|
||||
// если в устройстве и чип, и настоящий — берётся настоящий
|
||||
assert.equal(tempFor(hass, ['sensor.plug_device_temperature', 'sensor.room_temperature']), 21);
|
||||
});
|
||||
|
||||
test('buildDevices: entity marker gets an auto icon + temperature (split a multi-entity device)', () => {
|
||||
const h = mkHass({
|
||||
entities: {
|
||||
'sensor.clim_temp': { entity_id: 'sensor.clim_temp', device_id: 'clim', platform: 'demo' },
|
||||
'sensor.clim_hum': { entity_id: 'sensor.clim_hum', device_id: 'clim', platform: 'demo' },
|
||||
},
|
||||
states: {
|
||||
'sensor.clim_temp': { state: '22.5', attributes: { device_class: 'temperature', friendly_name: 'Climate Temperature' } },
|
||||
'sensor.clim_hum': { state: '55', attributes: { device_class: 'humidity', friendly_name: 'Climate Humidity' } },
|
||||
},
|
||||
});
|
||||
const ctx = baseCtx(h, { markers: [
|
||||
{ id: 'e1', binding: 'entity:sensor.clim_temp' },
|
||||
{ id: 'e2', binding: 'entity:sensor.clim_hum' },
|
||||
] });
|
||||
const devs = buildDevices(ctx);
|
||||
const t = devs.find((d) => d.id === 'e1');
|
||||
const hum = devs.find((d) => d.id === 'e2');
|
||||
assert.equal(t.icon, 'mdi:thermometer');
|
||||
assert.equal(t.temp, 22.5);
|
||||
// humidity resolves to a real icon (device_class fallback), not the old generic shape
|
||||
assert.ok(hum && hum.icon && hum.icon !== 'mdi:shape-outline');
|
||||
assert.equal(hum.primary, 'sensor.clim_hum'); // more-info target
|
||||
});
|
||||
|
||||
test('humFor: reads a humidity entity as an integer %, ignores non-humidity', () => {
|
||||
const h = mkHass({ states: {
|
||||
'sensor.h': { state: '54.7', attributes: { device_class: 'humidity' } },
|
||||
'sensor.t': { state: '22', attributes: { device_class: 'temperature' } },
|
||||
}});
|
||||
assert.equal(humFor(h, ['sensor.t', 'sensor.h']), 55); // rounded
|
||||
assert.equal(humFor(h, ['sensor.t']), null);
|
||||
});
|
||||
|
||||
test('buildDevices: humidity entity marker shows a humidity badge (item.hum), water-percent icon', () => {
|
||||
const h = mkHass({
|
||||
entities: { 'sensor.clim_hum': { entity_id: 'sensor.clim_hum', device_id: 'clim', platform: 'demo' } },
|
||||
states: { 'sensor.clim_hum': { state: '54.7', attributes: { device_class: 'humidity', friendly_name: 'Climate Humidity' } } },
|
||||
});
|
||||
const hum = buildDevices(baseCtx(h, { markers: [{ id: 'e2', binding: 'entity:sensor.clim_hum' }] })).find((d) => d.id === 'e2');
|
||||
assert.equal(hum.icon, 'mdi:water-percent');
|
||||
assert.equal(hum.hum, 55);
|
||||
assert.equal(hum.temp, undefined);
|
||||
});
|
||||
|
||||
test('buildDevices: humidity badge is gated on device_class, not the icon (name may force another icon)', () => {
|
||||
const h = mkHass({
|
||||
entities: { 'sensor.mh_hum': { entity_id: 'sensor.mh_hum', device_id: 'mh', platform: 'demo' } },
|
||||
// name contains "myheat" → icon rule resolves to water-boiler, NOT water-percent…
|
||||
states: { 'sensor.mh_hum': { state: '45.2', attributes: { device_class: 'humidity', friendly_name: 'Myheat Влажность 1 этаж' } } },
|
||||
});
|
||||
const d = buildDevices(baseCtx(h, { markers: [{ id: 'e', binding: 'entity:sensor.mh_hum' }] })).find((x) => x.id === 'e');
|
||||
assert.notEqual(d.icon, 'mdi:water-percent'); // name rule won the icon
|
||||
assert.equal(d.hum, 45); // …but the humidity value is still shown (gated on device_class)
|
||||
});
|
||||
|
||||
test('areaLightStats: counts unique lights that are on', () => {
|
||||
const hass = { states: {
|
||||
'light.a': { state: 'on' }, 'light.b': { state: 'off' }, 'light.c': { state: 'on' },
|
||||
} };
|
||||
const devs = [
|
||||
{ area: 'living', entities: ['light.a', 'sensor.x'] },
|
||||
{ area: 'living', entities: ['light.b', 'light.a'] }, // light.a duplicated on purpose
|
||||
{ area: 'other', entities: ['light.c'] },
|
||||
];
|
||||
assert.deepEqual(areaLightStats(hass, devs, 'living'), { on: 1, total: 2 });
|
||||
assert.deepEqual(areaLightStats(hass, devs, 'other'), { on: 1, total: 1 });
|
||||
assert.equal(areaLightStats(hass, devs, 'empty'), null);
|
||||
});
|
||||
|
||||
test('areaHum: averages climate sensors only, integer %', () => {
|
||||
const hass = { states: {
|
||||
'sensor.t1_humidity': { state: '41', attributes: { device_class: 'humidity', unit_of_measurement: '%' } },
|
||||
'sensor.t2_humidity': { state: '46', attributes: { device_class: 'humidity', unit_of_measurement: '%' } },
|
||||
'sensor.fridge_humidity': { state: '90', attributes: { device_class: 'humidity', unit_of_measurement: '%' } },
|
||||
} };
|
||||
const devs = [
|
||||
{ area: 'living', icon: 'mdi:thermometer', entities: ['sensor.t1_humidity'] },
|
||||
{ area: 'living', icon: 'mdi:air-filter', entities: ['sensor.t2_humidity'] },
|
||||
{ area: 'living', icon: 'mdi:fridge', entities: ['sensor.fridge_humidity'] }, // curated out
|
||||
];
|
||||
assert.equal(areaHum(hass, devs, 'living'), 44);
|
||||
assert.equal(areaHum(hass, devs, 'nothing'), null);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,9 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
|
||||
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
|
||||
splitRoomPath, polyContainsPoly, islandsOf,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
|
||||
} from '../test-build/logic.js';
|
||||
import {
|
||||
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
||||
@@ -258,3 +261,340 @@ test('spaceDisplayOf: temp bounds default to 20..25 and accept overrides', () =>
|
||||
assert.equal(o.tempMin, 18.5);
|
||||
assert.equal(o.tempMax, 23);
|
||||
});
|
||||
|
||||
test('segmentCm: cells scaled by cm-per-cell', () => {
|
||||
assert.equal(segmentCm([0, 0], [30, 40], 10, 5), 25); // 50 units / pitch 10 = 5 cells * 5cm
|
||||
assert.ok(Math.abs(segmentCm([0, 0], [240, 0], 1000 / 240, 5) - 288) < 1e-9);
|
||||
});
|
||||
|
||||
test('formatLength: metric metres with 2 decimals', () => {
|
||||
assert.equal(formatLength(25, false), '0.25 m');
|
||||
assert.equal(formatLength(125, false), '1.25 m');
|
||||
assert.equal(formatLength(0, false), '0.00 m');
|
||||
});
|
||||
|
||||
test('formatLength: imperial feet + inches, with inch rollover', () => {
|
||||
assert.equal(formatLength(124.46, true), '4′ 1″');
|
||||
assert.equal(formatLength(30.48, true), '1′ 0″');
|
||||
assert.equal(formatLength(29.464, true), '1′ 0″');
|
||||
});
|
||||
|
||||
test('roomEdges: a line exists only as a room edge; polygons and rects both yield walls', () => {
|
||||
const sq = { poly: [[0, 0], [1, 0], [1, 1], [0, 1]] };
|
||||
assert.equal(roomEdges([sq]).length, 4); // closed outline → 4 walls
|
||||
assert.equal(roomEdges([{ x: 0, y: 0, w: 1, h: 1 }]).length, 4); // legacy rect room
|
||||
assert.equal(roomEdges([]).length, 0); // no rooms → no lines at all
|
||||
assert.equal(roomEdges([{ poly: [[0, 0], [1, 1]] }]).length, 0); // not a closed room → nothing
|
||||
});
|
||||
|
||||
test('roomEdges: a wall shared by two rooms is emitted once, and survives deleting either room', () => {
|
||||
const left = { id: 'a', poly: [[0, 0], [0.5, 0], [0.5, 1], [0, 1]] };
|
||||
const right = { id: 'b', poly: [[0.5, 0], [1, 0], [1, 1], [0.5, 1]] }; // shares x=0.5 wall
|
||||
const both = roomEdges([left, right]);
|
||||
assert.equal(both.length, 7); // 4 + 4 - 1 shared, deduped regardless of direction
|
||||
const shared = (segs) => segs.some((s) => s[0] === 0.5 && s[2] === 0.5);
|
||||
assert.ok(shared(both));
|
||||
// deleting 'left' → the shared wall stays, because 'right' still contributes it
|
||||
assert.ok(shared(roomEdges([right])));
|
||||
// deleting both → no lines remain
|
||||
assert.equal(roomEdges([]).length, 0);
|
||||
});
|
||||
|
||||
const SQ = [[0, 0], [2, 0], [2, 2], [0, 2]];
|
||||
|
||||
test('pointStrictlyInside: a point on a wall is NOT inside (shared walls are normal)', () => {
|
||||
assert.ok(pointStrictlyInside([1, 1], SQ)); // middle
|
||||
assert.ok(!pointStrictlyInside([2, 1], SQ)); // on a wall mid-span (T-junction vertex)
|
||||
assert.ok(!pointStrictlyInside([0, 0], SQ)); // on a corner
|
||||
assert.ok(!pointStrictlyInside([3, 1], SQ)); // outside
|
||||
assert.ok(pointOnBoundary([2, 1], SQ));
|
||||
assert.ok(!pointOnBoundary([1, 1], SQ));
|
||||
});
|
||||
|
||||
test('roomsOverlap: sharing a wall or a corner is legal; real overlap is not', () => {
|
||||
const right = [[2, 0], [4, 0], [4, 2], [2, 2]]; // shares the whole x=2 wall
|
||||
assert.ok(!roomsOverlap(SQ, right));
|
||||
// neighbour's wall is LONGER than ours — the real dacha case (collinear partial overlap)
|
||||
const tall = [[2, -1], [4, -1], [4, 3], [2, 3]];
|
||||
assert.ok(!roomsOverlap(SQ, tall));
|
||||
// touching only at a corner
|
||||
assert.ok(!roomsOverlap(SQ, [[2, 2], [3, 2], [3, 3], [2, 3]]));
|
||||
// apart
|
||||
assert.ok(!roomsOverlap(SQ, [[5, 5], [6, 5], [6, 6], [5, 6]]));
|
||||
// genuine partial overlap
|
||||
assert.ok(roomsOverlap(SQ, [[1, 1], [3, 1], [3, 3], [1, 3]]));
|
||||
});
|
||||
|
||||
test('roomsOverlap v1.34: nesting is legal (islands), duplicates and crossings are not', () => {
|
||||
// nested & enclosing — legal since island rooms
|
||||
assert.ok(!roomsOverlap(SQ, [[0.5, 0.5], [1.5, 0.5], [1.5, 1.5], [0.5, 1.5]]));
|
||||
assert.ok(!roomsOverlap([[-1, -1], [3, -1], [3, 3], [-1, 3]], SQ));
|
||||
// a duplicate outline is still an overlap
|
||||
assert.ok(roomsOverlap(SQ, SQ));
|
||||
// a cross: no vertex of either lies inside the other, but the edges cross
|
||||
assert.ok(roomsOverlap([[0, 0.5], [3, 0.5], [3, 1.5], [0, 1.5]],
|
||||
[[0.5, -1], [1.5, -1], [1.5, 3], [0.5, 3]]));
|
||||
// genuine partial overlap is still rejected
|
||||
assert.ok(roomsOverlap(SQ, [[1, 1], [3, 1], [3, 3], [1, 3]]));
|
||||
});
|
||||
|
||||
test('polyContainsPoly & islandsOf', () => {
|
||||
const outer = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
const col = [[4, 4], [6, 4], [6, 6], [4, 6]];
|
||||
const tiny = [[4.5, 4.5], [5, 4.5], [5, 5], [4.5, 5]]; // внутри col
|
||||
const partial = [[8, 8], [12, 8], [12, 12], [8, 12]];
|
||||
assert.ok(polyContainsPoly(outer, col));
|
||||
assert.ok(!polyContainsPoly(col, outer));
|
||||
assert.ok(!polyContainsPoly(outer, outer)); // дубликат — не вложенность
|
||||
assert.ok(!polyContainsPoly(outer, partial));
|
||||
// прямые острова: col — да; tiny — нет (он остров col, не outer)
|
||||
const isl = islandsOf(outer, [col, tiny, partial]);
|
||||
assert.deepEqual(isl, [col]);
|
||||
assert.deepEqual(islandsOf(col, [tiny]), [tiny]);
|
||||
});
|
||||
|
||||
test('roomPoly: polygon rooms as-is, legacy rect rooms as four corners', () => {
|
||||
assert.equal(roomPoly({ poly: SQ }), SQ);
|
||||
assert.deepEqual(roomPoly({ x: 0, y: 0, w: 2, h: 2 }), SQ);
|
||||
assert.equal(roomPoly({}), null);
|
||||
});
|
||||
|
||||
test('polygonArea: shoelace, orientation-independent', () => {
|
||||
assert.equal(polygonArea([[0, 0], [2, 0], [2, 2], [0, 2]]), 4);
|
||||
assert.equal(polygonArea([[0, 0], [0, 2], [2, 2], [2, 0]]), 4); // reversed winding
|
||||
assert.equal(polygonArea([[0, 0], [1, 1]]), 0);
|
||||
});
|
||||
|
||||
test('mergeRooms: only rooms sharing a wall merge; the union is one simple outline', () => {
|
||||
const a = [[0, 0], [2, 0], [2, 2], [0, 2]];
|
||||
const full = mergeRooms(a, [[2, 0], [4, 0], [4, 2], [2, 2]]); // whole wall shared
|
||||
assert.equal(polygonArea(full), 8);
|
||||
assert.equal(full.length, 4); // collapses to one rectangle
|
||||
// the neighbour's wall is LONGER than ours — the real dacha case
|
||||
const partial = mergeRooms(a, [[2, -1], [4, -1], [4, 3], [2, 3]]);
|
||||
assert.equal(polygonArea(partial), 4 + 8);
|
||||
assert.ok(partial.length >= 6); // an L/T-shaped outline
|
||||
});
|
||||
|
||||
test('mergeRooms: refuses a corner touch, rooms apart, and a union with a hole', () => {
|
||||
const a = [[0, 0], [2, 0], [2, 2], [0, 2]];
|
||||
assert.equal(mergeRooms(a, [[2, 2], [4, 2], [4, 4], [2, 4]]), null); // corner only
|
||||
assert.equal(mergeRooms(a, [[5, 5], [6, 5], [6, 6], [5, 6]]), null); // apart
|
||||
const u = [[0, 0], [6, 0], [6, 2], [4, 2], [4, 6], [6, 6], [6, 8], [0, 8]];
|
||||
assert.equal(mergeRooms(u, [[6, 2], [8, 2], [8, 6], [6, 6]]), null); // would enclose a hole
|
||||
});
|
||||
|
||||
test('splitRoom: a wall-to-wall chord cuts the room in two, areas are preserved', () => {
|
||||
const sq = [[0, 0], [4, 0], [4, 4], [0, 4]];
|
||||
const parts = splitRoom(sq, [0, 2], [4, 2]); // straight across
|
||||
assert.ok(parts);
|
||||
assert.equal(polygonArea(parts[0]) + polygonArea(parts[1]), polygonArea(sq));
|
||||
assert.equal(polygonArea(parts[0]), 8);
|
||||
assert.equal(polygonArea(parts[1]), 8);
|
||||
// an off-centre cut → a bigger and a smaller part (the bigger one keeps the room)
|
||||
const off = splitRoom(sq, [0, 1], [4, 1]);
|
||||
const areas = [polygonArea(off[0]), polygonArea(off[1])].sort((x, y) => x - y);
|
||||
assert.deepEqual(areas, [4, 12]);
|
||||
});
|
||||
|
||||
test('splitRoom: refuses cuts that are not clean wall-to-wall chords', () => {
|
||||
const sq = [[0, 0], [4, 0], [4, 4], [0, 4]];
|
||||
assert.equal(splitRoom(sq, [1, 1], [3, 3]), null); // ends not on a wall
|
||||
assert.equal(splitRoom(sq, [0, 2], [0, 2]), null); // same point
|
||||
assert.equal(splitRoom(sq, [0, 0], [4, 0]), null); // along a wall → zero-area sliver
|
||||
// an L-shaped room: a chord that would leave the room is refused
|
||||
const L = [[0, 0], [4, 0], [4, 2], [2, 2], [2, 4], [0, 4]];
|
||||
assert.equal(splitRoom(L, [4, 1], [1, 4]), null);
|
||||
});
|
||||
|
||||
test('closestPointOnBoundary: projects a click onto the nearest wall', () => {
|
||||
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
assert.deepEqual(closestPointOnBoundary([5, -3], sq), [5, 0]); // above the bottom edge
|
||||
assert.deepEqual(closestPointOnBoundary([13, 5], sq), [10, 5]); // right of the right edge
|
||||
assert.deepEqual(closestPointOnBoundary([5, 4], sq), [5, 0]); // inside → nearest edge (bottom)
|
||||
assert.equal(closestPointOnBoundary([0, 0], [[0, 0]]), null); // no edges
|
||||
});
|
||||
|
||||
test('isActiveState: a sensor outage calms the plan down, it never pulses forever', () => {
|
||||
assert.ok(isActiveState('on'));
|
||||
assert.ok(isActiveState('open'));
|
||||
assert.ok(isActiveState('home'));
|
||||
assert.ok(isActiveState('detected'));
|
||||
assert.ok(!isActiveState('off'));
|
||||
assert.ok(!isActiveState('unavailable'));
|
||||
assert.ok(!isActiveState('unknown'));
|
||||
assert.ok(!isActiveState(undefined));
|
||||
assert.ok(!isActiveState(null));
|
||||
});
|
||||
|
||||
test('snapToWall: projects onto the nearest derived wall with its angle; misses return null', () => {
|
||||
const rooms = [{ poly: [[0, 0], [10, 0], [10, 10], [0, 10]] }];
|
||||
const s = snapToWall([4, 0.6], rooms, 1); // near the top wall
|
||||
assert.deepEqual([s.x, s.y], [4, 0]);
|
||||
assert.equal(Math.abs(s.angle) % 180, 0); // horizontal wall
|
||||
const v = snapToWall([10.4, 7], rooms, 1); // near the right wall
|
||||
assert.deepEqual([v.x, v.y], [10, 7]);
|
||||
assert.equal(Math.abs(v.angle), 90);
|
||||
assert.equal(snapToWall([5, 5], rooms, 1), null); // middle of the room: no wall within reach
|
||||
});
|
||||
|
||||
test('openingAmount: doors default open, windows closed; outages freeze the default', () => {
|
||||
assert.equal(openingAmount('door', null), 1);
|
||||
assert.equal(openingAmount('window', null), 0);
|
||||
assert.equal(openingAmount('door', 'unavailable'), 1);
|
||||
assert.equal(openingAmount('window', 'unknown'), 0);
|
||||
assert.equal(openingAmount('door', 'on'), 1);
|
||||
assert.equal(openingAmount('door', 'off'), 0);
|
||||
assert.equal(openingAmount('window', 'open'), 1);
|
||||
// invert flips on/off but never the outage default
|
||||
assert.equal(openingAmount('door', 'on', true), 0);
|
||||
assert.equal(openingAmount('door', 'off', true), 1);
|
||||
assert.equal(openingAmount('door', 'unavailable', true), 1);
|
||||
});
|
||||
|
||||
test('snapToWall: the angle is normalized to [-90, 90) so opposite edge directions cannot flip a dragged opening', () => {
|
||||
// the same wall as seen from two neighbouring rooms (opposite winding)
|
||||
const a = [{ poly: [[0, 0], [10, 0], [10, 10], [0, 10]] }];
|
||||
const b = [{ poly: [[10, 0], [0, 0], [0, 10], [10, 10]] }]; // reversed
|
||||
const sa = snapToWall([5, 0.4], a, 1);
|
||||
const sb = snapToWall([5, 0.4], b, 1);
|
||||
assert.equal(sa.angle, sb.angle);
|
||||
assert.ok(sa.angle >= -90 && sa.angle < 90);
|
||||
const v = snapToWall([10.3, 5], a, 1); // vertical wall
|
||||
assert.ok(v.angle >= -90 && v.angle < 90);
|
||||
});
|
||||
|
||||
test('fillColorsOf: defaults, merge, malformed entries dropped', () => {
|
||||
const d = fillColorsOf({});
|
||||
assert.equal(d.light_on.c, '#ffd45c');
|
||||
const o = fillColorsOf({ fill_colors: { light_on: { c: '#ff0000', a: 0.5 }, temp_hot: { c: 'javascript:x', a: 9 } } });
|
||||
assert.equal(o.light_on.c, '#ff0000');
|
||||
assert.equal(o.light_on.a, 0.5);
|
||||
assert.equal(o.temp_hot.c, '#ffd45c'); // malformed hex → default
|
||||
assert.equal(o.temp_hot.a, 1); // clamped
|
||||
});
|
||||
|
||||
test('lerpColor: endpoints and midpoint', () => {
|
||||
assert.equal(lerpColor('#000000', '#ffffff', 0), '#000000');
|
||||
assert.equal(lerpColor('#000000', '#ffffff', 1), '#ffffff');
|
||||
assert.equal(lerpColor('#000000', '#ffffff', 0.5), '#808080');
|
||||
assert.equal(lerpColor('#000000', '#ffffff', -5), '#000000'); // clamped
|
||||
});
|
||||
|
||||
test('roomFillStyle: palette-driven fills, lqi gradient between custom endpoints', () => {
|
||||
const colors = fillColorsOf({ fill_colors: { lqi_low: { c: '#000000', a: 0.1 }, lqi_high: { c: '#ffffff', a: 0.3 } } });
|
||||
assert.equal(roomFillStyle('lqi', 110, 'none', null, 20, 25, colors).c, '#808080'); // mid of 40..180
|
||||
assert.equal(roomFillStyle('lqi', null, 'none', null, 20, 25, colors), null);
|
||||
assert.deepEqual(roomFillStyle('light', null, 'on', null, 20, 25, colors), colors.light_on);
|
||||
assert.deepEqual(roomFillStyle('temp', 18, 'none', 18, 20, 25, colors), colors.temp_cold);
|
||||
assert.equal(roomFillStyle('none', 100, 'on', 22, 20, 25, colors), null);
|
||||
});
|
||||
|
||||
test('spaceDisplayOf: show_lqi tri-state (null = follow the card option)', () => {
|
||||
assert.equal(spaceDisplayOf({}).showLqi, null);
|
||||
assert.equal(spaceDisplayOf({ settings: { show_lqi: false } }).showLqi, false);
|
||||
assert.equal(spaceDisplayOf({ settings: { show_lqi: true } }).showLqi, true);
|
||||
});
|
||||
|
||||
test('roomFillStyle light_none: alpha 0 keeps no-fill; a custom color fills lightless rooms', () => {
|
||||
const def = fillColorsOf({});
|
||||
assert.equal(roomFillStyle('light', null, 'none', null, 20, 25, def), null); // default: unchanged
|
||||
const c = fillColorsOf({ fill_colors: { light_none: { c: '#123456', a: 0.2 } } });
|
||||
assert.deepEqual(roomFillStyle('light', null, 'none', null, 20, 25, c), { c: '#123456', a: 0.2 });
|
||||
});
|
||||
|
||||
test('stateIcon: doors/locks/bulbs reflect state; custom icons and outages never morph', () => {
|
||||
assert.equal(stateIcon('mdi:door', 'binary_sensor', 'door', 'on', false), 'mdi:door-open');
|
||||
assert.equal(stateIcon('mdi:door', 'binary_sensor', 'door', 'off', false), 'mdi:door-closed');
|
||||
assert.equal(stateIcon('mdi:window-closed', 'binary_sensor', 'window', 'on', false), 'mdi:window-open');
|
||||
assert.equal(stateIcon('mdi:garage-variant', 'binary_sensor', 'garage_door', 'on', false), 'mdi:garage-open-variant');
|
||||
assert.equal(stateIcon('mdi:lock', 'lock', undefined, 'unlocked', false), 'mdi:lock-open-variant');
|
||||
assert.equal(stateIcon('mdi:lightbulb', 'light', undefined, 'on', false), 'mdi:lightbulb-on');
|
||||
assert.equal(stateIcon('mdi:lightbulb', 'light', undefined, 'off', false), 'mdi:lightbulb');
|
||||
assert.equal(stateIcon('mdi:door', 'binary_sensor', 'door', 'unavailable', false), 'mdi:door');
|
||||
assert.equal(stateIcon('mdi:custom', 'lock', undefined, 'unlocked', true), 'mdi:custom'); // user icon wins
|
||||
assert.equal(stateIcon('mdi:cctv', 'camera', undefined, 'recording', false), 'mdi:cctv'); // unknown pair
|
||||
});
|
||||
|
||||
test('lightColorOf: rgb of an on light; off/unavailable/no-color → null', () => {
|
||||
assert.equal(lightColorOf({ state: 'on', attributes: { rgb_color: [255, 20, 40] } }), 'rgb(255, 20, 40)');
|
||||
assert.equal(lightColorOf({ state: 'off', attributes: { rgb_color: [255, 20, 40] } }), null);
|
||||
assert.equal(lightColorOf({ state: 'on', attributes: {} }), null);
|
||||
assert.equal(lightColorOf({ state: 'unavailable', attributes: { rgb_color: [1, 2, 3] } }), null);
|
||||
assert.equal(lightColorOf(undefined), null);
|
||||
});
|
||||
|
||||
test('isAlarmState: leak/smoke/gas/siren fire; doors and outages do not', () => {
|
||||
assert.ok(isAlarmState('binary_sensor', 'moisture', 'on'));
|
||||
assert.ok(isAlarmState('binary_sensor', 'smoke', 'on'));
|
||||
assert.ok(isAlarmState('siren', undefined, 'on'));
|
||||
assert.ok(!isAlarmState('binary_sensor', 'door', 'on'));
|
||||
assert.ok(!isAlarmState('binary_sensor', 'smoke', 'off'));
|
||||
assert.ok(!isAlarmState('binary_sensor', 'smoke', 'unavailable'));
|
||||
});
|
||||
|
||||
test('parseRoomRef: area rooms, sub-area rooms by id, malformed refs', () => {
|
||||
assert.deepEqual(parseRoomRef('f1#kitchen'), { space: 'f1', area: 'kitchen', roomId: null });
|
||||
assert.deepEqual(parseRoomRef('f1#@r7'), { space: 'f1', area: null, roomId: 'r7' });
|
||||
assert.equal(parseRoomRef(''), null);
|
||||
assert.equal(parseRoomRef('f1#'), null);
|
||||
assert.equal(parseRoomRef('f1#@'), null);
|
||||
assert.equal(parseRoomRef('#kitchen'), null);
|
||||
assert.equal(parseRoomRef(null), null);
|
||||
});
|
||||
|
||||
test('diffNewDevices: first run seeds the baseline silently; later additions are fresh', () => {
|
||||
const first = diffNewDevices(['a', 'b'], undefined);
|
||||
assert.deepEqual(first, { fresh: [], known: ['a', 'b'] });
|
||||
const none = diffNewDevices(['a', 'b'], ['a', 'b']);
|
||||
assert.deepEqual(none.fresh, []);
|
||||
assert.equal(none.known.length, 2);
|
||||
const added = diffNewDevices(['a', 'b', 'c'], ['a', 'b']);
|
||||
assert.deepEqual(added.fresh, ['c']);
|
||||
assert.deepEqual(added.known, ['a', 'b', 'c']);
|
||||
// removed devices stay in known (harmless, keeps the list append-only)
|
||||
const removed = diffNewDevices(['a'], ['a', 'b']);
|
||||
assert.deepEqual(removed.fresh, []);
|
||||
});
|
||||
|
||||
test('spaceDisplayOf: room-card metric flags default to off', () => {
|
||||
const d0 = spaceDisplayOf({ plan_url: 'x', settings: {} });
|
||||
assert.deepEqual([d0.labelTemp, d0.labelHum, d0.labelLqi, d0.labelLight], [false, false, false, false]);
|
||||
const d1 = spaceDisplayOf({ plan_url: 'x', settings: { label_temp: true, label_light: true } });
|
||||
assert.deepEqual([d1.labelTemp, d1.labelHum, d1.labelLqi, d1.labelLight], [true, false, false, true]);
|
||||
});
|
||||
|
||||
test('splitRoomPath: polyline cut of a square', () => {
|
||||
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
// Г-образный разрез: верхняя стена (4,0) → внутрь (4,6) → правая стена (10,6)
|
||||
const parts = splitRoomPath(sq, [[4, 0], [4, 6], [10, 6]], 1e-6);
|
||||
assert.ok(parts);
|
||||
const [p1, p2] = parts;
|
||||
const area = (p) => Math.abs(p.reduce((a, _, i) => {
|
||||
const [x1, y1] = p[i], [x2, y2] = p[(i + 1) % p.length];
|
||||
return a + x1 * y2 - x2 * y1;
|
||||
}, 0)) / 2;
|
||||
assert.ok(Math.abs(area(p1) + area(p2) - 100) < 1e-6);
|
||||
// меньшая часть — прямоугольник 6x6 минус... фактически площадь 4*6+... проверим что обе > 0
|
||||
assert.ok(area(p1) > 0 && area(p2) > 0);
|
||||
});
|
||||
|
||||
test('splitRoomPath: two points == classic straight chord', () => {
|
||||
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
const parts = splitRoomPath(sq, [[5, 0], [5, 10]], 1e-6);
|
||||
assert.ok(parts);
|
||||
});
|
||||
|
||||
test('splitRoomPath rejects bad paths', () => {
|
||||
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
// промежуточная точка снаружи
|
||||
assert.equal(splitRoomPath(sq, [[4, 0], [4, -3], [10, 6]], 1e-6), null);
|
||||
// сегмент пересекает стену (выходит и возвращается)
|
||||
assert.equal(splitRoomPath(sq, [[4, 0], [14, 5], [10, 6]], 1e-6), null);
|
||||
// конец не на стене
|
||||
assert.equal(splitRoomPath(sq, [[4, 0], [5, 5]], 1e-6), null);
|
||||
// самопересечение пути
|
||||
assert.equal(splitRoomPath(sq, [[2, 0], [8, 8], [8, 2], [2, 8], [0, 4]], 1e-6), null);
|
||||
// менее двух точек
|
||||
assert.equal(splitRoomPath(sq, [[4, 0]], 1e-6), null);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
NORM_W, spaceModels, roomBounds, roomCenter, defaultPositions, markerPos, labelPos,
|
||||
} from '../test-build/space-geometry.js';
|
||||
|
||||
const cfg = {
|
||||
spaces: [{
|
||||
id: 'f1', title: '1st', aspect: 2, plan_url: '/plans/f1.svg', view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'r1', name: 'Room', area: 'a1', poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]] }],
|
||||
}, {
|
||||
id: 'yard', title: 'Yard', aspect: 1, view_box: [0, 0, 1, 1], rooms: [],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
|
||||
test('spaceModels: scales vb/rooms by NORM_W and H=NORM_W/aspect; bg only with plan_url', () => {
|
||||
const m = spaceModels(cfg);
|
||||
assert.equal(m.length, 2);
|
||||
const f1 = m[0];
|
||||
assert.deepEqual(f1.vb, [0, 0, 1000, 500]); // aspect 2 → H 500
|
||||
assert.equal(f1.bg.href, '/plans/f1.svg');
|
||||
assert.deepEqual(f1.rooms[0].poly, [[100, 50], [500, 50], [500, 250], [100, 250]]);
|
||||
assert.equal(m[1].bg, null); // no plan_url
|
||||
assert.equal(spaceModels(null).length, 0);
|
||||
});
|
||||
|
||||
test('roomBounds + roomCenter for a polygon', () => {
|
||||
const r = spaceModels(cfg)[0].rooms[0];
|
||||
assert.deepEqual(roomBounds(r), { x: 100, y: 50, w: 400, h: 200 });
|
||||
assert.deepEqual(roomCenter(r), [300, 150]);
|
||||
});
|
||||
|
||||
test('markerPos: saved layout → default grid → space centre', () => {
|
||||
const model = spaceModels(cfg)[0];
|
||||
const dev = { id: 'd1', space: 'f1', area: 'a1', entities: [] };
|
||||
// saved layout (normalized) → render units
|
||||
assert.deepEqual(
|
||||
markerPos(dev, { d1: { s: 'f1', x: 0.2, y: 0.3 } }, cfg, {}, model),
|
||||
{ x: 200, y: 150 }, // 0.2*1000, 0.3*(1000/2)
|
||||
);
|
||||
// default grid position (inside the room)
|
||||
const defPos = defaultPositions([dev], model, 2.5);
|
||||
assert.ok(defPos.d1);
|
||||
assert.deepEqual(markerPos(dev, {}, cfg, defPos, model), defPos.d1);
|
||||
const b = roomBounds(model.rooms[0]);
|
||||
assert.ok(defPos.d1.x >= b.x && defPos.d1.x <= b.x + b.w && defPos.d1.y >= b.y && defPos.d1.y <= b.y + b.h);
|
||||
// no layout, no defPos → space centre
|
||||
assert.deepEqual(markerPos(dev, {}, cfg, {}, model), { x: 500, y: 250 });
|
||||
});
|
||||
|
||||
test('labelPos: saved rl_<id> → render units; else room centre', () => {
|
||||
const model = spaceModels(cfg)[0];
|
||||
const r = model.rooms[0];
|
||||
assert.deepEqual(labelPos(r, 'f1', { rl_r1: { s: 'f1', x: 0.3, y: 0.4 } }, cfg), { x: 300, y: 200 });
|
||||
assert.deepEqual(labelPos(r, 'f1', {}, cfg), { x: 300, y: 150 }); // room centre
|
||||
});
|
||||
|
||||
test('defaultPositions: several devices in one room are spread (declumped, distinct)', () => {
|
||||
const model = spaceModels(cfg)[0];
|
||||
const devs = [0, 1, 2, 3].map((i) => ({ id: 'd' + i, space: 'f1', area: 'a1', entities: [] }));
|
||||
const pos = defaultPositions(devs, model, 2.5);
|
||||
assert.equal(Object.keys(pos).length, 4);
|
||||
const keys = Object.keys(pos);
|
||||
for (let i = 0; i < keys.length; i++)
|
||||
for (let j = i + 1; j < keys.length; j++) {
|
||||
const a = pos[keys[i]], b = pos[keys[j]];
|
||||
assert.ok(Math.hypot(a.x - b.x, a.y - b.y) > 1, 'positions distinct');
|
||||
}
|
||||
});
|
||||
|
||||
test('NORM_W is 1000', () => assert.equal(NORM_W, 1000));
|
||||
+3
-2
@@ -11,6 +11,7 @@
|
||||
"src/logic.ts",
|
||||
"src/rules.ts",
|
||||
"src/devices.ts",
|
||||
"src/types.ts"
|
||||
"src/types.ts",
|
||||
"src/space-geometry.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user