mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 11:18:48 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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**.
|
||||
@@ -114,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.
|
||||
@@ -138,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.
|
||||
|
||||

|
||||
|
||||
@@ -165,6 +193,12 @@ You can also place a **single entity** (not just a whole device): start typing i
|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
+35
-1
@@ -51,6 +51,11 @@ House Plan показывает ваш умный дом так, как он в
|
||||
|
||||
## Установка
|
||||
|
||||
В один клик, если у вас уже есть HACS:
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
|
||||
|
||||
|
||||
### Через HACS (рекомендуется)
|
||||
|
||||
1. Откройте **HACS → меню (⋮) → Custom repositories**.
|
||||
@@ -122,6 +127,29 @@ title: План дома
|
||||
|
||||

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

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

|
||||
|
||||
---
|
||||
|
||||
@@ -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.19.0"
|
||||
VERSION = "1.26.0"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.19.0"
|
||||
"version": "1.26.0"
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import voluptuous as vol
|
||||
PLAN_EXTENSIONS = {"svg": "image/svg+xml", "png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
|
||||
MAX_PLAN_BYTES = 8 * 1024 * 1024
|
||||
FILE_EXTENSIONS = {"pdf", "png", "jpg", "jpeg", "webp", "txt"}
|
||||
MAX_FILE_BYTES = 25 * 1024 * 1024
|
||||
MAX_FILE_BYTES = 50 * 1024 * 1024
|
||||
|
||||
SPACE_ID_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
|
||||
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
@@ -87,6 +87,7 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
|
||||
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp"]),
|
||||
vol.Optional("temp_min"): vol.Coerce(float),
|
||||
vol.Optional("temp_max"): vol.Coerce(float),
|
||||
vol.Optional("show_lqi"): bool,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
@@ -100,6 +101,24 @@ SPACE_SCHEMA = vol.Schema(
|
||||
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
||||
vol.Required("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
|
||||
vol.Required("rooms"): [ROOM_SCHEMA],
|
||||
vol.Optional("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.
|
||||
@@ -121,6 +140,11 @@ 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("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)
|
||||
],
|
||||
@@ -131,7 +155,21 @@ 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("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,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,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,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();
|
||||
+579
-143
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+537
-142
File diff suppressed because one or more lines are too long
@@ -86,6 +86,15 @@ Built from the registries (`_buildDevices`), rules carried over 1-to-1 from the
|
||||
|
||||
## Device markers (v1.6.0+)
|
||||
|
||||
Per-marker appearance (v1.22.0): `display: badge|ripple|icon_ripple` (+`ripple_color`,
|
||||
`ripple_size`) draws presence-style pulsing rings gated by the pure `isActiveState` —
|
||||
deliberately independent of the card-wide `live_states` toggle, with `unavailable` counting as
|
||||
idle. `size` (icon multiplier via the `--dev-size` CSS var — value badges scale along) and
|
||||
`angle` rotate/scale a single icon. Room drawing shows a live **ruler** (`segmentCm` +
|
||||
`formatLength`, metres or feet+inches by `hass.config.unit_system`); the scale is per-space
|
||||
`cell_cm` (default 5 cm per grid cell).
|
||||
|
||||
|
||||
`config.markers[]`: `{id, binding:'device:<id>'|'entity:<eid>'|'virtual', space?, area?, hidden?,
|
||||
name?, icon?, model?, link?, description?, pdfs:[{name,url}]}`. A hybrid: auto-discovered HA devices
|
||||
appear on their own; a marker with `binding=device:<id>` overrides them (metadata/rebinding/hiding),
|
||||
@@ -109,6 +118,18 @@ 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/delroom), `_path` (the current outline,
|
||||
@@ -127,6 +148,26 @@ While drawing, the length of the current segment follows the cursor (`_fmtLen`
|
||||
`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 |
|
||||
|
||||
@@ -1,5 +1,167 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
@@ -69,6 +69,15 @@ cp dist/houseplan-card.js custom_components/houseplan/frontend/
|
||||
("InvalidStateError: Transition was aborted", hui-view is not created for 1–2 min).
|
||||
Cured by repeating the SPA navigation: pushState + a location-changed event, or just waiting.
|
||||
|
||||
## Dependency and cache gotchas
|
||||
|
||||
- **polygon-clipping is a trap**: its `.d.ts` declares named exports but the ESM build has only
|
||||
a default export — tsc or the runtime breaks, whichever you appease. Use **polyclip-ts**
|
||||
(proper ESM + native types; same results, +~50 KB bundle via bignumber.js).
|
||||
- **Redeploying the same version keeps the resource URL** (`/houseplan_files/houseplan-card.js?v=X`),
|
||||
so browsers may serve the previous bundle from cache. Bump the version for anything users must
|
||||
pick up, or hard-refresh (Ctrl+Shift+R) when testing a hotfix redeploy.
|
||||
|
||||
## Release
|
||||
|
||||
Tag `vX.Y.Z` + GitHub Release → the workflow `.github/workflows/release.yml` builds and attaches
|
||||
|
||||
+15
-8
@@ -14,27 +14,27 @@ This roadmap replaces them.
|
||||
|
||||
Track progress in `custom_components/houseplan/quality_scale.yaml` (done/exempt + comment).
|
||||
|
||||
- [ ] `runtime-data`: move `hass.data[DOMAIN]` (stores, entry, write lock) to typed
|
||||
- [x] `runtime-data`: move `hass.data[DOMAIN]` (stores, entry, write lock) to typed
|
||||
`entry.runtime_data` (`type HouseplanConfigEntry = ConfigEntry[HouseplanData]`).
|
||||
Keep WS command registration in `async_setup` (commands must survive entry reloads).
|
||||
- [ ] `config-entry-unloading` (Silver, do it now): real `async_unload_entry` — unregister
|
||||
- [x] `config-entry-unloading` (Silver, do it now): real `async_unload_entry` — unregister
|
||||
update listeners via `entry.async_on_unload`; document what stays (static paths cannot
|
||||
be unregistered — exempt comment).
|
||||
- [ ] `test-before-setup`: verify storage is loadable/writable in `async_setup_entry`,
|
||||
- [x] `test-before-setup`: verify storage is loadable/writable in `async_setup_entry`,
|
||||
raise `ConfigEntryNotReady` on failure.
|
||||
- [ ] `unique-config-entry`: replace the manual `_async_current_entries()` check with
|
||||
- [x] `unique-config-entry`: replace the manual `_async_current_entries()` check with
|
||||
`single_config_entry: true` in manifest.
|
||||
- [ ] `config-flow-test-coverage` + backend tests for websocket_api (pytest-homeassistant-custom-component:
|
||||
- [x] `config-flow-test-coverage` + backend tests for websocket_api (pytest-homeassistant-custom-component:
|
||||
conflict/rev, admin_only enforcement, layout point ops, upload limits).
|
||||
- [ ] Storage versioning: `Store(minor_version=...)` + `_async_migrate_func` skeleton **now**,
|
||||
- [x] Storage versioning: `Store(minor_version=...)` + `_async_migrate_func` skeleton **now**,
|
||||
before the config schema evolves further (Bronze-adjacent, cheap while schema is stable).
|
||||
- [ ] `quality_scale.yaml` with honest exempt list (no entities ⇒ entity rules N/A, no polling, no deps).
|
||||
- [x] `quality_scale.yaml` with honest exempt list (no entities ⇒ entity rules N/A, no polling, no deps).
|
||||
|
||||
## Phase 8 — Quality Scale: Silver + selected Gold
|
||||
|
||||
- [ ] `test-coverage` ≥95% backend; frontend: extract remaining pure logic (view math,
|
||||
marker resolution) into `logic.ts`/`devices.ts` and cover with node:test.
|
||||
- [ ] `diagnostics.py`: config + layout dump with `async_redact_data` (redact names/links/PDF paths).
|
||||
- [x] `diagnostics.py`: config + layout dump with `async_redact_data` (redact names/links/PDF paths).
|
||||
- [ ] `reconfiguration-flow` + richer **options flow**: admin_only, curation defaults
|
||||
(exclude domains — UI editable, replacing the hardcoded EXCLUDED_DOMAINS fallback),
|
||||
LQI thresholds, group_lights default.
|
||||
@@ -74,6 +74,13 @@ Track progress in `custom_components/houseplan/quality_scale.yaml` (done/exempt
|
||||
- [ ] GitHub: issue templates, discussions on, CONTRIBUTING.md (build/test in 5 minutes).
|
||||
- [ ] Semantic versioning discipline; keep release notes user-facing (they show in HACS).
|
||||
|
||||
## Phase 11 — UX modes redesign (approved, see docs/UX-MODES.md)
|
||||
|
||||
Three-tab interaction model (View / Plan / Devices): view mode is display-only —
|
||||
no dragging or editing; all editing splits between a Plan tab and a Devices tab.
|
||||
Deprecates "drag anywhere" and view-mode opening drag. Follow-ups from issue #3:
|
||||
state-reflecting icons, value display, RGB light colors, alarm visuals, sub-area rooms.
|
||||
|
||||
## Explicit non-goals (for now)
|
||||
|
||||
- 3D plans (.glb) — separate market (3Dash), heavy dependencies.
|
||||
|
||||
+26
-5
@@ -9,18 +9,18 @@
|
||||
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
|
||||
> ARCHITECTURE.md for design changes, ROADMAP.md when plans move.
|
||||
|
||||
## Snapshot (2026-07-05)
|
||||
## Snapshot (2026-07-17)
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.19.0** 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.26.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…**v1.23.1** (latest published 2026-07-17, bundle auto-attached by release.yml) |
|
||||
| CI | `.github/workflows/validate.yml` (hacs + hassfest + frontend + backend) — **fully green** since v1.11.1; `release.yml` auto-attaches the card bundle (needs `permissions: contents: write`, fixed) |
|
||||
| HACS | Works as custom repository (id 1290210112 on the home instance). **Inclusion PR: https://github.com/hacs/default/pull/9004** (queue ≈2 months as of 2026-07). Lesson: #8995 was auto-closed by hacs-bot — the PR body MUST be their exact template with every checkbox ticked and all 3 links (release, HACS action run, hassfest run); a custom body gets closed without discussion |
|
||||
| Brands | Ships **inside the integration**: `custom_components/houseplan/brand/{icon,icon@2x,logo,logo@2x}.png` (HA ≥2026.3 local-brands mechanism). home-assistant/brands PR #10700 was auto-closed — that repo no longer accepts custom integrations |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed v1.11.2, installed *via HACS* (custom repo) — updates flow through HACS now |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.23.1**, installed *via HACS* (custom repo). The user has real rooms, 12 openings (doors/windows with TTLock locks and contact sensors) and live fills configured |
|
||||
| Localization | UI en/ru (`src/i18n.ts`), auto by `hass.locale` + `language` card option; codebase and docs are English-first (`README.ru.md` is the Russian copy) |
|
||||
| Tests | 63 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)
|
||||
|
||||
@@ -67,6 +67,27 @@
|
||||
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
|
||||
|
||||
|
||||
+83
-3
@@ -29,6 +29,20 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
- [ ] Removal: delete entry → Lovelace resource entry disappears; `.storage/houseplan.*` survives; reinstall picks the old config up
|
||||
- [ ] Diagnostics download works; personal fields (name/link/description/pdfs) are `**REDACTED**` [auto]
|
||||
|
||||
## Modes (v1.25.0) ★
|
||||
|
||||
- [ ] The card always loads in **View**; edit modes are never restored [auto]
|
||||
- [ ] View: pan/zoom/space-switch/tap/long-press/tooltips only — dragging an icon,
|
||||
label or opening does nothing; panning may start on top of an icon [auto]
|
||||
- [ ] View header: space tabs + count + zoom + mode tabs, ZERO edit buttons [auto]
|
||||
- [ ] Plan: markup toolbar, space gears, +space, ⚙ palette; device icons hidden,
|
||||
labels/openings draggable; orange stage frame [auto]
|
||||
- [ ] Devices: icon drag works, click opens the marker editor directly; +/👁/↺/⬡
|
||||
buttons; accent stage frame [auto]
|
||||
- [ ] Mode tabs hidden for non-admin users; segmented control highlights the active mode
|
||||
- [ ] Opening: single click in View shows the info card; in Plan a click edits it;
|
||||
double-click no longer does anything special [auto]
|
||||
|
||||
## Onboarding ★
|
||||
|
||||
- [ ] Empty config, HA has floors → floors-import wizard offers them sorted by level [auto]
|
||||
@@ -43,7 +57,7 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
- [ ] Create with an image (SVG, PNG, JPG, WebP) → correct aspect, crisp at zoom (SVG)
|
||||
- [ ] Oversized plan (>8 MB) → readable error toast, dialog stays open
|
||||
- [ ] Create with "No image — I'll outline rooms by hand": orientation landscape/portrait/square respected [auto]; borders+names default ON [auto]
|
||||
- [ ] Draw-space renders an empty canvas (no black hole), markup works on it
|
||||
- [ ] Draw-space (no background) renders a WHITE canvas (paper-like), markup works on it; room borders/names stay legible on white [auto]
|
||||
- [ ] Edit: rename; replace image; **switch image→draw detaches the plan** [auto]
|
||||
- [ ] Delete space with rooms/devices → tab disappears, layout of other spaces untouched
|
||||
- [ ] Display settings: borders toggle, names toggle, color picker + opacity slider live-preview after save, fill selector [auto]
|
||||
@@ -56,6 +70,14 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
- [ ] Average room temperature counts ONLY thermometer/air-monitor devices — fridges, TRV heads,
|
||||
smart-plug chip temperatures (`*_device_temperature`) and diagnostic-category temps are excluded [auto]
|
||||
- [ ] Space dialog is 500 px wide; the comfort-bounds inputs are compact (56 px)
|
||||
- [ ] The scale (cm per cell) input is compact (72 px), not full-width [auto]
|
||||
- [ ] General settings (⚙ in the header): fill colors grouped by mode (lights on/off/none,
|
||||
temp cold/comfy/hot, LQI weak/strong), each with its own opacity slider [auto];
|
||||
Reset restores defaults; saving defaults stores nothing [auto]
|
||||
- [ ] Custom fill colors apply to the full card AND the static space-card
|
||||
- [ ] LQI gradient interpolates between the configured weak/strong colors [auto]
|
||||
- [ ] Per-space "Show zigbee signal (LQI)" toggle hides/shows the badges next to
|
||||
devices and the signal line in room tooltips for that space only [auto]
|
||||
- [ ] Device icon badge is centred exactly on its point (no 1 px down-right drift) [auto]
|
||||
- [ ] Device glyph is centred within its badge (no vertical drift — real ha-icon is block+line-height) [auto]
|
||||
- [ ] Room hover highlight still works when custom borders/fills are on
|
||||
@@ -71,6 +93,18 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
- [ ] 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
|
||||
@@ -90,6 +124,10 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
- [ ] ↺ reset restores auto layout after confirm
|
||||
- [ ] Temperature badge on thermometers; LQI value under zigbee icons with red→green color
|
||||
- [ ] Live states: light on = yellow, open cover/lock/door = orange, unavailable = faded
|
||||
- [ ] State icons (v1.26.0): auto icons morph with state — door/window/garage open↔closed,
|
||||
lock locked↔unlocked, bulb on; custom icons and unavailable states never morph [auto]
|
||||
- [ ] display "Value instead of an icon": the marker shows the measurement (°/%/unit)
|
||||
as its body, small badges hidden; non-numeric fallback keeps the icon [auto]
|
||||
- [ ] No devices at all in HA (fresh instance) → plan renders, "0 dev.", no console errors [auto]
|
||||
|
||||
## Device dialog (markers) ★
|
||||
@@ -99,7 +137,7 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
- [ ] Virtual device: requires name; room required; renders dashed
|
||||
- [ ] Room override moves the icon to the room center
|
||||
- [ ] Tap-action override select (default/info/more-info/toggle) saves and applies
|
||||
- [ ] PDF/manual upload: ok path; >25 MB → readable error; .exe → bad-ext error [auto backend]; traversal names sanitized [auto backend]
|
||||
- [ ] PDF/manual upload: ok path; >50 MB → readable error; .exe → bad-ext error [auto backend]; traversal names sanitized [auto backend]
|
||||
- [ ] `javascript:` in the link field is not rendered as a clickable link [auto]
|
||||
- [ ] Remove: auto device → hidden marker (reappears via dialog "show all"? no — stays hidden until re-added); virtual → gone incl. its layout entry [auto backend]
|
||||
|
||||
@@ -127,7 +165,7 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
- [ ] Zoom level persists per space (localStorage), restored on reload
|
||||
- [ ] Window resize / sidebar collapse refits without distortion
|
||||
- [ ] Room name labels: default at room center; dragging moves and persists (server layout, `rl_*`) [auto]; hidden in markup mode
|
||||
- [ ] Labels legible on light plans (text shadow) at min/max zoom
|
||||
- [ ] Labels legible on light and dark plans (no text shadow) at min/max zoom
|
||||
|
||||
## Multi-client & concurrency ★
|
||||
|
||||
@@ -162,6 +200,16 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
|
||||
## Last self-run
|
||||
|
||||
**v1.21.1 (2026-07-16), full audit of v1.16–v1.21.** All `[auto]` items pass (73 frontend
|
||||
tests, 12 backend). New smokes on the synthetic home: `smoke_merge_split` (merge fuses
|
||||
adjacent rooms keeping the survivor's id; non-adjacent refused with a toast; split creates
|
||||
the new room, cancel keeps the room whole, along-wall cut refused) and `smoke_split_nonsnap`.
|
||||
Finding turned into a fix (shipped this release): **Split required the click to land on a grid
|
||||
node**, so it silently failed on rooms whose walls are not grid-aligned (imported/legacy
|
||||
polygons) — the click now snaps to the nearest wall instead of the grid, and `splitRoom()`
|
||||
still rejects a bad cut. README (en+ru) gained the merge/split/ruler/scale documentation it
|
||||
was missing. The earlier self-run record follows.
|
||||
|
||||
**v1.14.0 (2026-07-06), headless demo harness + unit suites.** All `[auto]` items pass
|
||||
(43 frontend tests, 11 pure + 12 HA-harness backend tests, `smoke_space_settings`,
|
||||
tap/hold/wizard/rules smokes). Bugs found during the run, fixed in the same release:
|
||||
@@ -186,3 +234,35 @@ require hands on real hardware — they remain for the human pass.
|
||||
- [ ] 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,76 @@
|
||||
# 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, room tap → HA area, hover tooltips (name, temperature, signal).
|
||||
|
||||
Removed from this mode (they move, not die):
|
||||
- icon dragging ("drag anywhere", v1.9 — consciously reversed),
|
||||
- room-label dragging,
|
||||
- opening dragging along walls and double-click properties (v1.23.1),
|
||||
- every edit button in the header (+device, 👁 show-all, ↺ reset, ⬡ rules, ⚙ general,
|
||||
per-space gear, markup toggle).
|
||||
|
||||
Header in View: space tabs, device count, zoom cluster. Nothing else.
|
||||
|
||||
## Plan — geometry and appearance of the space
|
||||
|
||||
- Toolbar tools: Outline room, Delete room, Merge, Split, Opening (place / drag along
|
||||
walls / properties), Room labels (drag positions — labels are part of the plan).
|
||||
- Space gear dialog (title, plan image / hand-drawn, scale, Display section, show_lqi),
|
||||
add space, floors import, delete space.
|
||||
- ⚙ General settings (fill palette) lives here — it is about the plan's appearance.
|
||||
|
||||
## Devices — placement and marker configuration
|
||||
|
||||
- Icon dragging (ONLY here). Click on a device opens the **edit dialog directly**
|
||||
(binding, name, icon, size/angle, display badge/ripple + colors, tap override,
|
||||
model/link/description/PDFs, room).
|
||||
- + add device/entity/virtual, hide device, ↺ reset layout, 👁 show-all (curation
|
||||
tool), ⬡ icon rules.
|
||||
|
||||
## Deprecations decided
|
||||
|
||||
1. "Drag anywhere" (v1.9) — reversed by this design.
|
||||
2. Opening drag / dbl-click properties in view (v1.23.1) — moved into Plan.
|
||||
3. The markup toggle button — replaced by the Plan tab.
|
||||
4. Legacy localStorage mode (card without the integration) — candidate for removal
|
||||
in the next major; adds branching for a half-working scenario.
|
||||
|
||||
## Approved follow-up features (from issue #3, by priority)
|
||||
|
||||
1. State-reflecting icons (open/closed door variants etc., like core HA).
|
||||
2. `display: value` — show the measurement instead of an icon.
|
||||
3. Light color in the icon/ripple (RGB lights).
|
||||
4. Alarm visual (leak/smoke/doorbell): red pulse overlay.
|
||||
5. Rooms as sub-areas without an HA area + manual device placement by room id.
|
||||
6. Backlog (not planned): music notes for players, directional TV ripples.
|
||||
|
||||
## Implementation iterations
|
||||
|
||||
- **It.1 — mode shell:** the segmented control, mode state, View-mode gating of all
|
||||
edit interactions/buttons (biggest UX win, smallest surface).
|
||||
- **It.2 — Plan tab:** move markup tools + space dialogs + labels drag + openings
|
||||
editing under Plan; colored frame indicator.
|
||||
- **It.3 — Devices tab:** drag + direct-edit click + curation tools under Devices.
|
||||
- **It.4+:** follow-up features 1–5 above, each its own release.
|
||||
Generated
+29
-3
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.15.0",
|
||||
"version": "1.23.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.15.0",
|
||||
"version": "1.23.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3"
|
||||
"lit": "^3.1.3",
|
||||
"polyclip-ts": "^0.16.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
@@ -597,6 +598,15 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bignumber.js": {
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
||||
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
@@ -797,6 +807,16 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/polyclip-ts": {
|
||||
"version": "0.16.8",
|
||||
"resolved": "https://registry.npmjs.org/polyclip-ts/-/polyclip-ts-0.16.8.tgz",
|
||||
"integrity": "sha512-JPtKbDRuPEuAjuTdhR62Gph7Is2BS1Szx69CFOO3g71lpJDFo78k4tFyi+qFOMVPePEzdSKkpGU3NBXPHHjvKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bignumber.js": "^9.1.0",
|
||||
"splaytree-ts": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/randombytes": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||
@@ -936,6 +956,12 @@
|
||||
"source-map": "^0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/splaytree-ts": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/splaytree-ts/-/splaytree-ts-1.0.2.tgz",
|
||||
"integrity": "sha512-0kGecIZNIReCSiznK3uheYB8sbstLjCZLiwcQwbmLhgHJj2gz6OnSPkVzJQCMnmEz1BQ4gPK59ylhBoEWOhGNA==",
|
||||
"license": "BDS-3-Clause"
|
||||
},
|
||||
"node_modules/supports-preserve-symlinks-flag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.19.0",
|
||||
"version": "1.26.0",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -22,6 +22,7 @@
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3"
|
||||
"lit": "^3.1.3",
|
||||
"polyclip-ts": "^0.16.8"
|
||||
}
|
||||
}
|
||||
|
||||
+912
-64
File diff suppressed because it is too large
Load Diff
+65
-1
@@ -27,11 +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_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.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",
|
||||
@@ -56,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",
|
||||
@@ -92,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",
|
||||
@@ -179,5 +222,26 @@
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
|
||||
+65
-1
@@ -27,11 +27,36 @@
|
||||
"title.configure_space": "Настроить пространство",
|
||||
"title.add_space": "Добавить пространство",
|
||||
"title.markup_add": "Добавить комнату: соединяйте точки сетки линиями до замкнутого контура",
|
||||
"title.markup_merge": "Объединить комнаты: клик по одной, затем по соседней с общей стеной",
|
||||
"title.markup_split": "Разделить комнату: клик по комнате, затем две точки на её стенах",
|
||||
"title.markup_delroom": "Удалить комнату: клик внутри комнаты",
|
||||
"title.no_area_room": "Декоративная комната без привязки к зоне (например, холл)",
|
||||
"title.choose_area": "Выберите зону Home Assistant",
|
||||
"title.need_plan": "Загрузите подложку (план этажа)",
|
||||
"markup.add": "Добавить",
|
||||
"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": "кликните точку сетки, чтобы начать контур",
|
||||
@@ -56,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": "Ссылка",
|
||||
@@ -92,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": "Редактирование устройств доступно после переноса конфига на сервер",
|
||||
@@ -179,5 +222,26 @@
|
||||
"editor.button_label": "Текст кнопки",
|
||||
"editor.button_target": "Путь дашборда (куда вести)",
|
||||
"editor.aspect_ratio": "Соотношение сторон (напр. 16:9 или auto)",
|
||||
"marker.sub_entity": "сущность"
|
||||
"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": "Значение вместо иконки"
|
||||
}
|
||||
|
||||
+381
-5
@@ -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 {
|
||||
@@ -48,15 +49,19 @@ export function segKey(a: number[], b: number[], prec = 1): string {
|
||||
* 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 || []) {
|
||||
let pts: number[][] | null = null;
|
||||
if (r?.poly?.length) pts = r.poly;
|
||||
else if (r && r.x != null && r.y != null && r.w != null && r.h != null)
|
||||
pts = [[r.x, r.y], [r.x + r.w, r.y], [r.x + r.w, r.y + r.h], [r.x, r.y + r.h]];
|
||||
if (!pts || pts.length < 3) continue;
|
||||
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];
|
||||
@@ -69,6 +74,66 @@ export function roomEdges(rooms: any[]): number[][] {
|
||||
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. */
|
||||
export function samePoint(a: number[], b: number[], eps = 0.001): boolean {
|
||||
return Math.abs(a[0] - b[0]) < eps && Math.abs(a[1] - b[1]) < eps;
|
||||
@@ -85,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().
|
||||
@@ -244,6 +495,8 @@ export interface SpaceDisplay {
|
||||
fill: RoomFillMode;
|
||||
tempMin: number; // comfort range lower bound, °C
|
||||
tempMax: number; // comfort range upper bound, °C
|
||||
/** Per-space LQI badges near zigbee devices; null = follow the card option. */
|
||||
showLqi: boolean | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_ROOM_COLOR = '#3ea6ff';
|
||||
@@ -263,9 +516,107 @@ export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
|
||||
fill: ['lqi', 'light', 'temp'].includes(s.fill_mode) ? s.fill_mode : 'none',
|
||||
tempMin: typeof s.temp_min === 'number' ? s.temp_min : DEFAULT_TEMP_MIN,
|
||||
tempMax: typeof s.temp_max === 'number' ? s.temp_max : DEFAULT_TEMP_MAX,
|
||||
showLqi: typeof s.show_lqi === 'boolean' ? s.show_lqi : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------- 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)
|
||||
@@ -298,3 +649,28 @@ export function roomFillColor(
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------- state-reflecting icons ----------------
|
||||
|
||||
/**
|
||||
* Swap the auto icon for a state variant (open door, unlocked lock…), like core
|
||||
* HA does. Conservative: only well-known pairs, only when the user has NOT set
|
||||
* a custom icon, and unknown/unavailable states keep the base icon.
|
||||
*/
|
||||
export function stateIcon(
|
||||
base: string,
|
||||
domain: string | null | undefined,
|
||||
deviceClass: string | null | undefined,
|
||||
state: string | null | undefined,
|
||||
hasCustomIcon: boolean,
|
||||
): string {
|
||||
if (hasCustomIcon || !state || state === 'unavailable' || state === 'unknown') return base;
|
||||
if (domain === 'binary_sensor') {
|
||||
if (deviceClass === 'door') return state === 'on' ? 'mdi:door-open' : 'mdi:door-closed';
|
||||
if (deviceClass === 'window') return state === 'on' ? 'mdi:window-open' : 'mdi:window-closed';
|
||||
if (deviceClass === 'garage_door') return state === 'on' ? 'mdi:garage-open-variant' : 'mdi:garage-variant';
|
||||
}
|
||||
if (domain === 'lock') return state === 'locked' ? 'mdi:lock' : 'mdi:lock-open-variant';
|
||||
if (domain === 'light' && base === 'mdi:lightbulb') return state === 'on' ? 'mdi:lightbulb-on' : base;
|
||||
return base;
|
||||
}
|
||||
|
||||
+4
-3
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { html, svg, nothing, type TemplateResult } from 'lit';
|
||||
import { buildDevices, areaLqi, areaLights, areaTemp } from './devices';
|
||||
import { spaceDisplayOf, roomFillColor } from './logic';
|
||||
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';
|
||||
@@ -71,18 +71,19 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
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
|
||||
? roomFillColor(
|
||||
? 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}`, `--room-fill-op:${(0.3 * disp.opacity).toFixed(3)}`);
|
||||
parts.push(`--room-fill:${fillC.c}`, `--room-fill-op:${fillC.a.toFixed(3)}`);
|
||||
} else {
|
||||
parts.push('--room-fill:transparent', '--room-fill-op:0');
|
||||
}
|
||||
|
||||
+188
-9
@@ -134,6 +134,9 @@ export const cardStyles = css`
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.stage.noplan {
|
||||
background: #ffffff;
|
||||
}
|
||||
.stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -222,7 +225,123 @@ export const cardStyles = css`
|
||||
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;
|
||||
}
|
||||
g.opening:hover .op-outline {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.op-hit {
|
||||
fill: transparent;
|
||||
cursor: grab;
|
||||
pointer-events: auto;
|
||||
touch-action: none; /* drags, not scrolls, on touch */
|
||||
}
|
||||
.op-hit:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.stage.markup .op-hit {
|
||||
pointer-events: none; /* markup clicks go to the stage tools */
|
||||
}
|
||||
.oplock {
|
||||
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);
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
}
|
||||
.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); }
|
||||
.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);
|
||||
@@ -230,11 +349,10 @@ export const cardStyles = css`
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
cursor: grab;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
text-shadow: 0 0 4px var(--card-background-color, rgba(0, 0, 0, 0.6));
|
||||
z-index: 1;
|
||||
}
|
||||
.stage.markup .roomlabel { pointer-events: auto; }
|
||||
.roomlabel:active { cursor: grabbing; }
|
||||
.measurelayer {
|
||||
position: absolute;
|
||||
@@ -271,8 +389,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);
|
||||
@@ -324,6 +485,16 @@ 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;
|
||||
}
|
||||
.devlayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -331,10 +502,12 @@ export const cardStyles = css`
|
||||
}
|
||||
.dev {
|
||||
position: absolute;
|
||||
/* per-device multiplier on top of the card-wide icon size */
|
||||
--dev-size: calc(var(--icon-size, 2.5cqw) * var(--dev-scale, 1));
|
||||
/* центр квадрата (включая рамку 1px) точно на точке привязки: -(size/2 + border) */
|
||||
width: var(--icon-size, 2.5cqw);
|
||||
height: var(--icon-size, 2.5cqw);
|
||||
margin: calc(var(--icon-size, 2.5cqw) / -2 - 1px) 0 0 calc(var(--icon-size, 2.5cqw) / -2 - 1px);
|
||||
width: var(--dev-size);
|
||||
height: var(--dev-size);
|
||||
margin: calc(var(--dev-size) / -2 - 1px) 0 0 calc(var(--dev-size) / -2 - 1px);
|
||||
border-radius: 22%;
|
||||
background: var(--hp-bg);
|
||||
border: 1px solid var(--hp-line);
|
||||
@@ -395,7 +568,7 @@ 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);
|
||||
@@ -412,7 +585,7 @@ export const cardStyles = css`
|
||||
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(--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);
|
||||
@@ -497,6 +670,7 @@ export const cardStyles = css`
|
||||
}
|
||||
/* beat the generic .dialog .body .namein { width:100% } rule */
|
||||
.dialog .body .temprange .tempin { width: 56px; flex: none; padding: 3px 6px; }
|
||||
.dialog .body .colorrow .tempin { width: 72px; flex: none; }
|
||||
.srcrow { flex-wrap: nowrap; }
|
||||
.srcrow > span:first-of-type { white-space: nowrap; }
|
||||
.colorrow .opl { color: var(--hp-muted); font-size: 12px; }
|
||||
@@ -749,6 +923,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,26 @@ export interface Marker {
|
||||
description?: string | null;
|
||||
pdfs?: PdfRef[];
|
||||
tap_action?: string | null; // per-device override: 'info' | 'more-info' | 'toggle'
|
||||
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 {
|
||||
|
||||
+200
-1
@@ -3,7 +3,8 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
|
||||
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
|
||||
segmentCm, formatLength, roomEdges,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon,
|
||||
} from '../test-build/logic.js';
|
||||
import {
|
||||
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
||||
@@ -297,3 +298,201 @@ test('roomEdges: a wall shared by two rooms is emitted once, and survives deleti
|
||||
// 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
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user