Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
fbd3887e14 | ||
|
|
5f9cfb9e10 | ||
|
|
cc27a6b2fa | ||
|
|
927e7d7054 | ||
|
|
dc8cf3070d | ||
|
|
3e8b1c3778 | ||
|
|
72b2492554 | ||
|
|
2ee0b2c352 | ||
|
|
3eb5b4a14d | ||
|
|
eaa722679f | ||
|
|
4f8e98cdc7 | ||
|
|
6c8b509da2 | ||
|
|
4146e5998e | ||
|
|
34d073f484 |
@@ -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.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
@@ -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) и **поворот** — тоже индивидуальные: вентиль на стене
|
||||
может быть маленьким и повёрнутым так, как он установлен.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 76 KiB |
@@ -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
|
||||
|
||||
@@ -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.13.2"
|
||||
VERSION = "1.30.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", [])
|
||||
],
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.13.2"
|
||||
"version": "1.30.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._-]+")
|
||||
@@ -78,14 +78,50 @@ ROOM_SCHEMA = vol.All(
|
||||
),
|
||||
_require_geometry,
|
||||
)
|
||||
SPACE_DISPLAY_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional("show_borders"): bool,
|
||||
vol.Optional("show_names"): bool,
|
||||
vol.Optional("room_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
vol.Optional("room_opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
|
||||
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,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
SPACE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("title"): str,
|
||||
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
|
||||
vol.Optional("plan_url"): vol.Any(str, None),
|
||||
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("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,
|
||||
@@ -104,6 +140,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)
|
||||
],
|
||||
@@ -114,7 +156,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,17 @@
|
||||
# Synthetic demo home
|
||||
|
||||
A fully fictional house (plans, devices, states) used for README screenshots,
|
||||
the demo GIF and headless smoke tests — so no real home data ever appears in
|
||||
public materials.
|
||||
|
||||
- `srv/demo.html` — self-contained host page: `<ha-icon>`/`<ha-card>` stubs and a
|
||||
fake `hass` (registries, states, `callWS`, `callService`, floors).
|
||||
- `srv/assets/` — generated plan SVGs and `icons.js` (`node demo/gen_icons.mjs`,
|
||||
needs the repo's devDependencies). The card bundle is copied from `dist/`:
|
||||
`cp dist/houseplan-card.js demo/srv/assets/`.
|
||||
- `serve.mjs` — playwright launcher (route interception, no web server).
|
||||
- `smoke_*.mjs` — feature smoke tests; run with a Chromium installed via
|
||||
`PLAYWRIGHT_BROWSERS_PATH=<dir> npx playwright install chromium-headless-shell`.
|
||||
|
||||
Note for sandboxed sessions: `/tmp` does not survive; this directory is the
|
||||
persistent home of the harness (docs/DEVELOPMENT.md has the LD_LIBRARY_PATH recipe).
|
||||
@@ -0,0 +1,22 @@
|
||||
// Generate demo/srv/assets/icons.js: an { "mdi:name": "<svg path>" } map for every
|
||||
// mdi: icon referenced in src/ and demo/ (the demo host stubs <ha-icon> with it).
|
||||
import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
||||
import * as mdi from '@mdi/js';
|
||||
|
||||
const names = new Set();
|
||||
const scan = (dir) => {
|
||||
for (const f of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (f.isDirectory()) { scan(`${dir}/${f.name}`); continue; }
|
||||
if (!/\.(ts|json|html|mjs)$/.test(f.name) || f.name === 'icons.js') continue;
|
||||
const txt = readFileSync(`${dir}/${f.name}`, 'utf8');
|
||||
for (const m of txt.matchAll(/mdi:([a-z0-9-]+)/g)) names.add(m[1]);
|
||||
}
|
||||
};
|
||||
scan('src'); scan('demo');
|
||||
const map = {};
|
||||
for (const n of [...names].sort()) {
|
||||
const camel = 'mdi' + n.replace(/(^|-)(\w)/g, (_, __, c) => c.toUpperCase());
|
||||
if (mdi[camel]) map['mdi:' + n] = mdi[camel];
|
||||
}
|
||||
writeFileSync('demo/srv/assets/icons.js', 'window.__ICONS=' + JSON.stringify(map) + ';\n');
|
||||
console.log('icons:', Object.keys(map).length, 'of', names.size, 'referenced');
|
||||
@@ -0,0 +1,25 @@
|
||||
// Репро №3: пользовательский путь — открыть диалог, выставить «комфорт от 25», сохранить
|
||||
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;
|
||||
// living room: единственный термометр 22.4 → подменим на 24.0 как у пользователя
|
||||
const h = window.__mkHass();
|
||||
h.states = { ...h.states, 'sensor.living_temp': { ...h.states['sensor.living_temp'], state: '24.0' } };
|
||||
c.hass = h; c._regSignature=''; c._maybeRebuildDevices(); await c.updateComplete;
|
||||
// путь пользователя: диалог → режим temp, min=25 (макс не трогаем) → сохранить
|
||||
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
|
||||
out.dialogInit = { min: c._spaceDialog.tempMin, max: c._spaceDialog.tempMax, fill: c._spaceDialog.fillMode };
|
||||
c._spaceDialog = { ...c._spaceDialog, fillMode: 'temp', tempMin: 25 };
|
||||
await c._saveSpaceDialog(); await c.updateComplete;
|
||||
const sp = c._serverCfg.spaces.find(s=>s.id==='f1');
|
||||
out.savedSettings = sp.settings;
|
||||
const styles = [...sr().querySelectorAll('.room.styled')].map(r => (r.getAttribute('style')||'').match(/--room-fill:([^;]+)/)?.[1]);
|
||||
out.livingFill = styles[0];
|
||||
out.expected = '#4fc3f7 (blue: 24 < 25)';
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,28 @@
|
||||
// Shared launcher for demo captures: starts headless Chromium serving demo/srv/
|
||||
// via request interception (no HTTP server needed). Usage: const {page,browser}=await launch();
|
||||
import { chromium } from 'playwright';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
const ROOT = dirname(fileURLToPath(import.meta.url)) + '/srv';
|
||||
const CT = { '.html': 'text/html', '.js': 'text/javascript', '.svg': 'image/svg+xml' };
|
||||
export async function launch(viewport = { width: 820, height: 760 }, scale = 1) {
|
||||
const browser = await chromium.launch({ args: ['--no-sandbox'] });
|
||||
const page = await (await browser.newContext({ viewport, deviceScaleFactor: scale })).newPage();
|
||||
page.on('pageerror', (e) => console.log('EXC', e.message));
|
||||
await page.route('**/*', (r) => {
|
||||
const u = new URL(r.request().url());
|
||||
let p = decodeURIComponent(u.pathname);
|
||||
if (p === '/') p = '/demo.html';
|
||||
const f = ROOT + p;
|
||||
existsSync(f)
|
||||
? r.fulfill({ status: 200, headers: { 'content-type': CT[p.slice(p.lastIndexOf('.'))] || 'application/octet-stream' }, body: readFileSync(f) })
|
||||
: r.fulfill({ status: 404, body: 'nf' });
|
||||
});
|
||||
await page.goto('http://demo.local/demo.html', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.__card?._model?.length > 0, { timeout: 9000 });
|
||||
// hass flows continuously in production; the stub sets it once — nudge a rebuild
|
||||
await page.evaluate(() => { const c = window.__card; c.hass = { ...c.hass }; });
|
||||
await page.waitForFunction(() => window.__card._devices.length > 0, { timeout: 9000 });
|
||||
return { page, browser };
|
||||
}
|
||||
@@ -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,74 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const mk = () => document.createElement('houseplan-card');
|
||||
const sr = (c) => c.shadowRoot || c.renderRoot;
|
||||
|
||||
// 1) пустая инсталляция: ноль устройств/зон, конфиг с пространством без комнат
|
||||
const c1 = mk();
|
||||
c1.setConfig({ type: 'custom:houseplan-card' });
|
||||
document.body.appendChild(c1);
|
||||
c1.hass = { language:'en', locale:{language:'en'}, devices:{}, entities:{}, areas:{}, states:{},
|
||||
callWS: async (m) => m.type==='houseplan/config/get'
|
||||
? { config:{ spaces:[{ id:'s1', title:'Empty', plan_url:null, aspect:1.4, view_box:[0,0,1,1], rooms:[], segments:[] }], markers:[], settings:{} }, rev:1 }
|
||||
: { layout:{} },
|
||||
connection:{ subscribeEvents: async()=>()=>{} } };
|
||||
await new Promise(r=>setTimeout(r,150));
|
||||
c1.hass = { ...c1.hass };
|
||||
await c1.updateComplete; await new Promise(r=>setTimeout(r,50)); await c1.updateComplete;
|
||||
out.emptyDevices = c1._devices.length;
|
||||
out.emptyRenders = !!sr(c1).querySelector('.stage');
|
||||
out.emptyCount = sr(c1).querySelector('.count')?.textContent.trim();
|
||||
c1.remove();
|
||||
|
||||
// 2) XSS в именах комнат и устройств
|
||||
const c2 = mk();
|
||||
c2.setConfig({ type: 'custom:houseplan-card' });
|
||||
document.body.appendChild(c2);
|
||||
const evil = '<img src=x onerror=window.__pwned=1><b>bold</b>';
|
||||
c2.hass = { language:'en', locale:{language:'en'},
|
||||
devices:{ d1:{ id:'d1', name: evil, model:'M<script>1</script>', area_id:'a1', identifiers:[['x','1']] } },
|
||||
entities:{}, areas:{ a1:{ area_id:'a1', name:'A1' } }, states:{},
|
||||
callWS: async (m) => m.type==='houseplan/config/get'
|
||||
? { config:{ spaces:[{ id:'s1', title:'S', plan_url:null, aspect:1, view_box:[0,0,1,1],
|
||||
rooms:[{ id:'r1', name: evil, area:'a1', poly:[[0.1,0.1],[0.9,0.1],[0.9,0.9],[0.1,0.9]] }], segments:[] }], markers:[], settings:{} }, rev:1 }
|
||||
: { layout:{} },
|
||||
connection:{ subscribeEvents: async()=>()=>{} } };
|
||||
await new Promise(r=>setTimeout(r,150));
|
||||
c2.hass = { ...c2.hass };
|
||||
await c2.updateComplete; await new Promise(r=>setTimeout(r,50)); await c2.updateComplete;
|
||||
out.xssPwned = !!window.__pwned;
|
||||
const lbl = sr(c2).querySelector('.roomlabel');
|
||||
out.xssLabelIsText = lbl ? lbl.innerHTML.includes('<img') || lbl.textContent.includes('<img') : 'no label';
|
||||
out.xssDeviceRendered = sr(c2).querySelectorAll('.dev').length;
|
||||
c2.remove();
|
||||
|
||||
// 3) легаси-записи layout v1 {x,y} без ключа s — игнорируются в norm-режиме
|
||||
const c = window.__card;
|
||||
c._layout = { ...c._layout, d_light1_legacy_test: { x: 500, y: 300 } };
|
||||
const fake = { id: 'd_light1_legacy_test', space: 'f1' };
|
||||
const p = c._pos(fake);
|
||||
out.legacyIgnored = p.y !== 300; // centre y=400 if ignored; raw 300 would mean it leaked through
|
||||
delete c._layout.d_light1_legacy_test;
|
||||
|
||||
// 4) перф: 150 устройств
|
||||
const t0 = performance.now();
|
||||
const devices = {}; const entities = {}; const states = {};
|
||||
for (let i = 0; i < 150; i++) {
|
||||
devices['p'+i] = { id:'p'+i, name:'Plug '+i, model:'Smart Plug', area_id:'living_room', identifiers:[['demo','p'+i]] };
|
||||
entities['switch.p'+i] = { entity_id:'switch.p'+i, device_id:'p'+i, platform:'demo' };
|
||||
states['switch.p'+i] = { state: i%2?'on':'off', attributes:{ linkquality: 50+i } };
|
||||
}
|
||||
const bigHass = { ...window.__mkHass(), devices:{...window.__mkHass().devices, ...devices},
|
||||
entities:{...window.__mkHass().entities, ...entities}, states:{...window.__mkHass().states, ...states} };
|
||||
c.hass = bigHass; c._regSignature=''; c._maybeRebuildDevices();
|
||||
out.bigBuildMs = Math.round(performance.now() - t0);
|
||||
out.bigCount = c._devices.length;
|
||||
await c.updateComplete;
|
||||
const t1 = performance.now(); c.requestUpdate(); await c.updateComplete;
|
||||
out.bigRenderMs = Math.round(performance.now() - t1);
|
||||
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,28 @@
|
||||
// Asserts device icon badges sit exactly on their anchor point (no content-box/border drift).
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 1000 }, 2);
|
||||
const bad = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
c._space = 'f1'; c._resetZoom(); c.requestUpdate();
|
||||
const layer = c.renderRoot.querySelector('.devlayer');
|
||||
const lr = layer.getBoundingClientRect();
|
||||
const bad = [];
|
||||
for (const d of layer.querySelectorAll('.dev')) {
|
||||
const ax = lr.left + (parseFloat(d.style.left) / 100) * lr.width;
|
||||
const ay = lr.top + (parseFloat(d.style.top) / 100) * lr.height;
|
||||
const r = d.getBoundingClientRect();
|
||||
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
|
||||
const offX = cx - ax, offY = cy - ay;
|
||||
// glyph centred within the badge
|
||||
const icon = d.querySelector('ha-icon');
|
||||
const svg = icon?.shadowRoot?.querySelector('svg') || icon?.shadowRoot?.querySelector('ha-svg-icon')?.shadowRoot?.querySelector('svg');
|
||||
let gX = 0, gY = 0;
|
||||
if (svg) { const sr = svg.getBoundingClientRect(); gX = sr.left + sr.width / 2 - cx; gY = sr.top + sr.height / 2 - cy; }
|
||||
if (Math.abs(offX) > 0.6 || Math.abs(offY) > 0.6 || Math.abs(gX) > 0.6 || Math.abs(gY) > 0.6)
|
||||
bad.push({ icon: icon?.getAttribute('icon'), offX: +offX.toFixed(2), offY: +offY.toFixed(2), glyphX: +gX.toFixed(2), glyphY: +gY.toFixed(2) });
|
||||
}
|
||||
return bad;
|
||||
});
|
||||
await browser.close();
|
||||
if (bad.length) { console.error('FAIL icon-center: badges off anchor', JSON.stringify(bad.slice(0, 5))); process.exit(1); }
|
||||
console.log('OK icon-center: all device badges centred on their anchor');
|
||||
@@ -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,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,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._toggleMarkup(); 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===4;
|
||||
// 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; // 5
|
||||
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,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,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,50 @@
|
||||
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.defaultStyled = sr().querySelectorAll('.room.styled').length;
|
||||
out.defaultLabels = sr().querySelectorAll('.roomlabel').length;
|
||||
// 2) включаем границы+имена+заливку по свету
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : {
|
||||
...s, settings: { show_borders: true, show_names: true, room_color: '#ff8800', room_opacity: 0.8, fill_mode: 'light' },
|
||||
})};
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.styled = sr().querySelectorAll('.room.styled').length;
|
||||
out.labels = [...sr().querySelectorAll('.roomlabel')].map((l) => l.textContent.trim());
|
||||
const liv = [...sr().querySelectorAll('.room.styled')][0];
|
||||
out.livingStyle = liv.getAttribute('style');
|
||||
// living: ceiling on → жёлтая; kitchen: нет light-сущностей → без заливки; bedroom light off → серая
|
||||
const styles = [...sr().querySelectorAll('.room.styled')].map((r) => r.getAttribute('style'));
|
||||
out.hasYellow = styles.some((s) => s.includes('#ffd45c'));
|
||||
out.hasGrey = styles.some((s) => s.includes('#9aa0a6'));
|
||||
out.kitchenNoFill = styles.some((s) => s.includes('--room-fill:transparent'));
|
||||
// 3) lqi-заливка
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : {
|
||||
...s, settings: { ...s.settings, fill_mode: 'lqi' },
|
||||
})};
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.lqiFills = [...sr().querySelectorAll('.room.styled')].filter((r) => (r.getAttribute('style') || '').includes('hsl(')).length;
|
||||
// 4) drag лейбла → layout rl_
|
||||
const lbl = sr().querySelector('.roomlabel');
|
||||
c._labelDown({ preventDefault(){}, stopPropagation(){}, clientX: 100, clientY: 100, target: { setPointerCapture(){} }, pointerId: 5 },
|
||||
c._spaceModel().rooms[0], 'f1');
|
||||
c._labelMove({ clientX: 160, clientY: 140 }, c._spaceModel().rooms[0], 'f1');
|
||||
c._labelUp(c._spaceModel().rooms[0]);
|
||||
out.labelSaved = !!c._layout['rl_r1'];
|
||||
// 5) диалог: create + draw
|
||||
c._openSpaceDialog('create'); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, title: 'Attic', source: 'draw', orientation: 'square' };
|
||||
await c.updateComplete;
|
||||
out.saveEnabled = !sr().querySelector('.dialog .btn.on[disabled]');
|
||||
await c._saveSpaceDialog(); await c.updateComplete;
|
||||
const attic = c._serverCfg.spaces.find((s) => s.title === 'Attic');
|
||||
out.atticAspect = attic?.aspect;
|
||||
out.atticSettings = attic?.settings;
|
||||
out.atticNoPlan = attic ? attic.plan_url === null : null;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -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._toggleMarkup(); 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,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();
|
||||
@@ -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;
|
||||
const setFill = async (settings) => {
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({ ...s, settings })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
return [...sr().querySelectorAll('.room.styled')].map((r) => {
|
||||
const st = r.getAttribute('style') || '';
|
||||
const m = st.match(/--room-fill:([^;]+)/);
|
||||
return m ? m[1] : null;
|
||||
});
|
||||
};
|
||||
// living room avg temp = 22.4 (единственный термометр в demo)
|
||||
out.comfy = await setFill({ show_borders: true, fill_mode: 'temp', temp_min: 20, temp_max: 25 });
|
||||
out.cold = await setFill({ show_borders: true, fill_mode: 'temp', temp_min: 23, temp_max: 26 });
|
||||
out.hot = await setFill({ show_borders: true, fill_mode: 'temp', temp_min: 18, temp_max: 21 });
|
||||
out.swapped = await setFill({ show_borders: true, fill_mode: 'temp', temp_min: 25, temp_max: 20 });
|
||||
// диалог: поля границ видны только в режиме temp
|
||||
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
|
||||
out.dialogTempFields = sr().querySelectorAll('.tempin').length;
|
||||
c._spaceDialog = { ...c._spaceDialog, fillMode: 'none' }; await c.updateComplete;
|
||||
out.dialogHiddenWhenNone = sr().querySelectorAll('.tempin').length;
|
||||
c._spaceDialog = null;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,38 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 640, height: 980 }, 2);
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c.hass = { ...c.hass, locale: { language: 'ru' } };
|
||||
await c.updateComplete;
|
||||
// заливка temp вкл → класс filled и тултип с температурой
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({ ...s,
|
||||
settings: { show_borders: true, show_names: true, fill_mode: 'temp', temp_min: 20, temp_max: 25 } })) };
|
||||
c._regSignature=''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
out.filledClass = sr().querySelectorAll('.room.styled.filled').length; // только living (термометр)
|
||||
out.unfilled = sr().querySelectorAll('.room.styled:not(.filled)').length;
|
||||
// тултип комнаты: средняя температура
|
||||
const room = sr().querySelector('.room');
|
||||
room.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, composed: true, clientX: 200, clientY: 200 }));
|
||||
await c.updateComplete;
|
||||
out.tipTemp = c._tip?.temp;
|
||||
out.tipHasTempLine = (sr().querySelector('.tip')?.textContent || '').includes('средняя температура');
|
||||
c._tip = null;
|
||||
// диалог: радио заливки, компактные поля, ширина
|
||||
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
|
||||
out.fillRadios = sr().querySelectorAll('input[name="fillmode"]').length;
|
||||
out.tempInputs = sr().querySelectorAll('.temprange .tempin').length;
|
||||
out.dialogWide = !!sr().querySelector('.dialog.wide .srcrow');
|
||||
out.dialogWidth = Math.round(sr().querySelector('.dialog').getBoundingClientRect().width);
|
||||
// NaN-защита: пустой ввод не ломает границы
|
||||
const before = c._spaceDialog.tempMax;
|
||||
const fakeEv = { target: { value: '' } };
|
||||
// симулируем input с пустым значением через обработчик радио-строки — парсер должен сохранить старое
|
||||
const n = parseFloat('');
|
||||
out.nanGuard = !Number.isFinite(n) && c._spaceDialog.tempMax === before;
|
||||
return out;
|
||||
});
|
||||
await page.screenshot({ path: '/tmp/ux_dialog.png' });
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 800">
|
||||
<rect width="1000" height="800" fill="#f4f1ea"/>
|
||||
<g opacity="0.35" stroke="#d9d2c4" stroke-width="1"><line x1="0" y1="0" x2="0" y2="800"/><line x1="50" y1="0" x2="50" y2="800"/><line x1="100" y1="0" x2="100" y2="800"/><line x1="150" y1="0" x2="150" y2="800"/><line x1="200" y1="0" x2="200" y2="800"/><line x1="250" y1="0" x2="250" y2="800"/><line x1="300" y1="0" x2="300" y2="800"/><line x1="350" y1="0" x2="350" y2="800"/><line x1="400" y1="0" x2="400" y2="800"/><line x1="450" y1="0" x2="450" y2="800"/><line x1="500" y1="0" x2="500" y2="800"/><line x1="550" y1="0" x2="550" y2="800"/><line x1="600" y1="0" x2="600" y2="800"/><line x1="650" y1="0" x2="650" y2="800"/><line x1="700" y1="0" x2="700" y2="800"/><line x1="750" y1="0" x2="750" y2="800"/><line x1="800" y1="0" x2="800" y2="800"/><line x1="850" y1="0" x2="850" y2="800"/><line x1="900" y1="0" x2="900" y2="800"/><line x1="950" y1="0" x2="950" y2="800"/><line x1="0" y1="0" x2="1000" y2="0"/><line x1="0" y1="50" x2="1000" y2="50"/><line x1="0" y1="100" x2="1000" y2="100"/><line x1="0" y1="150" x2="1000" y2="150"/><line x1="0" y1="200" x2="1000" y2="200"/><line x1="0" y1="250" x2="1000" y2="250"/><line x1="0" y1="300" x2="1000" y2="300"/><line x1="0" y1="350" x2="1000" y2="350"/><line x1="0" y1="400" x2="1000" y2="400"/><line x1="0" y1="450" x2="1000" y2="450"/><line x1="0" y1="500" x2="1000" y2="500"/><line x1="0" y1="550" x2="1000" y2="550"/><line x1="0" y1="600" x2="1000" y2="600"/><line x1="0" y1="650" x2="1000" y2="650"/><line x1="0" y1="700" x2="1000" y2="700"/><line x1="0" y1="750" x2="1000" y2="750"/></g>
|
||||
<rect x="40" y="40" width="920" height="720" fill="none" stroke="#3b4753" stroke-width="14"/>
|
||||
<line x1="550" y1="40" x2="550" y2="300" stroke="#3b4753" stroke-width="10"/>
|
||||
<line x1="550" y1="380" x2="550" y2="620" stroke="#3b4753" stroke-width="10"/>
|
||||
<line x1="550" y1="700" x2="550" y2="760" stroke="#3b4753" stroke-width="10"/>
|
||||
<line x1="550" y1="360" x2="820" y2="360" stroke="#3b4753" stroke-width="10"/>
|
||||
<line x1="900" y1="360" x2="960" y2="360" stroke="#3b4753" stroke-width="10"/>
|
||||
<line x1="40" y1="480" x2="300" y2="480" stroke="#3b4753" stroke-width="10"/>
|
||||
<line x1="380" y1="480" x2="550" y2="480" stroke="#3b4753" stroke-width="10"/>
|
||||
<rect x="120" y="46" width="180" height="10" fill="#a8c6e8"/>
|
||||
<rect x="640" y="46" width="140" height="10" fill="#a8c6e8"/>
|
||||
<rect x="700" y="744" width="140" height="10" fill="#a8c6e8"/>
|
||||
<text x="295" y="270" font-family="Arial" font-size="30" letter-spacing="4" fill="#8b95a1" text-anchor="middle" opacity="0.55">LIVING ROOM</text>
|
||||
<text x="775" y="200" font-family="Arial" font-size="30" letter-spacing="4" fill="#8b95a1" text-anchor="middle" opacity="0.55">KITCHEN</text>
|
||||
<text x="775" y="560" font-family="Arial" font-size="30" letter-spacing="4" fill="#8b95a1" text-anchor="middle" opacity="0.55">BEDROOM</text>
|
||||
<text x="295" y="640" font-family="Arial" font-size="30" letter-spacing="4" fill="#8b95a1" text-anchor="middle" opacity="0.55">HALLWAY</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 700">
|
||||
<rect width="1000" height="700" fill="#eef3e6"/>
|
||||
<rect x="30" y="30" width="940" height="640" fill="none" stroke="#7c9c6b" stroke-width="10" stroke-dasharray="24 14"/>
|
||||
<circle cx="260" cy="240" r="90" fill="#dbe8cd" stroke="#9dba8a" stroke-width="4"/>
|
||||
<circle cx="700" cy="450" r="130" fill="#d3e4f2" stroke="#8fb6d8" stroke-width="4"/>
|
||||
<rect x="600" y="80" width="260" height="150" fill="#e7ddc8" stroke="#b8a888" stroke-width="5"/>
|
||||
<text x="730" y="165" font-family="Arial" font-size="26" letter-spacing="3" fill="#8b95a1" text-anchor="middle" opacity="0.6">GARAGE</text>
|
||||
<text x="260" y="248" font-family="Arial" font-size="22" letter-spacing="3" fill="#7d9169" text-anchor="middle" opacity="0.7">LAWN</text>
|
||||
<text x="700" y="458" font-family="Arial" font-size="22" letter-spacing="3" fill="#6f93ad" text-anchor="middle" opacity="0.7">POOL</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 920 B |
@@ -0,0 +1,148 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>House Plan demo</title>
|
||||
<style>
|
||||
:root{
|
||||
--primary-color:#3ea6ff; --primary-text-color:#e1e1e1; --secondary-text-color:#9aa4ad;
|
||||
--card-background-color:#1c2530; --ha-card-background:#1c2530;
|
||||
--divider-color:#33404d; --text-primary-color:#fff; --mdc-icon-size:24px;
|
||||
--header-height:0px;
|
||||
}
|
||||
html,body{margin:0;background:#0e1621;color:#e1e1e1;font-family:'Segoe UI',Roboto,Arial,sans-serif;}
|
||||
#host{width:780px;margin:0 auto;padding:8px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="host"></div>
|
||||
<script src="/assets/icons.js"></script>
|
||||
<script>
|
||||
class HaIcon extends HTMLElement{
|
||||
static get observedAttributes(){return ['icon'];}
|
||||
connectedCallback(){this._r();}
|
||||
attributeChangedCallback(){this._r();}
|
||||
set icon(v){this.setAttribute('icon',v);}
|
||||
get icon(){return this.getAttribute('icon');}
|
||||
_r(){
|
||||
const p=(window.__ICONS||{})[this.getAttribute('icon')]||'';
|
||||
if(!this.shadowRoot) this.attachShadow({mode:'open'});
|
||||
// Faithful to HA's ha-icon: host is display:block with a large inherited line-height,
|
||||
// which pushes the (smaller) svg off-centre unless the consumer neutralises it.
|
||||
this.shadowRoot.innerHTML='<style>:host{display:block;line-height:1.8;'+
|
||||
'width:var(--mdc-icon-size,24px);height:var(--mdc-icon-size,24px);'+
|
||||
'fill:currentColor;vertical-align:middle}'+
|
||||
'svg{width:var(--mdc-icon-size,24px);height:var(--mdc-icon-size,24px);fill:currentColor;'+
|
||||
'display:inline-block;vertical-align:baseline}</style>'+
|
||||
'<svg viewBox="0 0 24 24"><path d="'+p+'"></path></svg>';
|
||||
}
|
||||
}
|
||||
customElements.define('ha-icon',HaIcon);
|
||||
class HaCard extends HTMLElement{
|
||||
connectedCallback(){
|
||||
if(this.shadowRoot)return;
|
||||
this.attachShadow({mode:'open'});
|
||||
this.shadowRoot.innerHTML='<style>:host{display:block;background:var(--ha-card-background,#1c2530);'+
|
||||
'border-radius:12px;box-shadow:0 2px 8px rgba(0,0,0,.4);overflow:visible;color:var(--primary-text-color)}</style><slot></slot>';
|
||||
}
|
||||
}
|
||||
customElements.define('ha-card',HaCard);
|
||||
</script>
|
||||
<script type="module">
|
||||
const CFG = {
|
||||
spaces: [
|
||||
{ id:'f1', title:'Ground floor', plan_url:'/assets/f1.svg', aspect:1.25,
|
||||
view_box:[0,0,1,1],
|
||||
rooms:[
|
||||
{id:'r1', name:'Living room', area:'living_room', poly:[[0.04,0.05],[0.55,0.05],[0.55,0.6],[0.04,0.6]]},
|
||||
{id:'r2', name:'Kitchen', area:'kitchen', poly:[[0.55,0.05],[0.96,0.05],[0.96,0.45],[0.55,0.45]]},
|
||||
{id:'r3', name:'Bedroom', area:'bedroom', poly:[[0.55,0.45],[0.96,0.45],[0.96,0.95],[0.55,0.95]]},
|
||||
{id:'r4', name:'Hallway', area:'hallway', poly:[[0.04,0.6],[0.55,0.6],[0.55,0.95],[0.04,0.95]]}
|
||||
], segments:[] },
|
||||
{ id:'garden', title:'Garden', plan_url:'/assets/garden.svg', aspect:1.4286,
|
||||
view_box:[0,0,1,1],
|
||||
rooms:[ {id:'g1', name:'Garden', area:'garden', poly:[[0.03,0.04],[0.97,0.04],[0.97,0.96],[0.03,0.96]]} ], segments:[] }
|
||||
],
|
||||
markers: [],
|
||||
settings: {}
|
||||
};
|
||||
const LAYOUT = {
|
||||
d_light1:{s:'f1',x:0.22,y:0.22}, d_lamp:{s:'f1',x:0.42,y:0.5}, d_tv:{s:'f1',x:0.13,y:0.5},
|
||||
d_temp:{s:'f1',x:0.33,y:0.35}, d_kettle:{s:'f1',x:0.72,y:0.15}, d_leak:{s:'f1',x:0.88,y:0.34},
|
||||
d_bedlight:{s:'f1',x:0.75,y:0.6}, d_window:{s:'f1',x:0.9,y:0.85}, d_lock:{s:'f1',x:0.1,y:0.88},
|
||||
d_motion:{s:'f1',x:0.4,y:0.75}, d_mower:{s:'garden',x:0.25,y:0.35}, d_gate:{s:'garden',x:0.73,y:0.22}
|
||||
};
|
||||
const AREAS={living_room:{area_id:'living_room',name:'Living room'},kitchen:{area_id:'kitchen',name:'Kitchen'},
|
||||
bedroom:{area_id:'bedroom',name:'Bedroom'},hallway:{area_id:'hallway',name:'Hallway'},garden:{area_id:'garden',name:'Garden'}};
|
||||
function dev(id,name,model,area){return [id,{id,name,model,area_id:area,identifiers:[['demo',id]],entry_type:null,via_device_id:null}];}
|
||||
const DEVICES=Object.fromEntries([
|
||||
dev('d_light1','Ceiling light','Smart Bulb E27','living_room'),
|
||||
dev('d_lamp','Floor lamp','Smart Bulb E14','living_room'),
|
||||
dev('d_tv','TV','55U8HQ','living_room'),
|
||||
dev('d_temp','Temperature sensor','TH01','living_room'),
|
||||
dev('d_kettle','Kettle plug','Smart Plug','kitchen'),
|
||||
dev('d_leak','Sink leak sensor','SJCGQ11LM','kitchen'),
|
||||
dev('d_bedlight','Bedroom light','Smart Bulb E27','bedroom'),
|
||||
dev('d_window','Window sensor','MCCGQ11LM','bedroom'),
|
||||
dev('d_lock','Front door lock','Smart Lock S2','hallway'),
|
||||
dev('d_motion','Motion sensor','RTCGQ11LM','hallway'),
|
||||
dev('d_mower','Robot mower','Automower 305','garden'),
|
||||
dev('d_gate','Gate','GDO-4','garden'),
|
||||
]);
|
||||
const ENTITIES={
|
||||
'light.ceiling':{entity_id:'light.ceiling',device_id:'d_light1',platform:'demo'},
|
||||
'light.floor_lamp':{entity_id:'light.floor_lamp',device_id:'d_lamp',platform:'demo'},
|
||||
'media_player.tv':{entity_id:'media_player.tv',device_id:'d_tv',platform:'demo'},
|
||||
'sensor.living_temp':{entity_id:'sensor.living_temp',device_id:'d_temp',platform:'demo'},
|
||||
'switch.kettle':{entity_id:'switch.kettle',device_id:'d_kettle',platform:'demo'},
|
||||
'binary_sensor.sink_leak':{entity_id:'binary_sensor.sink_leak',device_id:'d_leak',platform:'demo'},
|
||||
'light.bedroom':{entity_id:'light.bedroom',device_id:'d_bedlight',platform:'demo'},
|
||||
'binary_sensor.window':{entity_id:'binary_sensor.window',device_id:'d_window',platform:'demo'},
|
||||
'lock.front_door':{entity_id:'lock.front_door',device_id:'d_lock',platform:'demo'},
|
||||
'binary_sensor.hall_motion':{entity_id:'binary_sensor.hall_motion',device_id:'d_motion',platform:'demo'},
|
||||
'vacuum.mower':{entity_id:'vacuum.mower',device_id:'d_mower',platform:'demo'},
|
||||
'cover.gate':{entity_id:'cover.gate',device_id:'d_gate',platform:'demo'},
|
||||
};
|
||||
let STATES={
|
||||
'light.ceiling':{entity_id:'light.ceiling',state:'on',attributes:{friendly_name:'Ceiling light'}},
|
||||
'light.floor_lamp':{entity_id:'light.floor_lamp',state:'off',attributes:{friendly_name:'Floor lamp',linkquality:196}},
|
||||
'media_player.tv':{entity_id:'media_player.tv',state:'playing',attributes:{friendly_name:'TV'}},
|
||||
'sensor.living_temp':{entity_id:'sensor.living_temp',state:'22.4',attributes:{friendly_name:'Temperature',device_class:'temperature',unit_of_measurement:'°C',linkquality:154}},
|
||||
'switch.kettle':{entity_id:'switch.kettle',state:'off',attributes:{friendly_name:'Kettle plug',linkquality:182}},
|
||||
'binary_sensor.sink_leak':{entity_id:'binary_sensor.sink_leak',state:'off',attributes:{friendly_name:'Leak',device_class:'moisture',linkquality:117}},
|
||||
'light.bedroom':{entity_id:'light.bedroom',state:'off',attributes:{friendly_name:'Bedroom light'}},
|
||||
'binary_sensor.window':{entity_id:'binary_sensor.window',state:'on',attributes:{friendly_name:'Window',device_class:'window',linkquality:88}},
|
||||
'lock.front_door':{entity_id:'lock.front_door',state:'locked',attributes:{friendly_name:'Front door',linkquality:143}},
|
||||
'binary_sensor.hall_motion':{entity_id:'binary_sensor.hall_motion',state:'on',attributes:{friendly_name:'Motion',device_class:'motion',linkquality:64}},
|
||||
'vacuum.mower':{entity_id:'vacuum.mower',state:'cleaning',attributes:{friendly_name:'Mower'}},
|
||||
'cover.gate':{entity_id:'cover.gate',state:'closed',attributes:{friendly_name:'Gate'}},
|
||||
};
|
||||
function mkHass(){
|
||||
return {
|
||||
language:'en', locale:{language:'en'},
|
||||
devices:DEVICES, entities:ENTITIES, areas:AREAS, states:STATES,
|
||||
floors:{g:{floor_id:'g',name:'Ground floor',level:0},u:{floor_id:'u',name:'Upstairs',level:1}},
|
||||
callWS:async (m)=>{
|
||||
if(m.type==='houseplan/config/get')return{config:CFG,rev:1};
|
||||
if(m.type==='houseplan/layout/get')return{layout:LAYOUT};
|
||||
return {ok:true,rev:2};
|
||||
},
|
||||
callService:async (d,s,data)=>{
|
||||
const eid=data.entity_id, st=STATES[eid];
|
||||
if(st){const on=['on','playing','unlocked','open'].includes(st.state);
|
||||
STATES={...STATES,[eid]:{...st,state:on?'off':'on'}};
|
||||
card.hass=mkHass();}
|
||||
},
|
||||
connection:{subscribeEvents:async()=>()=>{}},
|
||||
formatEntityState:(st)=>st.state,
|
||||
};
|
||||
}
|
||||
await import('/assets/houseplan-card.js');
|
||||
const card=document.createElement('houseplan-card');
|
||||
card.setConfig({type:'custom:houseplan-card', title:'House Plan', icon_size:3.4});
|
||||
document.getElementById('host').appendChild(card);
|
||||
card.hass=mkHass();
|
||||
window.__card=card;
|
||||
window.__mkHass=mkHass;
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -21,7 +21,6 @@ houseplan-card/
|
||||
│ ├─ config_flow.py # single entry; admin_only option (editing restricted to admins)
|
||||
│ ├─ const.py # DOMAIN, STORAGE_KEY, VERSION, FRONTEND_URL
|
||||
│ └─ frontend/houseplan-card.js # copy of dist, served as /houseplan_files/houseplan-card.js
|
||||
├─ assets/ # plan sources: f1_plan.svg, f2_plan.svg (REMPLANNER), *_bg.png (old raster versions)
|
||||
├─ hacs.json # HACS manifest
|
||||
└─ docs/ # this documentation
|
||||
```
|
||||
@@ -87,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),
|
||||
@@ -110,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 |
|
||||
@@ -132,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,413 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
`--room-stroke-op` — so the existing `stroke-opacity: 1` on hover reveals a crisp border
|
||||
(previously the stroke was `transparent` when borders were off, so hover showed nothing).
|
||||
|
||||
## v1.15.5 — 2026-07-08 (fix: room hover was always grey even when filled)
|
||||
- Room hover now **darkens the current fill** for filled rooms and only greys **unfilled**
|
||||
ones (as intended since v1.15.1). The legacy `.room.overlay:hover` / `.room.yard:hover`
|
||||
grey rules were still matching styled rooms and, being applied to `fill` directly, beat
|
||||
the `--room-fill` variable — so a temperature/light/zigbee-filled room turned grey on
|
||||
hover. Scoped those legacy rules with `:not(.styled)`; styled rooms are now governed only
|
||||
by `.styled.filled:hover` (brightness 0.78) and `.styled:not(.filled):hover` (grey).
|
||||
|
||||
## v1.15.4 — 2026-07-08 (fix: device icon vertical centering — proper root cause)
|
||||
- **Root cause of the off-centre icon, found in the live app** (the demo stub hid it):
|
||||
HA's real `<ha-icon>` host is `display:block` with a large inherited `line-height`
|
||||
(~22 px for a ~12 px glyph), so the SVG sat ~1.8 px **below** the badge centre.
|
||||
Fix: `.dev ha-icon` is now a zero-line-height flex box — the glyph centres exactly.
|
||||
- Reverted the v1.15.3 `box-sizing: border-box` (it shrank the badge by 2 px and made the
|
||||
vertical offset *more* visible — "worse"). The 1 px anchor drift is instead corrected by
|
||||
the centering margin (`-(size/2 + 1px)`), keeping the original badge size.
|
||||
- Verified in the real dashboard: anchor offset 0, glyph offset 0, badge size unchanged.
|
||||
- Demo `ha-icon` stub made faithful to HA (block + line-height) so `smoke_icon_center`
|
||||
now actually reproduces and guards this; the smoke also asserts glyph-in-badge centering.
|
||||
|
||||
## v1.15.3 — 2026-07-08 (fix: device icon 1px off its anchor point)
|
||||
- Device icon badges were sitting **1 px down-and-right of their true point**: `.dev`
|
||||
used the default `content-box`, so the 1 px border made the rendered square 2 px
|
||||
wider than the width the centering margin assumed. Added `box-sizing: border-box` —
|
||||
the badge centre now lands exactly on the device coordinate (verified in the demo:
|
||||
anchor offset 1 px → 0). The glyph itself was already centred within the square.
|
||||
|
||||
## v1.15.2 — 2026-07-08 (fix: room average temperature counted non-thermometers)
|
||||
- **Average room temperature now uses only devices the card treats as thermometers**
|
||||
(`mdi:thermometer` / `mdi:air-filter`). Previously `areaTemp` swept every device in the
|
||||
area through `tempFor`, so fridges, TRV heads and smart plugs leaked their readings into
|
||||
the average (e.g. a 8.3 °C valve/appliance temperature dragging a room down).
|
||||
- **`isTempEntity` now excludes chip/diagnostic temperatures**: `*_device_temperature`
|
||||
sensors and any entity in the `config`/`diagnostic` category are no longer treated as a
|
||||
room temperature — so even a genuine thermometer no longer reports its chip temperature.
|
||||
- Affects the temperature room fill and the room tooltip average. (+3 frontend tests: 46 → 48.)
|
||||
|
||||
## v1.15.1 — 2026-07-06 (display-settings UX round from live usage)
|
||||
- **Comfort-bounds input hardening**: clearing a temperature field can no longer
|
||||
collapse a bound to 0 (`Number('') === 0` — this silently turned "comfort from
|
||||
25" into a 0–25 range after the auto-swap, showing green at 24°). Inputs now
|
||||
parse with `parseFloat` + `isFinite` guard and the save path falls back to the
|
||||
defaults for non-finite values.
|
||||
- Room tooltip now shows the **average room temperature** (what the temperature
|
||||
fill actually uses — averages every thermometer in the area, including TRVs).
|
||||
- **Hover no longer recolors rooms blue**: filled rooms darken their current fill
|
||||
(`brightness(0.78)`), unfilled rooms get a light grey tint.
|
||||
- Fill mode selector is a **radio group** with short labels (no color legend);
|
||||
the comfort bounds sit compactly inline on the temperature row (56 px inputs).
|
||||
- The space dialog is wider (500 px) — the settings no longer feel cramped.
|
||||
|
||||
## v1.15.0 — 2026-07-06 (temperature room fill)
|
||||
- New room fill mode **"Temperature"**: light blue below the comfort range, green
|
||||
inside it, warm yellow above. The comfort bounds (default 20–25 °C) are editable
|
||||
right in the space dialog and appear only when the mode is selected; bounds
|
||||
entered in the wrong order are swapped automatically. A room's temperature is
|
||||
the average of its devices' temperature readings; rooms without a reading stay
|
||||
unfilled. (+`areaTemp` helper, 3 new frontend tests, backend schema fields.)
|
||||
|
||||
## v1.14.0 — 2026-07-06 (per-space display settings, hand-drawn spaces, testing checklist)
|
||||
- **Per-space "Display" settings** (space dialog): always-visible room borders,
|
||||
room name labels, a border/name color picker with an opacity slider, and a room
|
||||
fill mode — none / by zigbee signal (red→green) / by lights (yellow = something
|
||||
is on, grey = all lights off; rooms without lights stay unfilled).
|
||||
- **Room name labels are draggable** like device icons; positions persist server-side
|
||||
(layout keys `rl_<roomId>`), defaults to the room centre; hidden in markup mode.
|
||||
- **Hand-drawn spaces**: the space dialog got a "No image — I'll outline rooms by
|
||||
hand" option with a canvas orientation choice (landscape/portrait/square). Such
|
||||
spaces default to visible borders and names; switching an existing space to this
|
||||
mode detaches its background image. The plan image is no longer mandatory.
|
||||
- Backend: explicit validation schema for the new per-space settings (+test).
|
||||
- **`demo/` harness moved into the repository** (synthetic home, host page, capture
|
||||
and smoke scripts, icon-map generator) — public materials and smoke tests no
|
||||
longer depend on a perishable sandbox.
|
||||
- **`docs/TESTING.md`**: a comprehensive manual-testing checklist (environments
|
||||
matrix, every feature, edge cases); policy — updated in the same commit as any
|
||||
functional change. The first self-run found and fixed two bugs:
|
||||
`plan_url` not detached on image→draw switch, and a `_stateClass` crash on
|
||||
state objects without `entity_id`.
|
||||
- Tests: 43 frontend + 11 pure backend; new smokes `smoke_space_settings` and
|
||||
`smoke_edge_cases` (empty install, XSS names, legacy layout entries, 150-device perf).
|
||||
|
||||
## v1.13.3 — 2026-07-06 (privacy: drop legacy real-house plan sources)
|
||||
- Removed the legacy `assets/` directory (real floor-plan sources from the pre-v1.3
|
||||
bundled-data era). Nothing in the build referenced it; instance data lives in
|
||||
server-side config. Note: the files remain in old git history and release archives.
|
||||
|
||||
## v1.13.2 — 2026-07-05 (audit round 3: fixes + buildDevices test suite)
|
||||
- **buildDevices finally has a direct unit-test suite** (12 tests on a fake hass):
|
||||
area filtering, curation incl. show-all, duplicate numbering, light-group folding
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.13.2** 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/8995** (queue ≈2 months as of 2026-07) |
|
||||
| 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)
|
||||
|
||||
@@ -43,6 +43,51 @@
|
||||
- **v1.13.2** — audit round 3: buildDevices unit-test suite, multi-placeholder t(),
|
||||
conflict resync in _saveConfigNow, pointercancel long-press fix, repairs re-check
|
||||
on config save (repairs.py).
|
||||
- **v1.13.3** — privacy: legacy real-house assets/ removed; README screenshots synthetic.
|
||||
- **v1.14.0** — per-space display settings (borders/names/color/opacity/fills),
|
||||
draggable room labels, hand-drawn spaces (no image required), demo/ harness in-repo,
|
||||
docs/TESTING.md manual checklist (update with every functional change!).
|
||||
- **v1.15.0** — temperature room fill (blue/green/yellow) with editable comfort bounds.
|
||||
- **v1.15.1** — display-settings UX: radio fill selector, inline compact bounds
|
||||
(with the Number('')→0 bound-collapse bug fixed), avg room temp in the tooltip,
|
||||
darken-on-hover, wider space dialog.
|
||||
- **v1.15.2** — fix: average room temperature (fill + tooltip) counted non-thermometers
|
||||
(fridges/TRVs/chip `*_device_temperature`/diagnostic); now thermometer/air-monitor only.
|
||||
- **v1.15.3** — fix: device icon badge sat 1 px off its anchor (content-box + 1 px
|
||||
border); `box-sizing: border-box` centres it exactly on the device point.
|
||||
- **v1.15.4** — fix: real `ha-icon` (block + big line-height) put the glyph ~1.8 px low;
|
||||
`.dev ha-icon` is now a zero-line-height flex box. Reverted v1.15.3 border-box (shrank the
|
||||
badge). Verified live. Demo stub made faithful so the smoke guards it.
|
||||
- **v1.15.5** — fix: room hover was always grey; legacy overlay/yard hover rules scoped
|
||||
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
|
||||
|
||||
@@ -55,11 +100,16 @@
|
||||
|
||||
## Open items / watchlist
|
||||
|
||||
1. **hacs/default PR #8995** — waiting for moderation (bot may draft the PR for minor fixes —
|
||||
fix and re-ready; wrongly filled template ⇒ silent close).
|
||||
1. **hacs/default PR #9004** — accepted by the bot into the review queue ('New default
|
||||
repository' label). Minor issues ⇒ the bot drafts the PR (fix and re-ready).
|
||||
2. GitHub PAT `houseplan-card-publish` (repo+workflow) expires ~2026-07-12; in sandbox
|
||||
`~/.git-credentials`. Revoke after the HACS queue clears, or re-issue when needed.
|
||||
3. Stale files on the mount that cannot be deleted from the sandbox: `src/data/` leftovers,
|
||||
3. Privacy: legacy real-house plan sources (`assets/`) removed from the tree in
|
||||
v1.13.3, but they persist in git history and old release archives; 8 README
|
||||
screenshots in docs/images are still from the real house (replacement with
|
||||
synthetic ones is a standing watchlist item). History rewrite deliberately NOT
|
||||
done — it would break existing release tags/HACS installs.
|
||||
4. Stale files on the mount that cannot be deleted from the sandbox: `src/data/` leftovers,
|
||||
`brand_preview.png`, old nested bundle copies — ignore, git is authoritative.
|
||||
4. Roadmap: phases 7–10 are DONE (v1.12.0 quality scale, v1.13.0 universality,
|
||||
v1.13.1 distribution). Next candidates: replace the remaining real-house README
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
# Manual testing checklist
|
||||
|
||||
> **Policy:** this checklist is updated **in the same commit** as any functional
|
||||
> change (like CHANGELOG.md). Every release: run at least the smoke column on the
|
||||
> synthetic demo (`demo/`), and the full list before major releases. Items marked
|
||||
> `[auto]` are covered by unit tests or the headless smokes in `demo/` — they still
|
||||
> deserve an occasional eyeball. File every failure as a GitHub issue before fixing.
|
||||
|
||||
## Environments matrix
|
||||
|
||||
Run the *core flows* (marked ★ below) in each environment at least once per minor release:
|
||||
|
||||
- [ ] Chrome / Edge (desktop, Windows or Linux)
|
||||
- [ ] Firefox (desktop) — SVG viewBox math and container queries differ historically
|
||||
- [ ] Safari (macOS) — pointer events / pinch behavior
|
||||
- [ ] HA Companion app, Android (cold start! the v1.7.2 race lived here)
|
||||
- [ ] HA Companion app, iOS
|
||||
- [ ] Tablet in kiosk/panel mode (wall-tablet scenario, landscape)
|
||||
- [ ] Phone portrait, narrow ≤400 px (adaptive header ≤620 px)
|
||||
- [ ] Dark theme and light theme (badges, dialogs, plan contrast)
|
||||
- [ ] RU profile locale and EN profile locale (+ `language:` card option forcing each)
|
||||
|
||||
## Installation / upgrade / removal
|
||||
|
||||
- [ ] Fresh install via HACS custom repository → integration appears, card auto-registers as a Lovelace resource (`?v=` matches manifest); no manual resource setup
|
||||
- [ ] `single_config_entry`: adding a second entry is impossible [auto]
|
||||
- [ ] Upgrade via HACS: `?v=` bumps after HA restart, browser picks the new bundle without cache clearing
|
||||
- [ ] YAML-mode Lovelace: falls back to `extra_module_url` (card loads)
|
||||
- [ ] 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]
|
||||
- [ ] Wizard: uncheck all → "Create" disabled; "Start from scratch" → classic dialog
|
||||
- [ ] Wizard: N floors → space dialog per floor with prefilled name and progress "i of N"; Skip skips one; Cancel aborts the whole queue [auto]
|
||||
- [ ] After the last wizard space (or first manual space) → markup mode auto-opens with a toast
|
||||
- [ ] Empty config, no floors → classic "New space" dialog auto-opens once per session
|
||||
- [ ] All floors skipped, nothing created → empty state with "Add space" button remains usable
|
||||
|
||||
## Spaces ★
|
||||
|
||||
- [ ] 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 (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]
|
||||
- [ ] Fill "zigbee": rooms tint red→green by average LQI; rooms without zigbee stay unfilled [auto]
|
||||
- [ ] Fill "lights": yellow when any light on, grey when all off, unfilled when the room has no lights [auto]; toggling a light from the plan recolors the room
|
||||
- [ ] Fill "temperature": blue below the comfort range, green inside, yellow above [auto]; comfort bounds editable inline on the radio row (swapped bounds tolerated [auto], clearing a field cannot zero a bound [auto]); rooms without a temperature reading stay unfilled [auto]
|
||||
- [ ] Fill mode is a radio group (no dropdown); labels carry no color legend
|
||||
- [ ] Room hover darkens the current fill (no recolor to blue); unfilled rooms hover light grey
|
||||
- [ ] Room tooltip shows the average room temperature when any thermometer reports [auto]
|
||||
- [ ] 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
|
||||
- [ ] Settings persist across reload and other browsers (server-side)
|
||||
|
||||
## Room markup editor ★
|
||||
|
||||
- [ ] 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
|
||||
- [ ] "No area" room (decorative) requires a name; saves with `area: null`
|
||||
- [ ] Cancel in the dialog reopens the contour (last point undone)
|
||||
- [ ] Saving a room with an area: area devices appear with icons; positions are fixed into the layout [auto]
|
||||
- [ ] Erase tool removes exactly the clicked line; Delete-room removes the polygon after confirm
|
||||
- [ ] Device icons hidden during markup; visible again on exit
|
||||
|
||||
## Devices on the plan ★
|
||||
|
||||
- [ ] Auto devices appear only in rooms bound to their area [auto]
|
||||
- [ ] Curation hides bridges/groups/scenes/excluded integrations; 👁 "show all" reveals [auto]
|
||||
- [ ] Duplicate "name|area" numbered ("Lamp", "Lamp 2") [auto]
|
||||
- [ ] Light groups fold their single lamps; `group_lights=false` unfolds [auto]
|
||||
- [ ] Drag anywhere (no edit mode), snaps to grid, persists after reload, per space
|
||||
- [ ] ↺ 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
|
||||
- [ ] 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) ★
|
||||
|
||||
- [ ] 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; >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]
|
||||
|
||||
## Icon rules ★
|
||||
|
||||
- [ ] ⬡ opens the editor with current rules (defaults if none saved)
|
||||
- [ ] Test field resolves live; add/delete/reorder rows; first match wins [auto]
|
||||
- [ ] Invalid regex highlights red and is skipped at runtime (other rules still work) [auto]
|
||||
- [ ] Reset to defaults; saving defaults stores nothing (settings key removed) [auto]
|
||||
- [ ] Custom rules re-icon existing devices immediately; per-device icon override still wins; lock devices keep mdi:lock [auto]
|
||||
- [ ] Rules survive reload; second browser sees them after live-sync
|
||||
|
||||
## Tap actions & gestures ★
|
||||
|
||||
- [ ] Default: tap → info card; card option `toggle`: tap toggles lights/switches/fans/humidifiers only [auto]
|
||||
- [ ] Locks/alarms never toggle, even with per-device override [auto]; covers/valves toggle only with explicit per-device override [auto]
|
||||
- [ ] Long-press (600 ms) always opens the info card, also when tap=toggle [auto]
|
||||
- [ ] Drag > 3 px cancels both tap and long-press; pinch/pan never triggers taps
|
||||
- [ ] `pointercancel` (touch interrupted) does not leave a phantom info card [auto]
|
||||
|
||||
## Zoom / pan / labels
|
||||
|
||||
- [ ] Wheel zoom at cursor; +/− buttons; fit button resets; badge shows %
|
||||
- [ ] Pinch zoom + two-finger pan on touch; one-finger pan when zoomed
|
||||
- [ ] 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 and dark plans (no text shadow) at min/max zoom
|
||||
|
||||
## Multi-client & concurrency ★
|
||||
|
||||
- [ ] Two browser tabs: drag in A → position appears in B without reload (live event)
|
||||
- [ ] Config edit collision: stale tab saving gets a conflict toast, auto-resyncs, retry works [auto backend]
|
||||
- [ ] Point layout updates from two windows don't overwrite each other's icons [auto backend]
|
||||
- [ ] `admin_only` ON: non-admin user gets readable "administrators only" errors on every write path
|
||||
|
||||
## Edge cases
|
||||
|
||||
- [ ] HA instance with zero devices/areas → onboarding works, rooms can be drawn, no crashes [auto]
|
||||
- [ ] Space with zero rooms → renders; markup hint visible
|
||||
- [ ] Room without area + borders ON → drawn, click does nothing, no area tooltip signal
|
||||
- [ ] No zigbee devices anywhere → no LQI badges, lqi fill leaves all rooms unfilled [auto]
|
||||
- [ ] 100+ devices in one space → build under ~50 ms [auto], drag stays smooth
|
||||
- [ ] Very long device/room names → ellipsis/wrap, no layout explosion
|
||||
- [ ] HTML/emoji in names (`<b>xss</b>`, 🚿) → rendered as text, never as markup [auto]
|
||||
- [ ] Plan file deleted from disk → Repairs issue appears after config save/restart; re-upload clears it [auto backend]
|
||||
- [ ] Corrupted `.storage/houseplan.config` → entry retries (ConfigEntryNotReady), no crash loop [auto backend]
|
||||
- [ ] HA restart while a dialog is open → next save gets a clean error/conflict, no data loss
|
||||
- [ ] Legacy layout entries (v1 {x,y} without space) are ignored gracefully
|
||||
- [ ] Kiosk cold start on mobile app: card defined before dashboard render (resource registration)
|
||||
|
||||
## Release regression quickies
|
||||
|
||||
- [ ] Browser console has zero errors from houseplan-card.js on: dashboard load, markup, dialogs, zoom
|
||||
- [ ] HA log has zero houseplan errors/warnings after restart
|
||||
- [ ] `npm test` (frontend), `pytest tests_backend` (pure), CI HA-harness — all green
|
||||
- [ ] README screenshots/GIF still match the current UI (synthetic home only)
|
||||
|
||||
---
|
||||
|
||||
## 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:
|
||||
1. Edit dialog: switching an existing space from image to "draw" kept the old
|
||||
background (`plan_url` not detached) — fixed.
|
||||
2. `_stateClass` crashed on state objects without `entity_id` (domain is now
|
||||
derived from `d.primary`, which the state was looked up by) — fixed; found by
|
||||
the 150-device perf item of this checklist.
|
||||
3. Perf item measured: 162 devices build in ~14 ms, re-render ~1 ms — well within budget.
|
||||
4. (earlier rounds) long-press phantom after `pointercancel`; `_saveConfigNow`
|
||||
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,78 @@
|
||||
# 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:
|
||||
|
||||
**[ 👁 View ] [ 📐 Plan ] [ 🔧 Devices ]**
|
||||
|
||||
- **View** is the default and the only mode after every load (edit modes are never
|
||||
restored across reloads).
|
||||
- **Plan** and **Devices** 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.
|
||||
|
Before Width: | Height: | Size: 323 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 290 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 1012 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 823 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 198 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 299 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 292 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 69 KiB |
@@ -1,21 +1,24 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.12.0",
|
||||
"version": "1.23.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.12.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",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@rollup/plugin-typescript": "^11.1.6",
|
||||
"playwright": "^1.61.1",
|
||||
"rollup": "^4.18.0",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.4.5"
|
||||
@@ -86,6 +89,13 @@
|
||||
"@lit-labs/ssr-dom-shim": "^1.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mdi/js": {
|
||||
"version": "7.4.47",
|
||||
"resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz",
|
||||
"integrity": "sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@rollup/plugin-json": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz",
|
||||
@@ -588,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",
|
||||
@@ -741,6 +760,63 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"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",
|
||||
@@ -880,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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.13.2",
|
||||
"version": "1.30.0",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -11,15 +11,18 @@
|
||||
"test": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/*.test.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@rollup/plugin-typescript": "^11.1.6",
|
||||
"playwright": "^1.61.1",
|
||||
"rollup": "^4.18.0",
|
||||
"tslib": "^2.6.2",
|
||||
"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);
|
||||
}
|
||||
@@ -40,6 +40,10 @@ export function domainOfDevice(hass: any, dev: any, entIds: string[]): string {
|
||||
}
|
||||
|
||||
export function isTempEntity(hass: any, eid: string): boolean {
|
||||
// Температура чипа самого устройства — это диагностика, не комнатная температура.
|
||||
if (/_device_temperature$/.test(eid)) return false;
|
||||
// Диагностические/конфигурационные сущности (чип, батарея, калибровки) — не комната.
|
||||
if (hass.entities?.[eid]?.entity_category) return false;
|
||||
const st = hass.states[eid];
|
||||
if (!st) return /_temperature$/.test(eid);
|
||||
const a = st.attributes || {};
|
||||
@@ -100,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 [];
|
||||
@@ -202,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);
|
||||
}
|
||||
|
||||
@@ -250,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') {
|
||||
@@ -257,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 {
|
||||
@@ -292,3 +325,50 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
}
|
||||
return rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Light situation of an area: 'on' if any light entity of the area's devices is on,
|
||||
* 'off' if lights exist but none is on, 'none' when the area has no lights at all.
|
||||
*/
|
||||
export function areaLights(hass: any, devices: { area: string; entities: string[] }[], area: string): 'on' | 'off' | 'none' {
|
||||
let seen = false;
|
||||
for (const d of devices) {
|
||||
if (d.area !== area) continue;
|
||||
for (const eid of d.entities) {
|
||||
if (!eid.startsWith('light.')) continue;
|
||||
seen = true;
|
||||
if (hass.states[eid]?.state === 'on') return 'on';
|
||||
}
|
||||
}
|
||||
return seen ? 'off' : 'none';
|
||||
}
|
||||
|
||||
/** 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[] }[],
|
||||
area: string,
|
||||
): number | null {
|
||||
const vals: number[] = [];
|
||||
for (const d of devices) {
|
||||
if (d.area !== area) continue;
|
||||
// Учитываем только устройства, которые сама карточка считает термометрами —
|
||||
// не холодильники, термоголовки, розетки с температурой чипа и т.п.
|
||||
if (d.icon !== 'mdi:thermometer' && d.icon !== 'mdi:air-filter') continue;
|
||||
const t = tempFor(hass, d.entities);
|
||||
if (t != null) vals.push(t);
|
||||
}
|
||||
if (!vals.length) return null;
|
||||
return Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 10) / 10;
|
||||
}
|
||||
|
||||
@@ -27,13 +27,36 @@
|
||||
"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 +81,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",
|
||||
@@ -94,6 +124,17 @@
|
||||
"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.point_in_room": "That point is inside room “{name}” — rooms must not overlap",
|
||||
"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": "Click a grid dot on the room’s wall",
|
||||
"toast.split_bad_cut": "The cut must be a straight line from wall to wall, inside the room",
|
||||
"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",
|
||||
@@ -151,5 +192,61 @@
|
||||
"import.manual": "Start from scratch",
|
||||
"import.progress": "Floor {i} of {n}",
|
||||
"import.done": "Spaces created. Outline the rooms: click grid dots and close the contour.",
|
||||
"btn.skip": "Skip"
|
||||
"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)",
|
||||
"space.room_color": "Border & name color",
|
||||
"space.opacity": "Opacity",
|
||||
"space.fill_label": "Room fill",
|
||||
"fill.none": "None",
|
||||
"fill.lqi": "Zigbee signal",
|
||||
"fill.light": "Lights",
|
||||
"space.source_file": "I have a floor-plan image",
|
||||
"space.source_draw": "No image — I'll outline rooms by hand",
|
||||
"space.orientation": "Canvas",
|
||||
"orient.landscape": "Landscape",
|
||||
"orient.portrait": "Portrait",
|
||||
"orient.square": "Square",
|
||||
"fill.temp": "Temperature",
|
||||
"space.temp_min": "Comfort from",
|
||||
"space.temp_max": "to",
|
||||
"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.view": "View",
|
||||
"mode.plan": "Plan",
|
||||
"mode.devices": "Devices",
|
||||
"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…"
|
||||
}
|
||||
|
||||
@@ -27,13 +27,36 @@
|
||||
"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 +81,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": "Ссылка",
|
||||
@@ -94,6 +124,17 @@
|
||||
"toast.markup_needs_server": "Разметка доступна после переноса конфига на сервер",
|
||||
"toast.conflict": "Конфиг изменён в другом окне — данные обновлены, повторите последнее действие",
|
||||
"toast.cfg_save_failed": "Не удалось сохранить конфиг: {err}",
|
||||
"toast.point_in_room": "Точка внутри комнаты «{name}» — комнаты не должны накладываться",
|
||||
"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": "Редактирование устройств доступно после переноса конфига на сервер",
|
||||
@@ -151,5 +192,61 @@
|
||||
"import.manual": "Начать с нуля",
|
||||
"import.progress": "Этаж {i} из {n}",
|
||||
"import.done": "Пространства созданы. Обведите комнаты: кликайте по точкам сетки и замкните контур.",
|
||||
"btn.skip": "Пропустить"
|
||||
"btn.skip": "Пропустить",
|
||||
"space.scale_label": "Масштаб (размер клетки сетки)",
|
||||
"space.scale_unit": "см на клетку",
|
||||
"space.display_section": "Отображение",
|
||||
"space.show_borders": "Всегда отображать границы комнат",
|
||||
"space.show_names": "Отображать названия комнат (перетаскиваются)",
|
||||
"space.room_color": "Цвет границ и названий",
|
||||
"space.opacity": "Прозрачность",
|
||||
"space.fill_label": "Заливка комнат",
|
||||
"fill.none": "Нет",
|
||||
"fill.lqi": "По силе зигби-сигнала",
|
||||
"fill.light": "По освещению",
|
||||
"space.source_file": "У меня есть картинка плана",
|
||||
"space.source_draw": "Нет подложки — нарисую комнаты вручную",
|
||||
"space.orientation": "Холст",
|
||||
"orient.landscape": "Альбомный",
|
||||
"orient.portrait": "Портретный",
|
||||
"orient.square": "Квадрат",
|
||||
"fill.temp": "По температуре",
|
||||
"space.temp_min": "Комфорт от",
|
||||
"space.temp_max": "до",
|
||||
"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.view": "Просмотр",
|
||||
"mode.plan": "План",
|
||||
"mode.devices": "Устройства",
|
||||
"display.value": "Значение вместо иконки",
|
||||
"marker.subarea": "без зоны, вручную",
|
||||
"device.new": "Новое устройство — откройте его редактор, чтобы снять отметку",
|
||||
"opening.unlock_action": "Открыть замок",
|
||||
"opening.lock_action": "Закрыть замок",
|
||||
"opening.lock_pending": "Выполняется…"
|
||||
}
|
||||
|
||||
@@ -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,192 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do two room outlines share floor area? Rooms must never overlap, but sharing a wall
|
||||
* (fully or partially) and touching at a corner are normal and stay legal. A room nested
|
||||
* inside another counts as an overlap.
|
||||
*/
|
||||
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;
|
||||
return coversArea(a, b, eps) || coversArea(b, a, 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 {
|
||||
if (!poly || poly.length < 3 || 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
|
||||
for (let i = 0; i < poly.length; i++)
|
||||
if (segmentsProperlyCross(a, b, poly[i], poly[(i + 1) % poly.length])) return null; // leaves the room
|
||||
// a chord lying along a wall has its midpoint ON the outline, not inside it
|
||||
if (!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 pts: number[][] = [from];
|
||||
let i = (fromIdx + 1) % poly.length;
|
||||
for (let guard = 0; guard <= poly.length; guard++) {
|
||||
pts.push(poly[i]);
|
||||
if (i === toIdx) break;
|
||||
i = (i + 1) % poly.length;
|
||||
}
|
||||
pts.push(to);
|
||||
return dropRepeats(pts, eps);
|
||||
};
|
||||
const p1 = walk(a, ia, b, ib);
|
||||
const p2 = walk(b, ib, a, ia);
|
||||
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().
|
||||
@@ -180,3 +481,271 @@ export function subst(s: string, vars?: Record<string, string | number>): string
|
||||
for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v));
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------- room fills & colors ----------------
|
||||
|
||||
export type RoomFillMode = 'none' | 'lqi' | 'light' | 'temp';
|
||||
|
||||
/** Per-space display settings with their defaults resolved. */
|
||||
export interface SpaceDisplay {
|
||||
showBorders: boolean;
|
||||
showNames: boolean;
|
||||
color: string; // hex like #3ea6ff
|
||||
opacity: number; // 0..1 — applied to borders, names and fills
|
||||
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;
|
||||
}
|
||||
|
||||
export const DEFAULT_ROOM_COLOR = '#3ea6ff';
|
||||
export const DEFAULT_ROOM_OPACITY = 0.55;
|
||||
export const DEFAULT_TEMP_MIN = 20;
|
||||
export const DEFAULT_TEMP_MAX = 25;
|
||||
|
||||
/** Resolve a space's display settings; spaces without a plan default to visible markup. */
|
||||
export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
|
||||
const s = spaceCfg?.settings || {};
|
||||
const noPlan = !spaceCfg?.plan_url;
|
||||
return {
|
||||
showBorders: s.show_borders ?? noPlan,
|
||||
showNames: s.show_names ?? noPlan,
|
||||
color: typeof s.room_color === 'string' && /^#[0-9a-f]{6}$/i.test(s.room_color) ? s.room_color : DEFAULT_ROOM_COLOR,
|
||||
opacity: typeof s.room_opacity === 'number' ? Math.min(1, Math.max(0, s.room_opacity)) : DEFAULT_ROOM_OPACITY,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------- 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)
|
||||
* - light: warm yellow when any light in the room is on, grey when all known
|
||||
* lights are off; rooms without lights get no fill (a bathroom without smart
|
||||
* bulbs should not look permanently "off").
|
||||
*/
|
||||
export function roomFillColor(
|
||||
mode: RoomFillMode,
|
||||
lqi: number | null,
|
||||
lights: 'on' | 'off' | 'none',
|
||||
temp?: number | null,
|
||||
tempMin: number = DEFAULT_TEMP_MIN,
|
||||
tempMax: number = DEFAULT_TEMP_MAX,
|
||||
): string | null {
|
||||
if (mode === 'lqi') return lqi == null ? null : lqiColor(lqi);
|
||||
if (mode === 'light') {
|
||||
if (lights === 'none') return null;
|
||||
return lights === 'on' ? '#ffd45c' : '#9aa0a6';
|
||||
}
|
||||
if (mode === 'temp') {
|
||||
// blue below the comfort range, green inside, yellow above; no reading → no fill.
|
||||
// Bounds are swapped automatically if entered in the wrong order.
|
||||
if (temp == null) return null;
|
||||
const lo = Math.min(tempMin, tempMax);
|
||||
const hi = Math.max(tempMin, tempMax);
|
||||
if (temp < lo) return '#4fc3f7';
|
||||
if (temp > hi) return '#ffd45c';
|
||||
return '#66d17a';
|
||||
}
|
||||
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>
|
||||
`;
|
||||
}
|
||||
@@ -134,6 +134,9 @@ export const cardStyles = css`
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.stage.noplan {
|
||||
background: #ffffff;
|
||||
}
|
||||
.stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -191,19 +194,203 @@ export const cardStyles = css`
|
||||
stroke: transparent;
|
||||
stroke-width: 2;
|
||||
}
|
||||
.room.overlay:hover {
|
||||
fill: rgba(62, 166, 255, 0.18);
|
||||
stroke: var(--hp-accent);
|
||||
.room.overlay:not(.styled):hover {
|
||||
fill: #9aa0a6;
|
||||
fill-opacity: 0.22;
|
||||
stroke: var(--hp-muted);
|
||||
}
|
||||
.room.yard {
|
||||
fill: rgba(75, 140, 90, 0.14);
|
||||
stroke: #4b8c5a;
|
||||
stroke-width: 2;
|
||||
}
|
||||
.room.yard:hover {
|
||||
.room.yard:not(.styled):hover {
|
||||
fill: rgba(75, 140, 90, 0.24);
|
||||
stroke: #6fbf86;
|
||||
}
|
||||
.room.styled {
|
||||
stroke: var(--room-stroke, transparent);
|
||||
stroke-opacity: var(--room-stroke-op, 0);
|
||||
stroke-width: 2.5;
|
||||
fill: var(--room-fill, transparent);
|
||||
fill-opacity: var(--room-fill-op, 0);
|
||||
}
|
||||
/* hover: darken the current fill instead of recoloring; grey when unfilled */
|
||||
.room.styled.filled:hover {
|
||||
filter: brightness(0.78);
|
||||
stroke-opacity: 1;
|
||||
}
|
||||
.room.styled:not(.filled):hover {
|
||||
fill: #9aa0a6;
|
||||
fill-opacity: 0.22;
|
||||
stroke-opacity: 1;
|
||||
}
|
||||
/* 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%);
|
||||
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);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.rlabel {
|
||||
fill: var(--hp-muted);
|
||||
font-size: 15px;
|
||||
@@ -220,8 +407,51 @@ export const cardStyles = css`
|
||||
.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.active {
|
||||
background: var(--hp-accent);
|
||||
color: var(--text-primary-color, #fff);
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.modetab .ml { display: none; }
|
||||
}
|
||||
.room.picked {
|
||||
stroke: #ffc14d;
|
||||
stroke-width: 3;
|
||||
fill: rgba(255, 193, 77, 0.25);
|
||||
}
|
||||
.room.outlined {
|
||||
stroke: rgba(62, 166, 255, 0.55);
|
||||
@@ -273,6 +503,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;
|
||||
@@ -280,9 +557,12 @@ export const cardStyles = css`
|
||||
}
|
||||
.dev {
|
||||
position: absolute;
|
||||
width: var(--icon-size, 2.5cqw);
|
||||
height: var(--icon-size, 2.5cqw);
|
||||
margin: calc(var(--icon-size, 2.5cqw) / -2) 0 0 calc(var(--icon-size, 2.5cqw) / -2);
|
||||
/* 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(--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);
|
||||
@@ -290,7 +570,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);
|
||||
@@ -298,10 +578,13 @@ export const cardStyles = css`
|
||||
}
|
||||
.dev ha-icon {
|
||||
--mdc-icon-size: calc(var(--icon-size, 2.5cqw) * 0.62);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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);
|
||||
@@ -339,7 +622,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);
|
||||
@@ -383,6 +683,52 @@ export const cardStyles = css`
|
||||
.tab.tabadd ha-icon {
|
||||
--mdc-icon-size: 15px;
|
||||
}
|
||||
.srcrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.dispsection {
|
||||
margin-top: 12px !important;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--hp-line);
|
||||
font-weight: 600;
|
||||
color: var(--hp-txt) !important;
|
||||
}
|
||||
.colorrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.colorrow input[type='color'] {
|
||||
width: 42px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--hp-line);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
padding: 1px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.colorrow input[type='range'] { flex: 1; }
|
||||
.colorrow .tempin { width: 70px; flex: none; }
|
||||
.temprange {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-left: auto;
|
||||
color: var(--hp-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
/* 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; }
|
||||
.colorrow .opv { font-size: 12px; min-width: 34px; text-align: right; }
|
||||
.planrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -416,7 +762,7 @@ export const cardStyles = css`
|
||||
flex: 1;
|
||||
}
|
||||
.dialog.wide {
|
||||
width: min(440px, 94vw);
|
||||
width: min(500px, 94vw);
|
||||
}
|
||||
.dialog .body {
|
||||
max-height: 66vh;
|
||||
@@ -631,6 +977,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';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor } from '../test-build/devices.js';
|
||||
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp } from '../test-build/devices.js';
|
||||
import { compileIconRules } from '../test-build/rules.js';
|
||||
|
||||
/** Minimal fake hass around the pieces buildDevices reads. */
|
||||
@@ -158,3 +158,125 @@ test('lqiFor: dedicated sensor wins over attribute duplication; tempFor rounds',
|
||||
assert.equal(lqiFor(h, ['sensor.x_linkquality', 'sensor.temp']), 119); // avg(120,118)
|
||||
assert.equal(tempFor(h, ['sensor.temp']), 22.5);
|
||||
});
|
||||
|
||||
test('areaLights: on / off / none tri-state', () => {
|
||||
const hass = mkHass({ states: { 'light.a': { state: 'on' }, 'light.b': { state: 'off' } } });
|
||||
const devs = [
|
||||
{ area: 'living', entities: ['light.a', 'sensor.x'] },
|
||||
{ area: 'kitchen', entities: ['light.b'] },
|
||||
{ area: 'bath', entities: ['sensor.hum'] },
|
||||
];
|
||||
assert.equal(areaLights(hass, devs, 'living'), 'on');
|
||||
assert.equal(areaLights(hass, devs, 'kitchen'), 'off');
|
||||
assert.equal(areaLights(hass, devs, 'bath'), 'none');
|
||||
});
|
||||
|
||||
test('areaTemp: averages only thermometer devices, null when none report', () => {
|
||||
const hass = mkHass({ states: {
|
||||
'sensor.t1': { state: '20.0', attributes: { device_class: 'temperature' } },
|
||||
'sensor.t2': { state: '23.1', attributes: { device_class: 'temperature' } },
|
||||
'sensor.hum': { state: '55', attributes: { device_class: 'humidity' } },
|
||||
}});
|
||||
const devs = [
|
||||
{ area: 'living', icon: 'mdi:thermometer', entities: ['sensor.t1'] },
|
||||
{ area: 'living', icon: 'mdi:thermometer', entities: ['sensor.t2'] },
|
||||
{ area: 'bath', icon: 'mdi:water-percent', entities: ['sensor.hum'] },
|
||||
];
|
||||
assert.equal(areaTemp(hass, devs, 'living'), 21.6); // (20+23.1)/2=21.55 → 21.6
|
||||
assert.equal(areaTemp(hass, devs, 'bath'), null);
|
||||
assert.equal(areaTemp(hass, devs, 'garage'), null);
|
||||
});
|
||||
|
||||
test('areaTemp: ignores non-thermometer devices that expose a temperature (fridge, TRV, chip)', () => {
|
||||
const hass = mkHass({
|
||||
entities: { 'sensor.plug_device_temperature': { entity_id: 'sensor.plug_device_temperature', entity_category: 'diagnostic' } },
|
||||
states: {
|
||||
'sensor.room': { state: '22.0', attributes: { device_class: 'temperature' } },
|
||||
'sensor.fridge_t': { state: '4.5', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
||||
'sensor.trv_current': { state: '8.3', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
||||
'sensor.plug_device_temperature': { state: '31', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
||||
},
|
||||
});
|
||||
const devs = [
|
||||
{ area: 'living', icon: 'mdi:thermometer', entities: ['sensor.room'] },
|
||||
{ area: 'living', icon: 'mdi:fridge', entities: ['sensor.fridge_t'] },
|
||||
{ area: 'living', icon: 'mdi:radiator', entities: ['sensor.trv_current'] },
|
||||
{ area: 'living', icon: 'mdi:power-socket-de', entities: ['sensor.plug_device_temperature'] },
|
||||
];
|
||||
// только настоящий термометр (22.0), холодильник/термоголовка/чип игнорируются
|
||||
assert.equal(areaTemp(hass, devs, 'living'), 22.0);
|
||||
});
|
||||
|
||||
test('isTempEntity: excludes chip temperature and diagnostic-category entities', () => {
|
||||
const hass = mkHass({
|
||||
entities: { 'sensor.calibrated': { entity_id: 'sensor.calibrated', entity_category: 'diagnostic' } },
|
||||
states: {
|
||||
'sensor.room_temperature': { state: '21', attributes: { device_class: 'temperature' } },
|
||||
'sensor.plug_device_temperature': { state: '30', attributes: { device_class: 'temperature' } },
|
||||
'sensor.calibrated': { state: '19', attributes: { device_class: 'temperature' } },
|
||||
},
|
||||
});
|
||||
// настоящий комнатный датчик проходит
|
||||
assert.equal(tempFor(hass, ['sensor.room_temperature']), 21);
|
||||
// чип и диагностика — нет
|
||||
assert.equal(tempFor(hass, ['sensor.plug_device_temperature']), null);
|
||||
assert.equal(tempFor(hass, ['sensor.calibrated']), null);
|
||||
// если в устройстве и чип, и настоящий — берётся настоящий
|
||||
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)
|
||||
});
|
||||
|
||||
@@ -2,7 +2,9 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
|
||||
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst,
|
||||
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
|
||||
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,
|
||||
@@ -212,3 +214,326 @@ test('subst: replaces every occurrence of a placeholder, ignores unknown', () =>
|
||||
assert.equal(subst('no vars'), 'no vars');
|
||||
assert.equal(subst('keep {unknown}', { n: 1 }), 'keep {unknown}');
|
||||
});
|
||||
|
||||
test('spaceDisplayOf: defaults differ for spaces with and without a plan', () => {
|
||||
const withPlan = spaceDisplayOf({ plan_url: '/x.svg' });
|
||||
assert.equal(withPlan.showBorders, false);
|
||||
assert.equal(withPlan.showNames, false);
|
||||
assert.equal(withPlan.fill, 'none');
|
||||
const noPlan = spaceDisplayOf({ plan_url: null });
|
||||
assert.equal(noPlan.showBorders, true);
|
||||
assert.equal(noPlan.showNames, true);
|
||||
const s = spaceDisplayOf({ plan_url: null, settings: { show_borders: false, room_color: '#ff0000', room_opacity: 2, fill_mode: 'lqi' } });
|
||||
assert.equal(s.showBorders, false);
|
||||
assert.equal(s.color, '#ff0000');
|
||||
assert.equal(s.opacity, 1);
|
||||
assert.equal(s.fill, 'lqi');
|
||||
const g = spaceDisplayOf({ settings: { room_color: 'javascript:alert(1)', fill_mode: 'weird' } });
|
||||
assert.equal(g.color, '#3ea6ff');
|
||||
assert.equal(g.fill, 'none');
|
||||
});
|
||||
|
||||
test('roomFillColor: lqi gradient, light tri-state, none', () => {
|
||||
assert.equal(roomFillColor('none', 200, 'on'), null);
|
||||
assert.equal(roomFillColor('lqi', null, 'on'), null);
|
||||
assert.equal(roomFillColor('lqi', 180, 'none'), 'hsl(120, 85%, 55%)');
|
||||
assert.equal(roomFillColor('light', null, 'on'), '#ffd45c');
|
||||
assert.equal(roomFillColor('light', null, 'off'), '#9aa0a6');
|
||||
assert.equal(roomFillColor('light', null, 'none'), null);
|
||||
});
|
||||
|
||||
test('roomFillColor temp: blue/green/yellow bands, swapped bounds tolerated, no reading → no fill', () => {
|
||||
assert.equal(roomFillColor('temp', null, 'none', 18, 20, 25), '#4fc3f7'); // cold
|
||||
assert.equal(roomFillColor('temp', null, 'none', 20, 20, 25), '#66d17a'); // lower bound inclusive
|
||||
assert.equal(roomFillColor('temp', null, 'none', 25, 20, 25), '#66d17a'); // upper bound inclusive
|
||||
assert.equal(roomFillColor('temp', null, 'none', 26.5, 20, 25), '#ffd45c'); // hot
|
||||
assert.equal(roomFillColor('temp', null, 'none', 18, 25, 20), '#4fc3f7'); // swapped bounds
|
||||
assert.equal(roomFillColor('temp', null, 'none', null, 20, 25), null);
|
||||
assert.equal(roomFillColor('temp', null, 'none', undefined, 20, 25), null);
|
||||
});
|
||||
|
||||
test('spaceDisplayOf: temp bounds default to 20..25 and accept overrides', () => {
|
||||
const d = spaceDisplayOf({ plan_url: '/x.svg' });
|
||||
assert.equal(d.tempMin, 20);
|
||||
assert.equal(d.tempMax, 25);
|
||||
const o = spaceDisplayOf({ settings: { temp_min: 18.5, temp_max: 23 } });
|
||||
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: nested, identical and enclosing outlines all count as overlap', () => {
|
||||
assert.ok(roomsOverlap(SQ, [[0.5, 0.5], [1.5, 0.5], [1.5, 1.5], [0.5, 1.5]])); // nested
|
||||
assert.ok(roomsOverlap(SQ, SQ)); // duplicate
|
||||
// drawn AROUND an existing room: every vertex outside, no vertex of ours inside it
|
||||
assert.ok(roomsOverlap([[-1, -1], [3, -1], [3, 3], [-1, 3]], 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]]));
|
||||
});
|
||||
|
||||
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, []);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
@@ -97,3 +97,31 @@ def test_layout_schema():
|
||||
v.LAYOUT_SCHEMA({"dev1": {"x": 0.1, "y": 0.2, "s": "f1"}})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.LAYOUT_SCHEMA({"dev1": {"x": 0.1}})
|
||||
|
||||
|
||||
def test_space_display_settings():
|
||||
"""Per-space display settings validate; garbage color/mode is rejected."""
|
||||
ok = {
|
||||
"id": "f1", "title": "Floor 1", "aspect": 1.0, "view_box": [0, 0, 1, 1],
|
||||
"rooms": [], "settings": {
|
||||
"show_borders": True, "show_names": False,
|
||||
"room_color": "#3ea6ff", "room_opacity": 0.5, "fill_mode": "lqi",
|
||||
},
|
||||
}
|
||||
v.SPACE_SCHEMA(ok)
|
||||
import pytest as _pytest
|
||||
bad_color = dict(ok, settings={"room_color": "javascript:x"})
|
||||
with _pytest.raises(Exception):
|
||||
v.SPACE_SCHEMA(bad_color)
|
||||
bad_mode = dict(ok, settings={"fill_mode": "rainbow"})
|
||||
with _pytest.raises(Exception):
|
||||
v.SPACE_SCHEMA(bad_mode)
|
||||
|
||||
|
||||
def test_space_temp_bounds():
|
||||
"""Temperature comfort bounds validate as floats; temp fill mode accepted."""
|
||||
ok = {
|
||||
"id": "f1", "title": "F", "aspect": 1.0, "view_box": [0, 0, 1, 1], "rooms": [],
|
||||
"settings": {"fill_mode": "temp", "temp_min": 19.5, "temp_max": "24"},
|
||||
}
|
||||
v.SPACE_SCHEMA(ok)
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"src/logic.ts",
|
||||
"src/rules.ts",
|
||||
"src/devices.ts",
|
||||
"src/types.ts"
|
||||
"src/types.ts",
|
||||
"src/space-geometry.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||