mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 03:38:47 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e20279adf | ||
|
|
031e5439eb | ||
|
|
2bf9e44178 | ||
|
|
c2eaf50118 | ||
|
|
ac082569d7 | ||
|
|
a90316c9f3 | ||
|
|
912613bbf8 | ||
|
|
935a519c32 | ||
|
|
dd930b64f7 | ||
|
|
f235c8afc5 | ||
|
|
5e6f9c407c | ||
|
|
849117eb27 | ||
|
|
37b39e7a1e | ||
|
|
d4f2d81a2f | ||
|
|
bcb546dbc7 | ||
|
|
ccf36e283e | ||
|
|
821bdfbd9a | ||
|
|
22992b256f | ||
|
|
d273a90db8 | ||
|
|
48d47a46af | ||
|
|
0d712b9069 | ||
|
|
dfa2a8badc | ||
|
|
2dfab3565b | ||
|
|
6ee09d3750 | ||
|
|
29c5a89aad | ||
|
|
71f44fd528 | ||
|
|
c8722642b7 | ||
|
|
0a7b9a627f | ||
|
|
961d6afe67 | ||
|
|
b9fecbdd95 | ||
|
|
9fd36bd710 | ||
|
|
a9346c9c14 | ||
|
|
794b02b84f | ||
|
|
3bc25ad5ae | ||
|
|
d1a79cd0b4 |
@@ -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.25.0"
|
||||
VERSION = "1.34.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.25.0"
|
||||
"version": "1.34.0"
|
||||
}
|
||||
|
||||
@@ -88,10 +88,37 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
|
||||
vol.Optional("temp_min"): vol.Coerce(float),
|
||||
vol.Optional("temp_max"): vol.Coerce(float),
|
||||
vol.Optional("show_lqi"): bool,
|
||||
vol.Optional("label_temp"): bool,
|
||||
vol.Optional("label_hum"): bool,
|
||||
vol.Optional("label_lqi"): bool,
|
||||
vol.Optional("label_light"): bool,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
_DECOR_COMMON = {
|
||||
vol.Required("id"): str,
|
||||
vol.Optional("color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
vol.Optional("width"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=30)),
|
||||
}
|
||||
_NORM = vol.All(vol.Coerce(float), vol.Range(min=-1, max=2))
|
||||
DECOR_SCHEMA = vol.Any(
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "line",
|
||||
vol.Required("x1"): _NORM, vol.Required("y1"): _NORM,
|
||||
vol.Required("x2"): _NORM, vol.Required("y2"): _NORM},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): vol.In(["rect", "ellipse"]),
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
vol.Required("w"): _NORM, vol.Required("h"): _NORM,
|
||||
vol.Optional("fill"): bool},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "text",
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
vol.Required("text"): vol.All(str, vol.Length(min=1, max=200)),
|
||||
vol.Optional("size"): vol.In(["s", "m", "l"])},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
)
|
||||
|
||||
SPACE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
@@ -101,6 +128,7 @@ SPACE_SCHEMA = vol.Schema(
|
||||
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
||||
vol.Required("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
|
||||
vol.Required("rooms"): [ROOM_SCHEMA],
|
||||
vol.Optional("decor"): [DECOR_SCHEMA],
|
||||
vol.Optional("openings"): [
|
||||
vol.Schema(
|
||||
{
|
||||
@@ -140,6 +168,7 @@ MARKER_SCHEMA = vol.Schema(
|
||||
vol.Optional("link"): vol.Any(str, None),
|
||||
vol.Optional("description"): vol.Any(str, None),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
|
||||
vol.Optional("room_id"): vol.Any(str, None),
|
||||
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", None),
|
||||
vol.Optional("ripple_color"): vol.Any(str, None),
|
||||
vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None),
|
||||
@@ -157,6 +186,8 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
vol.Optional("markers", default=list): [MARKER_SCHEMA],
|
||||
vol.Optional("settings", default=dict): vol.Schema(
|
||||
{
|
||||
vol.Optional("known_devices"): [str],
|
||||
vol.Optional("new_device_ids"): [str],
|
||||
vol.Optional("fill_colors"): vol.Schema(
|
||||
{
|
||||
str: vol.Schema(
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
const stage = sr().querySelector('.stage');
|
||||
const label = sr().querySelector('.roomlabel');
|
||||
out.hasLabel = !!label;
|
||||
// 1) draw: клик по карточке не добавляет точку
|
||||
c._tool = 'draw'; c._path = []; await c.updateComplete;
|
||||
label.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, clientX: 10, clientY: 10 }));
|
||||
await c.updateComplete;
|
||||
out.drawIgnored = c._path.length === 0;
|
||||
// 2) клик по угловой метке тоже не считается кликом инструмента
|
||||
const handle = label.querySelector('.rlhandle');
|
||||
handle.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, clientX: 10, clientY: 10 }));
|
||||
await c.updateComplete;
|
||||
out.handleIgnored = c._path.length === 0;
|
||||
// 3) во время резайза клики по сцене игнорируются
|
||||
const room = c._spaceModel().rooms.find((r) => r.name);
|
||||
c._rlResize = { id: 'rl_' + room.id, space: c._space, k0: 1, cx: 0, cy: 0, d0: 50 };
|
||||
const before = c._path.length;
|
||||
c._markupClick(new MouseEvent('click', { bubbles: true, clientX: 200, clientY: 200 }));
|
||||
out.duringResizeIgnored = c._path.length === before;
|
||||
c._rlResize = null;
|
||||
// 4) обычный клик по сцене (мимо карточки) по-прежнему работает — точка ставится
|
||||
const r = stage.getBoundingClientRect();
|
||||
// найти долю экрана, попадающую в свободное место (не внутри комнаты)
|
||||
let fx = 0.4, fy = 0.4;
|
||||
outer: for (let ix = 1; ix < 20; ix++) for (let iy = 1; iy < 20; iy++) {
|
||||
const ev = new MouseEvent('click', { clientX: r.left + r.width * ix / 20, clientY: r.top + r.height * iy / 20 });
|
||||
const raw = c._svgPoint(ev);
|
||||
if (!c._roomAt(c._snap(raw))) { fx = ix / 20; fy = iy / 20; break outer; }
|
||||
}
|
||||
stage.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true,
|
||||
clientX: r.left + r.width * fx, clientY: r.top + r.height * fy }));
|
||||
await c.updateComplete;
|
||||
out.normalClickWorks = c._path.length === 1;
|
||||
// 5) delroom: клик по карточке не зовёт confirm (переопределим)
|
||||
let confirmCalled = false;
|
||||
window.confirm = () => { confirmCalled = true; return false; };
|
||||
c._tool = 'delroom'; c._path = []; await c.updateComplete;
|
||||
label.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, clientX: 10, clientY: 10 }));
|
||||
await c.updateComplete;
|
||||
out.delroomIgnored = !confirmCalled;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,74 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
|
||||
// 1) третья вкладка есть
|
||||
const tabs = [...sr().querySelectorAll('.modetab')];
|
||||
out.threeTabs = tabs.length === 3;
|
||||
tabs[2].click(); await c.updateComplete;
|
||||
out.decorMode = c._mode === 'decor';
|
||||
out.decorBar = !!sr().querySelector('.editbar.decorbar');
|
||||
out.toolBtns = sr().querySelectorAll('.decorbar .btn:not(.barclose)').length === 6;
|
||||
// 2) нарисовать прямоугольник drag-ом (через прямые вызовы)
|
||||
c._decorTool = 'rect'; c._decorStyle = { color: '#ff0000', width: 3, fill: true }; await c.updateComplete;
|
||||
const g = c._gridPitch;
|
||||
c._decorDraft = { kind: 'rect', a: [g * 10, g * 10], b: [g * 10, g * 10], pid: 1 };
|
||||
c._decorDraft = { ...c._decorDraft, b: [g * 20, g * 16] };
|
||||
await c.updateComplete;
|
||||
out.draftPreview = !!sr().querySelector('.ddraft');
|
||||
c._decorCommitDraft(); await c.updateComplete;
|
||||
out.rectSaved = c._curSpaceCfg.decor?.length === 1 && c._curSpaceCfg.decor[0].kind === 'rect';
|
||||
out.rectFill = c._curSpaceCfg.decor[0].fill === true && c._curSpaceCfg.decor[0].color === '#ff0000';
|
||||
out.rectRendered = !!sr().querySelector('.decorlayer rect.dshape');
|
||||
// 3) линия и овал
|
||||
c._decorDraft = { kind: 'line', a: [g * 2, g * 2], b: [g * 8, g * 2], pid: 1 };
|
||||
c._decorCommitDraft();
|
||||
c._decorDraft = { kind: 'ellipse', a: [g * 30, g * 30], b: [g * 40, g * 36], pid: 1 };
|
||||
c._decorCommitDraft(); await c.updateComplete;
|
||||
out.threeShapes = c._decorList.length === 3;
|
||||
// вырожденная фигура не сохраняется
|
||||
c._decorDraft = { kind: 'line', a: [g, g], b: [g, g], pid: 1 };
|
||||
c._decorCommitDraft();
|
||||
out.degenerateSkipped = c._decorList.length === 3;
|
||||
// 4) надпись через диалог
|
||||
c._decorTextDialog = { x: 0.5, y: 0.5, text: 'Сауна', size: 'l', color: '#0000ff' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
out.textSaved = c._decorList.some((x) => x.kind === 'text' && x.text === 'Сауна');
|
||||
out.textRendered = [...sr().querySelectorAll('.decorlayer text')].some((t) => t.textContent.includes('Сауна'));
|
||||
// 5) select: перемещение сохраняет форму, drag двигает
|
||||
const rect = c._decorList.find((x) => x.kind === 'rect');
|
||||
c._decorTool = 'select';
|
||||
c._decorMove = { id: rect.id, start: [0, 0], orig: JSON.parse(JSON.stringify(rect)), pid: 7, moved: false };
|
||||
c._decorMoveUpdate({ clientX: 0, clientY: 0, }); // без смещения
|
||||
const before = { ...c._decorList.find((x) => x.id === rect.id) };
|
||||
c._decorMove.start = [0, 0];
|
||||
// сдвиг на 5 клеток по x: подделаем _svgPoint? проще напрямую:
|
||||
const m = c._decorMove; const dx5 = (g * 5) / 1000;
|
||||
c._curSpaceCfg.decor = c._decorList.map((x) => x.id === m.id ? { ...x, x: m.orig.x + dx5 } : x);
|
||||
out.moveKeepsSize = Math.abs(c._decorList.find((x) => x.id === rect.id).w - before.w) < 1e-9;
|
||||
c._decorMove = null;
|
||||
// 6) erase удаляет
|
||||
const n0 = c._decorList.length;
|
||||
c._decorTool = 'erase';
|
||||
c._decorShapeDown({ stopPropagation() {}, preventDefault() {}, target: null, pointerId: 1 }, c._decorList[0]);
|
||||
out.eraseWorks = c._decorList.length === n0 - 1;
|
||||
// 7) Esc-лестница: инструмент → select → выход
|
||||
c._decorTool = 'line'; c._decorSel = null; await c.updateComplete;
|
||||
await esc(); out.escTool = c._decorTool === 'select' && c._mode === 'decor';
|
||||
await esc(); out.escExit = c._mode === 'view';
|
||||
// 8) в Просмотре фигуры видны, но inert
|
||||
const shp = sr().querySelector('.decorlayer .dshape');
|
||||
out.visibleInView = !!shp;
|
||||
out.inertInView = shp ? getComputedStyle(shp).pointerEvents === 'none' : null;
|
||||
// 9) Delete удаляет выбранное
|
||||
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
|
||||
c._decorTool = 'select'; c._decorSel = c._decorList[0].id; const n1 = c._decorList.length; await c.updateComplete;
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete' })); await c.updateComplete;
|
||||
out.deleteKey = c._decorList.length === n1 - 1;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,39 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const tabs = () => [...sr().querySelectorAll('.modetab')];
|
||||
// 1) две вкладки, Просмотра нет, крестиков в неактивных нет
|
||||
out.twoTabs = tabs().length === 3; // третья — Редактор подложки (v1.33.0)
|
||||
out.labels = tabs().map((t) => t.textContent.trim());
|
||||
out.noCrossIdle = sr().querySelectorAll('.modetab .closex').length === 0;
|
||||
out.startView = c._mode === 'view';
|
||||
// 2) клик по «Редактор плана» → активна, панель с крестиком
|
||||
tabs()[0].click(); await c.updateComplete;
|
||||
out.planActive = c._mode === 'plan' && tabs()[0].classList.contains('active');
|
||||
out.planBar = !!sr().querySelector('.editbar');
|
||||
out.planBarClose = !!sr().querySelector('.editbar .barclose');
|
||||
out.tabCross = !!tabs()[0].querySelector('.closex');
|
||||
// 3) повторный клик по активной вкладке — ничего
|
||||
tabs()[0].click(); await c.updateComplete;
|
||||
out.reclickNoop = c._mode === 'plan';
|
||||
// 4) прямое переключение План → Устройства
|
||||
tabs()[1].click(); await c.updateComplete;
|
||||
out.directSwitch = c._mode === 'devices';
|
||||
out.devBar = !!sr().querySelector('.editbar.devbar');
|
||||
out.devBarBtns = sr().querySelectorAll('.editbar.devbar .btn:not(.barclose)').length === 3; // add/show-all/rules (v1.33.2: Reset removed)
|
||||
// 5) инструменты устройств из шапки исчезли (в .bar их больше нет)
|
||||
out.headerCleanInDev = !sr().querySelector('.bar > .btn[title*="' + (c._t('title.add_device')) + '"]');
|
||||
// 6) крестик на панели → Просмотр
|
||||
sr().querySelector('.editbar .barclose').click(); await c.updateComplete;
|
||||
out.barCloseWorks = c._mode === 'view' && !sr().querySelector('.editbar');
|
||||
// 7) крестик в самой вкладке → Просмотр (и не переключает режим)
|
||||
tabs()[1].click(); await c.updateComplete;
|
||||
tabs()[1].querySelector('.closex').click(); await c.updateComplete;
|
||||
out.tabCrossWorks = c._mode === 'view';
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,35 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
|
||||
// общие настройки
|
||||
c._openSettingsDialog(); await c.updateComplete;
|
||||
await esc(); out.settings = !c._settingsDialog;
|
||||
// правила иконок
|
||||
c._openRulesDialog(); await c.updateComplete;
|
||||
await esc(); out.rules = !c._rulesDialog;
|
||||
// диалог устройства
|
||||
c._setMode('devices'); c._openMarkerDialog(); await c.updateComplete;
|
||||
await esc(); out.marker = !c._markerDialog;
|
||||
// диалог пространства (+ очистка очереди импорта)
|
||||
c._openSpaceDialog('edit', c._space); c._importQueue = ['x']; c._importTotal = 1; await c.updateComplete;
|
||||
await esc(); out.space = !c._spaceDialog && c._importQueue.length === 0;
|
||||
// инфо-карточка устройства
|
||||
c._setMode('view'); c._infoCard = c._devices[0]; await c.updateComplete;
|
||||
await esc(); out.info = !c._infoCard;
|
||||
// инфо двери/замка
|
||||
c._openingInfo = { id: 'op1', type: 'door', rx: 550, ry: 200, len_cm: 90, lock: 'lock.front_door' }; await c.updateComplete;
|
||||
await esc(); out.openingInfo = !c._openingInfo;
|
||||
// приоритет: инфо поверх настроек — Esc закрывает только инфо
|
||||
c._openSettingsDialog(); c._infoCard = c._devices[0]; await c.updateComplete;
|
||||
await esc(); out.stacked = !c._infoCard && !!c._settingsDialog;
|
||||
await esc(); out.stacked2 = !c._settingsDialog;
|
||||
// Esc в разметке по-прежнему откатывает точку, а не только диалоги
|
||||
c._setMode('plan'); c._tool = 'draw'; c._path = [[1, 2], [3, 4]]; await c.updateComplete;
|
||||
await esc(); out.undoPointStillWorks = c._path.length === 1;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,35 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const gears = () => sr().querySelectorAll('.tab .tabedit').length;
|
||||
// шестерёнки видны во всех трёх режимах
|
||||
out.viewGears = gears() > 0;
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
out.planGears = gears() > 0;
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
out.devGears = gears() > 0;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
// выравнивание: центр иконки ~ центру текста таба
|
||||
const tab = sr().querySelector('.tab');
|
||||
const icon = tab.querySelector('.tabedit');
|
||||
const tb = tab.getBoundingClientRect(), ib = icon.getBoundingClientRect();
|
||||
const tabMid = tb.top + tb.height / 2, iconMid = ib.top + ib.height / 2;
|
||||
out.alignDelta = Math.abs(tabMid - iconMid);
|
||||
out.aligned = out.alignDelta <= 1.5;
|
||||
// клик по шестерёнке в Просмотре открывает диалог пространства и не переключает таб
|
||||
const cur = c._space;
|
||||
icon.click(); await c.updateComplete;
|
||||
out.dialogOpens = !!c._spaceDialog;
|
||||
out.tabNotSwitched = c._space === cur;
|
||||
c._spaceDialog = null; await c.updateComplete;
|
||||
// кнопка "+" по-прежнему только в Плане
|
||||
out.noAddInView = !sr().querySelector('.tab.tabadd');
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
out.addInPlan = !!sr().querySelector('.tab.tabadd');
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,37 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const grid = () => !!sr().querySelector('rect[fill="url(#hp-grid)"]');
|
||||
// Просмотр: сетки нет
|
||||
out.noGridInView = !grid();
|
||||
// все три редактора: сетка есть
|
||||
c._setMode('plan'); await c.updateComplete; out.gridInPlan = grid();
|
||||
c._setMode('devices'); await c.updateComplete; out.gridInDevices = grid();
|
||||
c._setMode('decor'); await c.updateComplete; out.gridInDecor = grid();
|
||||
await new Promise((r) => setTimeout(r, 250)); // transition .room 0.12s
|
||||
// decor: комнаты и устройства полупрозрачны
|
||||
const room = sr().querySelector('.room');
|
||||
out.roomFaded = room ? Number(getComputedStyle(room).opacity) < 0.5 : null;
|
||||
const dl = sr().querySelector('.devlayer');
|
||||
out.devsFaded = dl ? Number(getComputedStyle(dl).opacity) < 0.5 : null;
|
||||
// а декор-слой — нет
|
||||
c._curSpaceCfg.decor = [{ id: 'd1', kind: 'line', x1: 0.1, y1: 0.1, x2: 0.3, y2: 0.1, color: '#ff0000', width: 3 }];
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const shp = sr().querySelector('.decorlayer .dshape');
|
||||
out.decorNotFaded = shp ? Number(getComputedStyle(shp).opacity) > 0.9 : null;
|
||||
// в других редакторах прозрачности нет
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const room2 = sr().querySelector('.room');
|
||||
out.notFadedInDevices = room2 ? Number(getComputedStyle(room2).opacity) > 0.9 : null;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const room3 = sr().querySelector('.room');
|
||||
out.notFadedInView = room3 ? Number(getComputedStyle(room3).opacity) > 0.9 : null;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,18 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const gs = () => !!sr().querySelector('.head .btn[title="' + c._t('title.general_settings') + '"]');
|
||||
out.view = gs();
|
||||
c._setMode('plan'); await c.updateComplete; out.plan = gs();
|
||||
c._setMode('devices'); await c.updateComplete; out.devices = gs();
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
sr().querySelector('.head .btn[title="' + c._t('title.general_settings') + '"]').click();
|
||||
await c.updateComplete;
|
||||
out.opensInView = !!c._settingsDialog;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,31 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
// устройство без явной иконки (marker.icon пуст)
|
||||
const dev = c._devices.find((d) => !d.marker?.icon && d.icon);
|
||||
out.hasAutoDev = !!dev;
|
||||
c._openMarkerDialog(dev); await c.updateComplete;
|
||||
out.autoIconStored = c._markerDialog.autoIcon === dev.icon;
|
||||
out.iconEmpty = c._markerDialog.icon === '';
|
||||
// подсказка с авто-иконкой видна
|
||||
const hint = sr().querySelector('.iconauto');
|
||||
out.hintShown = !!hint && hint.textContent.includes(dev.icon);
|
||||
out.hintIcon = hint?.querySelector('ha-icon')?.getAttribute('icon') === dev.icon;
|
||||
// пикер (или фолбэк-инпут) получил placeholder
|
||||
const picker = sr().querySelector('ha-icon-picker');
|
||||
const inputs = [...sr().querySelectorAll('.dialog input')];
|
||||
out.placeholderSet = picker
|
||||
? picker.placeholder === dev.icon
|
||||
: inputs.some((i) => i.placeholder === dev.icon);
|
||||
// при явной иконке подсказки нет
|
||||
c._markerDialog = { ...c._markerDialog, icon: 'mdi:star' }; await c.updateComplete;
|
||||
out.noHintWhenExplicit = !sr().querySelector('.iconauto');
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,47 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// добавить открывание на f1
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({
|
||||
...s, openings: [{ id: 'op1', type: 'door', rx: 550, ry: 200, angle: 90, len_cm: 90 }] })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const hit = sr().querySelector('.op-hit');
|
||||
out.hasOpening = !!hit;
|
||||
const cs = hit ? getComputedStyle(hit) : null;
|
||||
out.viewInert = cs ? cs.pointerEvents === 'none' && cs.cursor === 'default' : null;
|
||||
out.lockBadgeInert = (() => { const l = sr().querySelector('.oplock'); return l ? getComputedStyle(l).pointerEvents === 'none' : 'no-badge'; })();
|
||||
// View: клик по открыванию ничего не открывает
|
||||
c._opClick({ stopPropagation(){} }, { id: 'op1', rx: 550, ry: 200, rlen: 90 });
|
||||
out.viewClickNoop = !c._openingInfo && !c._openingDialog;
|
||||
// курсор устройств в Просмотре — pointer, в Устройствах — grab
|
||||
const dev = sr().querySelector('.dev');
|
||||
out.viewDevCursor = getComputedStyle(dev).cursor;
|
||||
// бейдж замка: замок привязан → бейдж кликабелен и открывает инфо
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s2) => s2.id !== 'f1' ? s2 : ({
|
||||
...s2, openings: [{ id: 'op1', type: 'door', rx: 550, ry: 200, angle: 90, len_cm: 90, lock: 'lock.front_door' }] })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const badge = sr().querySelector('.oplock');
|
||||
out.badgeShown = !!badge;
|
||||
const bcs = badge ? getComputedStyle(badge) : null;
|
||||
out.badgeClickableInView = bcs ? bcs.pointerEvents === 'auto' && bcs.cursor === 'pointer' : null;
|
||||
badge?.click(); await c.updateComplete;
|
||||
out.badgeOpensInfo = !!c._openingInfo;
|
||||
c._openingInfo = null;
|
||||
// Plan: op-hit интерактивен, клик редактирует
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
out.devModeCursor = getComputedStyle(sr().querySelector('.dev')).cursor;
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
out.badgeInertInPlan = (() => { const b = sr().querySelector('.oplock'); return b ? getComputedStyle(b).pointerEvents === 'none' : 'no-badge'; })();
|
||||
const cs2 = getComputedStyle(sr().querySelector('.op-hit'));
|
||||
out.planInteractive = cs2.pointerEvents === 'auto' && cs2.cursor === 'grab';
|
||||
const op = c._spaceModel().openings?.[0] || { id: 'op1', rx: 550, ry: 200, rlen: 90 };
|
||||
c._opClick({ stopPropagation(){} }, op);
|
||||
out.planClickEdits = !!c._openingDialog;
|
||||
c._openingDialog = null; c._setMode('view');
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,59 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
|
||||
const g = c._gridPitch;
|
||||
// r1 — комната; рисуем остров-«колонну» внутри неё
|
||||
const r1 = c._spaceModel().rooms.find((r) => r.id === 'r1');
|
||||
const [cx, cy] = c._roomCenter(r1).map((v) => Math.round(v / g) * g);
|
||||
const pts = [[cx, cy], [cx + g * 2, cy], [cx + g * 2, cy + g * 2], [cx, cy + g * 2]];
|
||||
// 1) клики внутри комнаты больше не отклоняются
|
||||
for (const p of pts) c._markupClick({ clientX: 0, clientY: 0, composedPath: () => [], ...{} , __pt: p });
|
||||
// _markupClick берёт координаты из _svgPoint(ev) — подменим напрямую через _path:
|
||||
c._path = [];
|
||||
for (const p of pts) {
|
||||
const before = c._path.length;
|
||||
// эмулируем клик готовой точкой: повторим логику через прямой вызов невозможен — построим путь вручную и замкнём
|
||||
c._path = [...c._path, p];
|
||||
if (c._path.length === before) break;
|
||||
}
|
||||
out.pointsAccepted = c._path.length === 4;
|
||||
// замыкание: проверка пересечений должна пройти (вложенность легальна)
|
||||
const clash = c._overlapRoom([...c._path]);
|
||||
out.nestedAllowed = !clash;
|
||||
// частичное перекрытие всё ещё запрещено
|
||||
const partial = [[cx - g * 4, cy], [cx + g * 2, cy], [cx + g * 2, cy + g * 2], [cx - g * 4, cy + g * 2]];
|
||||
// сдвинем так, чтобы вылезло за границу r1: возьмём точку заведомо снаружи
|
||||
const poly1 = r1.poly || [[r1.x, r1.y], [r1.x + r1.w, r1.y], [r1.x + r1.w, r1.y + r1.h], [r1.x, r1.y + r1.h]];
|
||||
const minX = Math.min(...poly1.map((p) => p[0]));
|
||||
const cross = [[minX - g * 2, cy], [cx, cy], [cx, cy + g * 2], [minX - g * 2, cy + g * 2]];
|
||||
out.partialStillRejected = !!c._overlapRoom(cross);
|
||||
// 2) сохранить остров как комнату без зоны (замкнуть контур)
|
||||
c._path = [...pts, pts[0]];
|
||||
c._nameSel = 'Колонна'; c._areaSel = '';
|
||||
c._commitRoom();
|
||||
await c.updateComplete;
|
||||
const island = c._spaceModel().rooms.find((r) => r.name === 'Колонна');
|
||||
out.islandSaved = !!island;
|
||||
// 3) внешняя комната рендерится как path с evenodd и дыркой
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
// включим границы, чтобы r1 рендерился
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
|
||||
...s, settings: { ...(s.settings || {}), show_borders: true } })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const evenodd = [...sr().querySelectorAll('path.room')].find((p) => p.getAttribute('fill-rule') === 'evenodd');
|
||||
out.evenoddPath = !!evenodd;
|
||||
out.holeInPath = evenodd ? (evenodd.getAttribute('d').match(/M /g) || []).length === 2 : null;
|
||||
// 4) остров кликабелен (элемент существует и он поверх дырки)
|
||||
const islandEl = [...sr().querySelectorAll('.room')].find((el) => {
|
||||
const d = el.getAttribute('points') || el.getAttribute('d') || '';
|
||||
return d.includes(String(cx)) && el !== evenodd;
|
||||
});
|
||||
out.islandRendered = !!islandEl;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,51 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const calls = [];
|
||||
c.hass = { ...c.hass, callService: (d, s, data) => calls.push([d, s, data.entity_id]) };
|
||||
await c.updateComplete;
|
||||
// добавить дверь с замком на f1
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({
|
||||
...s, openings: [{ id: 'op1', type: 'door', rx: 550, ry: 200, angle: 90, len_cm: 90, lock: 'lock.front_door' }] })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const op = c._spaceModel()?.openings?.[0] || { id: 'op1', type: 'door', rx: 550, ry: 200, len_cm: 90, lock: 'lock.front_door' };
|
||||
out.hasLockedDoor = !!op.lock;
|
||||
const lockId = op.lock;
|
||||
// 1) замок закрыт → кнопка "Unlock"
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, [lockId]: { state: 'locked', attributes: {} } } };
|
||||
c._openingInfo = op; await c.updateComplete;
|
||||
let btn = sr().querySelector('.btn.lockact');
|
||||
out.unlockBtnShown = !!btn && !btn.disabled;
|
||||
out.unlockBtnDanger = btn?.classList.contains('warn');
|
||||
btn.click();
|
||||
out.unlockCalled = JSON.stringify(calls.at(-1)) === JSON.stringify(['lock', 'unlock', lockId]);
|
||||
// 2) замок открыт → кнопка "Lock"
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, [lockId]: { state: 'unlocked', attributes: {} } } };
|
||||
await c.updateComplete;
|
||||
btn = sr().querySelector('.btn.lockact');
|
||||
btn.click();
|
||||
out.lockCalled = JSON.stringify(calls.at(-1)) === JSON.stringify(['lock', 'lock', lockId]);
|
||||
// 3) переходное состояние → кнопка задизейблена, сервис не зовётся
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, [lockId]: { state: 'unlocking', attributes: {} } } };
|
||||
await c.updateComplete;
|
||||
btn = sr().querySelector('.btn.lockact');
|
||||
out.pendingDisabled = !!btn && btn.disabled;
|
||||
// 4) недоступен → кнопки нет
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, [lockId]: { state: 'unavailable', attributes: {} } } };
|
||||
await c.updateComplete;
|
||||
out.noBtnWhenUnavailable = !sr().querySelector('.btn.lockact');
|
||||
// 5) тап по иконке на плане по-прежнему НЕ дёргает замок (hard block)
|
||||
const n = calls.length;
|
||||
const dev = c._devices.find((d) => d.domains?.includes('lock'));
|
||||
out.planTapStillBlocked = true;
|
||||
if (dev) {
|
||||
c._clickDevice(new MouseEvent('click'), dev); await c.updateComplete;
|
||||
out.planTapStillBlocked = !calls.slice(n).some((x) => x[0] === 'lock' && (x[1] === 'lock' || x[1] === 'unlock'));
|
||||
}
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,46 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const near = (a, b) => a && b && Math.abs(a.x - b.x) < 1e-6 && Math.abs(a.y - b.y) < 1e-6 && a.s === b.s;
|
||||
// подопытный: авто-устройство с сохранённой позицией
|
||||
const dev = c._devices.find((d) => !d.virtual && !d.marker && c._layout[d.id]);
|
||||
out.hasDev = !!dev;
|
||||
const pos0 = { ...c._layout[dev.id] };
|
||||
// 1) смена комнаты (та же space) → позиция не меняется
|
||||
const otherRoom = c._spaceModel(dev.space).rooms.find((r) => r.id && r.area && r.area !== dev.area);
|
||||
out.hasOtherRoom = !!otherRoom;
|
||||
c._openMarkerDialog(dev);
|
||||
c._markerDialog = { ...c._markerDialog, room: dev.space + '#' + otherRoom.area };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
out.stayAfterRoomChange = near(c._layout[dev.id], pos0);
|
||||
// вернуть комнату назад
|
||||
c._openMarkerDialog(c._devices.find((d) => d.id === dev.id));
|
||||
c._markerDialog = { ...c._markerDialog, room: dev.space + '#' + dev.area };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
// 2) смена привязки device -> entity (id меняется) → позиция мигрирует
|
||||
const freeEnt = Object.keys(c.hass.states).find((e) => e.startsWith('sensor.') &&
|
||||
!c._devices.some((d) => d.id === 'lg_' + e));
|
||||
out.hasFreeEnt = !!freeEnt;
|
||||
c._openMarkerDialog(c._devices.find((d) => d.id === dev.id));
|
||||
c._markerDialog = { ...c._markerDialog, binding: 'entity:' + freeEnt };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const newId = 'lg_' + freeEnt;
|
||||
out.migrated = near(c._layout[newId], pos0);
|
||||
out.oldGone = !c._layout[dev.id];
|
||||
// 3) новый виртуальный маркер по-прежнему центрируется в выбранной комнате
|
||||
c._openMarkerDialog();
|
||||
const room = c._spaceModel().rooms.find((r) => r.id && r.area);
|
||||
c._markerDialog = { ...c._markerDialog, name: 'Тест', binding: 'virtual', room: c._space + '#' + room.area };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const vid = c._serverCfg.markers.find((m) => m.name === 'Тест')?.id;
|
||||
const center = c._roomCenter(room);
|
||||
const vpos = c._layout[vid];
|
||||
const aspect = c._curSpaceCfg.aspect || 1;
|
||||
out.newCentered = vpos && Math.abs(vpos.x * 1000 - center[0]) < 1 && Math.abs(vpos.y * (1000 / aspect) - center[1]) < 1;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,24 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('plan'); c._tool = 'merge'; await c.updateComplete;
|
||||
const room = c._spaceModel().rooms.find((r) => r.name);
|
||||
c._mergeSel = room.id; c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 250)); // дождаться transition 0.12s
|
||||
const el = [...sr().querySelectorAll('.room')].find((e) => e.classList.contains('picked'));
|
||||
out.pickedRendered = !!el;
|
||||
const cs = el ? getComputedStyle(el) : null;
|
||||
out.amberStroke = cs ? cs.stroke.includes('255, 193, 77') : null;
|
||||
out.amberFill = cs ? cs.fill.includes('255, 193, 77') : null;
|
||||
// split-выбор подсвечивается так же
|
||||
c._mergeSel = null; c._tool = 'split'; c._splitSel = { roomId: room.id, pts: [] }; c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const el2 = [...sr().querySelectorAll('.room')].find((e) => e.classList.contains('picked'));
|
||||
out.splitPicked = !!el2 && getComputedStyle(el2).stroke.includes('255, 193, 77');
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res));
|
||||
await browser.close();
|
||||
@@ -16,7 +16,7 @@ const S = () => page.evaluate(() => {
|
||||
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 enter = (t) => page.evaluate((t)=>{const c=window.__card; if(!c._markup)c._setMode('plan'); c._tool=t; return true;}, t);
|
||||
const out = {};
|
||||
|
||||
// MERGE living(r1)+kitchen(r2) — share wall x=0.55, y∈[0.05..0.45]
|
||||
@@ -56,14 +56,14 @@ 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;
|
||||
out.cancelWhole = (await S()).rooms.length===5; // 4 базовых + rg
|
||||
// redo + confirm with a name (no area)
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.28,0.28));
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.0625));
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.5));
|
||||
await page.evaluate(()=>{const c=window.__card; c._nameSel='Cabinet'; c._saveRoomNoArea();});
|
||||
s = await S();
|
||||
out.splitRooms = s.rooms.length; // 5
|
||||
out.splitRooms = s.rooms.length===6; // 4 базовых + rg разрезанная надвое
|
||||
out.bigKeepsLiving = s.rooms.some(r=>r.id==='r1' && r.area==='living_room');
|
||||
out.newRoom = s.rooms.find(r=>!['r1','r2','r3','r4','rg'].includes(r.id))?.name;
|
||||
await restore();
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// 1) первый запуск: базлайн засеян молча, точек нет
|
||||
out.baselineSeeded = Array.isArray(c._serverCfg.settings.known_devices) && c._serverCfg.settings.known_devices.length > 0;
|
||||
out.noDotsOnFirstRun = sr().querySelectorAll('.newdot').length === 0;
|
||||
// 2) в HA появилось новое устройство
|
||||
const h = { ...c.hass };
|
||||
h.devices = { ...h.devices, d_new: { id: 'd_new', name: 'Brand new plug', model: 'Smart Plug', area_id: 'kitchen', identifiers: [['demo', 'd_new']] } };
|
||||
h.entities = { ...h.entities, 'switch.newplug': { entity_id: 'switch.newplug', device_id: 'd_new', platform: 'demo' } };
|
||||
h.states = { ...h.states, 'switch.newplug': { state: 'off', attributes: { friendly_name: 'Brand new plug' } } };
|
||||
c.hass = h; await c.updateComplete; c.requestUpdate(); await c.updateComplete;
|
||||
out.flagged = c._serverCfg.settings.new_device_ids?.includes('d_new');
|
||||
out.dotShown = sr().querySelectorAll('.newdot').length === 1;
|
||||
// 3) существующие не флагуются
|
||||
out.onlyOne = (c._serverCfg.settings.new_device_ids || []).length === 1;
|
||||
// 4) открытие редактора снимает флаг
|
||||
const dev = c._devices.find((d) => d.id === 'd_new');
|
||||
c._setMode('devices');
|
||||
c._openMarkerDialog(dev); await c.updateComplete;
|
||||
c._markerDialog = null; c._setMode('view'); await c.updateComplete;
|
||||
out.ackCleared = !(c._serverCfg.settings.new_device_ids || []).includes('d_new');
|
||||
out.dotGone = sr().querySelectorAll('.newdot').length === 0;
|
||||
// 5) known растёт, повторно не флагуется
|
||||
out.stillKnown = c._serverCfg.settings.known_devices.includes('d_new');
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,43 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('plan'); c._tool = 'opening'; await c.updateComplete;
|
||||
const rooms = c._spaceModel().rooms;
|
||||
const r = rooms.find((x) => x.id === 'r1');
|
||||
const poly = r.poly || [[r.x, r.y], [r.x + r.w, r.y], [r.x + r.w, r.y + r.h], [r.x, r.y + r.h]];
|
||||
const wallMid = [(poly[0][0] + poly[1][0]) / 2, (poly[0][1] + poly[1][1]) / 2];
|
||||
// 1) курсор возле стены → пунктирный призрак есть
|
||||
c._cursorPt = [wallMid[0], wallMid[1] + c._gridPitch * 0.5]; c.requestUpdate(); await c.updateComplete;
|
||||
const ghost = sr().querySelector('.opghost');
|
||||
out.ghostNearWall = !!ghost;
|
||||
if (ghost) {
|
||||
const cs = getComputedStyle(ghost);
|
||||
out.dashed = cs.strokeDasharray !== 'none' && cs.strokeDasharray !== '';
|
||||
// призрак лежит на стене (y координаты концов совпадают с y стены)
|
||||
out.onWall = Math.abs(Number(ghost.getAttribute('y1')) - poly[0][1]) < 0.5
|
||||
&& Math.abs(Number(ghost.getAttribute('y2')) - poly[0][1]) < 0.5;
|
||||
// длина = 90 см в рендер-единицах
|
||||
const len = Math.hypot(ghost.getAttribute('x2') - ghost.getAttribute('x1'), ghost.getAttribute('y2') - ghost.getAttribute('y1'));
|
||||
out.len90cm = Math.abs(len - c._cmToUnits(90)) < 0.5;
|
||||
}
|
||||
// 2) курсор далеко от стен → призрака нет
|
||||
const center = c._roomCenter(r);
|
||||
c._cursorPt = center; c.requestUpdate(); await c.updateComplete;
|
||||
out.noGhostFarFromWall = !sr().querySelector('.opghost');
|
||||
// 3) в других инструментах призрака нет
|
||||
c._tool = 'draw'; c._cursorPt = [wallMid[0], wallMid[1] + 2]; c.requestUpdate(); await c.updateComplete;
|
||||
out.noGhostOtherTool = !sr().querySelector('.opghost');
|
||||
// 4) над существующим проёмом призрак не рисуется (там клик = редактирование)
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
|
||||
...s, openings: [{ id: 'op9', type: 'door', x: wallMid[0] / 1000, y: wallMid[1] / (1000 / (c._curSpaceCfg.aspect || 1)) * (c._curSpaceCfg.aspect ? 1 : 1), angle: 0, length: 0.09 }] })) };
|
||||
c._tool = 'opening'; c.requestUpdate(); await c.updateComplete;
|
||||
const op = c._openingsR[0];
|
||||
c._cursorPt = [op.rx, op.ry]; c.requestUpdate(); await c.updateComplete;
|
||||
out.noGhostOverExisting = !sr().querySelector('.opghost');
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,32 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// RGB-лампа: ceiling on + rgb_color
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'light.ceiling': { entity_id: 'light.ceiling', state: 'on', attributes: { friendly_name: 'Ceiling light', rgb_color: [255, 0, 128] } } } };
|
||||
await c.updateComplete;
|
||||
const rgbDev = sr().querySelector('.dev.rgb');
|
||||
out.rgbClass = !!rgbDev;
|
||||
out.rgbVar = rgbDev?.getAttribute('style')?.includes('--light-color:rgb(255, 0, 128)');
|
||||
// тревога: датчик протечки on
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'binary_sensor.sink_leak': { entity_id: 'binary_sensor.sink_leak', state: 'on', attributes: { friendly_name: 'Leak', device_class: 'moisture' } } } };
|
||||
await c.updateComplete;
|
||||
out.alarmCount = sr().querySelectorAll('.dev.alarm').length;
|
||||
// выключили — тревога ушла
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'binary_sensor.sink_leak': { entity_id: 'binary_sensor.sink_leak', state: 'off', attributes: { friendly_name: 'Leak', device_class: 'moisture' } } } };
|
||||
await c.updateComplete;
|
||||
out.alarmCleared = sr().querySelectorAll('.dev.alarm').length === 0;
|
||||
// unavailable не тревожит
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'binary_sensor.sink_leak': { entity_id: 'binary_sensor.sink_leak', state: 'unavailable', attributes: { device_class: 'moisture' } } } };
|
||||
await c.updateComplete;
|
||||
out.outageSafe = sr().querySelectorAll('.dev.alarm').length === 0;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,61 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// включить имена и все метрики у текущего пространства
|
||||
const spId = c._space;
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||||
...s, settings: { ...(s.settings || {}), show_names: true,
|
||||
label_temp: true, label_hum: true, label_lqi: true, label_light: true } })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const labels = [...sr().querySelectorAll('.roomlabel')];
|
||||
out.labels = labels.length;
|
||||
const cards = labels.filter((l) => l.classList.contains('card'));
|
||||
out.cardsWithMetrics = cards.length;
|
||||
out.hasName = !!labels[0]?.querySelector('.rlname');
|
||||
const metrics = cards[0] ? [...cards[0].querySelectorAll('.rlm')].map((m) => m.textContent.trim()) : [];
|
||||
out.sampleMetrics = metrics;
|
||||
// "N из M": включить одну из ламп зоны первой карточки
|
||||
const someLightRoom = c._spaceModel().rooms.find((r) => r.area && c._devices.some((d) => d.area === r.area && d.entities.some((e) => e.startsWith('light.'))));
|
||||
out.partialTested = false;
|
||||
if (someLightRoom) {
|
||||
const lights = [...new Set(c._devices.filter((d) => d.area === someLightRoom.area).flatMap((d) => d.entities).filter((e) => e.startsWith('light.')))];
|
||||
if (lights.length >= 2) {
|
||||
const st = { ...c.hass.states };
|
||||
lights.forEach((e, i) => { st[e] = { ...st[e], state: i === 0 ? 'on' : 'off' }; });
|
||||
c.hass = { ...c.hass, states: st }; await c.updateComplete;
|
||||
const lbl = [...sr().querySelectorAll('.roomlabel.card')].find((l) => l.textContent.includes(someLightRoom.name));
|
||||
out.partialText = lbl?.querySelector('.rlm.lit')?.textContent.trim();
|
||||
out.partialTested = /1\D+' + lights.length + '/.test(out.partialText || '') || (out.partialText || '').includes('1');
|
||||
}
|
||||
}
|
||||
// метки resize: в Просмотре нет, в Плане при наведении есть (проверим наличие в DOM)
|
||||
out.noHandlesInView = sr().querySelectorAll('.rlhandle').length === 0;
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
const planLbl = sr().querySelector('.roomlabel');
|
||||
out.handlesInPlan = planLbl?.querySelectorAll('.rlhandle').length === 4;
|
||||
// метрики скрыты в редакторе плана (карточка = имя, чтобы не мешать разметке)
|
||||
out.plainInPlan = sr().querySelectorAll('.roomlabel.card').length === 0;
|
||||
// масштаб: сымитируем resize через прямой вызов
|
||||
const room = c._spaceModel().rooms.find((r) => r.name);
|
||||
c._rlResize = { id: 'rl_' + room.id, space: spId, k0: 1, cx: 100, cy: 100, d0: 50 };
|
||||
c._rlResizeMove({ clientX: 200, clientY: 100, stopPropagation() {} });
|
||||
c._rlResizeUp();
|
||||
await c.updateComplete;
|
||||
out.scaleSaved = Math.abs((c._layout['rl_' + room.id]?.k || 0) - 2) < 0.01;
|
||||
const lblEl = sr().querySelector('.roomlabel');
|
||||
out.scaleApplied = getComputedStyle(lblEl).getPropertyValue('--rl-scale').trim() === '2';
|
||||
// клампы
|
||||
c._rlResize = { id: 'rl_' + room.id, space: spId, k0: 2, cx: 100, cy: 100, d0: 50 };
|
||||
c._rlResizeMove({ clientX: 1000, clientY: 100, stopPropagation() {} });
|
||||
c._rlResizeUp();
|
||||
out.clamped = c._layout['rl_' + room.id].k <= 3;
|
||||
// drag сохраняет масштаб
|
||||
c._savePos({ id: 'rl_' + room.id, space: spId }, 300, 300);
|
||||
out.dragKeepsScale = c._layout['rl_' + room.id].k <= 3 && c._layout['rl_' + room.id].k >= 2.9;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -5,7 +5,7 @@ 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';});
|
||||
await page.evaluate(()=>{const c=window.__card; if(!c._markup)c._setMode('plan'); c._tool='split';});
|
||||
// living room (r1) has walls at y=0.05 which are NOT grid nodes; click near the wall
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // pick living
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.052)); // near top wall (off grid)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const stage = () => sr().querySelector('.stage');
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
// 1) курсоры: split до выбора комнаты — pointer, после — crosshair; merge — pointer
|
||||
c._tool = 'split'; c.requestUpdate(); await c.updateComplete;
|
||||
out.splitPickCursor = getComputedStyle(stage()).cursor === 'pointer';
|
||||
const rooms = c._spaceModel().rooms;
|
||||
const room = rooms.find((r) => r.id === 'r1');
|
||||
const center = c._roomCenter(room);
|
||||
c._splitClick(center); await c.updateComplete;
|
||||
out.roomPicked = c._splitSel?.roomId === 'r1';
|
||||
out.splitCutCursor = getComputedStyle(stage()).cursor === 'crosshair';
|
||||
c._tool = 'merge'; c._splitSel = null; c.requestUpdate(); await c.updateComplete;
|
||||
out.mergeCursor = getComputedStyle(stage()).cursor === 'pointer';
|
||||
// 2) полилинейный разрез: r1 = прямоугольник? возьмём его poly и построим Г-разрез
|
||||
c._tool = 'split'; await c.updateComplete;
|
||||
c._splitClick(center);
|
||||
const poly = (() => { const r = rooms.find((x) => x.id === 'r1');
|
||||
return r.poly ? r.poly : [[r.x, r.y], [r.x + r.w, r.y], [r.x + r.w, r.y + r.h], [r.x, r.y + r.h]]; })();
|
||||
// старт на середине верхней стены, промежуточная внутри, конец на правой стене
|
||||
const [A, B, C2, D] = [poly[0], poly[1], poly[2], poly[3]];
|
||||
const top = [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
|
||||
const right = [(B[0] + C2[0]) / 2, (B[1] + C2[1]) / 2];
|
||||
const mid = [(top[0] + right[0]) / 2 - 20, (top[1] + right[1]) / 2 + 20];
|
||||
c._splitClick(top);
|
||||
out.firstOnWall = c._splitSel?.pts.length === 1;
|
||||
c._splitClick(mid);
|
||||
out.midAdded = c._splitSel?.pts.length === 2;
|
||||
c._splitClick(right);
|
||||
out.dialogAfterCut = !!c._pendingSplit && c._roomDialog;
|
||||
out.partsPolys = c._pendingSplit ? [c._pendingSplit.mainPoly.length, c._pendingSplit.newPoly.length] : null;
|
||||
// отмена — комната целая
|
||||
c._roomDialogCancel(); await c.updateComplete;
|
||||
out.cancelKeepsRoom = !c._pendingSplit && rooms.length === c._spaceModel().rooms.length;
|
||||
// 3) Esc: пошаговый выход
|
||||
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
|
||||
c._tool = 'split'; c._splitSel = null;
|
||||
c._splitClick(center); c._splitClick(top); c._splitClick(mid); await c.updateComplete;
|
||||
await esc(); out.escDropsPoint = c._splitSel?.pts.length === 1;
|
||||
await esc(); out.escDropsFirst = c._splitSel?.pts.length === 0;
|
||||
await esc(); out.escDropsRoom = c._splitSel === null && c._tool === 'split';
|
||||
await esc(); out.escExitsTool = c._tool === 'draw';
|
||||
// merge: выбор → Esc снимает, ещё Esc — выход
|
||||
c._tool = 'merge'; c._mergeSel = 'r1'; await c.updateComplete;
|
||||
await esc(); out.escClearsMerge = c._mergeSel === null && c._tool === 'merge';
|
||||
await esc(); out.escExitsMerge = c._tool === 'draw';
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,34 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const devIcon = (id) => {
|
||||
const idx = c._devices.filter((x) => x.space === c._space).findIndex((x) => x.id === id);
|
||||
const el = sr().querySelectorAll('.dev')[idx];
|
||||
return el?.querySelector('ha-icon')?.getAttribute('icon');
|
||||
};
|
||||
// 1) state-иконки: замок locked → mdi:lock; unlocked → lock-open-variant
|
||||
out.lockLocked = devIcon('d_lock');
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'lock.front_door': { ...c.hass.states['lock.front_door'], state: 'unlocked' } } };
|
||||
await c.updateComplete;
|
||||
out.lockUnlocked = devIcon('d_lock');
|
||||
// окно: on → window-open
|
||||
out.windowOpen = devIcon('d_window');
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'binary_sensor.window': { ...c.hass.states['binary_sensor.window'], state: 'off' } } };
|
||||
await c.updateComplete;
|
||||
out.windowClosed = devIcon('d_window');
|
||||
// лампа on → lightbulb-on
|
||||
out.bulbOn = devIcon('d_light1');
|
||||
// 2) display:value у термометра
|
||||
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'd_temp', binding: 'device:d_temp', display: 'value' }] };
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
const vd = [...sr().querySelectorAll('.dev.valonly')];
|
||||
out.valonly = vd.length;
|
||||
out.valText = vd[0]?.querySelector('.valtext')?.textContent.trim();
|
||||
out.noSmallBadge = !vd[0]?.querySelector('.tval');
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,35 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
// комната без зоны на f1
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({
|
||||
...s, rooms: [...s.rooms, { id: 'rc', name: 'Cupboard', area: null, poly: [[0.05,0.62],[0.2,0.62],[0.2,0.9],[0.05,0.9]] }],
|
||||
})) };
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
|
||||
// список комнат в диалоге содержит подзону
|
||||
c._setMode('devices');
|
||||
c._openMarkerDialog(); await c.updateComplete;
|
||||
const rooms = c._allRoomsFlat();
|
||||
out.subareaListed = rooms.some((r) => r.value === 'f1#@rc');
|
||||
// создать виртуальный маркер в подзону
|
||||
c._markerDialog = { ...c._markerDialog, name: 'Vacuum dock', binding: 'virtual', room: 'f1#@rc' };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const m = c._serverCfg.markers.find((x) => x.name === 'Vacuum dock');
|
||||
out.markerRoomId = m?.room_id;
|
||||
out.markerAreaNull = m ? m.area === null : null;
|
||||
// позиция — в центре подзоны (норм. центр rc: x=0.125, y=0.76)
|
||||
const pos = c._layout[m.id];
|
||||
out.posInRoom = pos && Math.abs(pos.x - 0.125) < 0.03 && Math.abs(pos.y - 0.76) < 0.03;
|
||||
// девайс отрисован
|
||||
const dev = c._devices.find((d) => d.id === m.id);
|
||||
out.deviceBuilt = !!dev && dev.space === 'f1';
|
||||
// reopen диалога восстанавливает выбор
|
||||
c._openMarkerDialog(dev);
|
||||
out.reopenRoom = c._markerDialog?.room;
|
||||
c._markerDialog = null;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
+439
-138
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+439
-138
File diff suppressed because one or more lines are too long
@@ -1,5 +1,191 @@
|
||||
# Changelog
|
||||
|
||||
## v1.34.0 — 2026-07-22 (island rooms)
|
||||
- **Nested rooms are now legal**: draw a contour fully inside an existing room
|
||||
(or around one) — a column in a ring-shaped room, an inner room, a wardrobe
|
||||
island. The parent room's fill is rendered with an evenodd hole, so a ring
|
||||
paints as a ring; the island itself stays clickable and can carry its own
|
||||
area, fill and devices. Partial overlaps and duplicate outlines are still
|
||||
rejected (`roomsOverlap` reworked; `polyContainsPoly`/`islandsOf` helpers,
|
||||
unit-tested). The per-click "point inside a room" rejection is gone —
|
||||
validation happens once, when the outline closes.
|
||||
|
||||
## v1.33.5 — 2026-07-22
|
||||
- Editor tabs got extended tooltips explaining what each editor is for (plan
|
||||
geometry vs device icons vs visual decor).
|
||||
|
||||
## v1.33.4 — 2026-07-22
|
||||
- Editing a device no longer makes its icon jump. Changing the HA binding
|
||||
(which changes the marker id) migrates the saved position to the new id;
|
||||
changing the room within the same space keeps the icon exactly where it
|
||||
stands (previously it re-centered in the new room). Only a brand-new icon,
|
||||
or a move to a room in a different space, is centered.
|
||||
|
||||
## v1.33.3 — 2026-07-22
|
||||
- Device dialog: when no icon is set explicitly, the icon picker no longer
|
||||
looks empty — it shows the **auto-derived icon** (from the icon rules /
|
||||
device class) as a placeholder, with an "Auto: mdi:…" hint line underneath.
|
||||
Picking an explicit icon replaces it as before; clearing returns to auto.
|
||||
|
||||
## v1.33.2 — 2026-07-22
|
||||
- Removed the **Reset** button from the Device editor. It wiped the entire
|
||||
layout — positions of all devices, room cards and their scales across every
|
||||
space — behind a single confirm. Low value, high blast radius.
|
||||
|
||||
## v1.33.1 — 2026-07-22
|
||||
- The dot grid is now shown in **every editor** (Plan, Devices, Background),
|
||||
not just Plan — an instant visual cue that you are editing.
|
||||
- In the Background editor, rooms, devices, openings and labels fade to 35%
|
||||
opacity so the decor you are drawing stands out; decor itself stays fully
|
||||
opaque. Other modes are unaffected.
|
||||
|
||||
## v1.33.0 — 2026-07-22 (background editor)
|
||||
- New third mode: **Background editor**. Draw purely visual decor on the plan —
|
||||
lines, rectangles, ovals and text labels that never interact with rooms,
|
||||
devices or fills. Shapes are drag-drawn with grid snap and a live preview;
|
||||
text is placed via a small dialog (size S/M/L, color; double-click to edit).
|
||||
Toolbar: Select (move, Delete key), Erase, color, three line widths and an
|
||||
optional 25% fill for rects/ovals. Esc walks back: draft → selection →
|
||||
Select tool → View.
|
||||
- The decor layer renders **under rooms** (a true underlay), is visible in all
|
||||
modes and completely click-transparent outside the editor. Stored per space
|
||||
in the server config (`space.decor`, validated on the backend, shared across
|
||||
clients with the usual rev/optimistic locking).
|
||||
|
||||
## v1.32.1 — 2026-07-22
|
||||
- Opening tool: hovering near a wall now shows a **dashed preview** of where
|
||||
the opening would land — snapped onto the wall, default door length (90 cm),
|
||||
with a center dot. No preview far from walls or over an existing opening
|
||||
(a click there edits it instead).
|
||||
|
||||
## v1.32.0 — 2026-07-22 (split polyline, tool cursors, Esc)
|
||||
- **Split can now cut along a polyline**, not just a straight chord: start on
|
||||
a wall, click intermediate points inside the room, finish on another wall.
|
||||
The path is validated (no wall crossings, no self-intersection) and drawn
|
||||
live with vertices and a preview segment. Two clicks still work as before.
|
||||
- **Tool cursors**: Merge and delete-room show a pointer; Split shows a
|
||||
pointer while picking the room, then a crosshair while cutting.
|
||||
- **Esc walks back out of Merge/Split** step by step: last cut point → first
|
||||
point → room selection → back to the Draw tool. Merge: selection → tool.
|
||||
|
||||
## v1.31.2 — 2026-07-22
|
||||
- Plan editor: the room picked with the **Merge** tool (and the room selected
|
||||
for **Split**) is highlighted amber again. The `.outlined` markup style,
|
||||
added later in the stylesheet, was silently overriding the `.picked`
|
||||
highlight at equal specificity (source-order gotcha #4 — rule order fixed
|
||||
and documented in the stylesheet).
|
||||
|
||||
## v1.31.1 — 2026-07-22
|
||||
- Plan editor: interacting with a room card (drag, corner-resize or a plain
|
||||
click) no longer leaks into the active markup tool — previously the click
|
||||
after a resize could add an outline point, pick a merge/split room or even
|
||||
prompt to delete the room under the card.
|
||||
|
||||
## v1.31.0 — 2026-07-22 (room cards)
|
||||
- Room labels grew into **room cards**: the name on top, and an optional
|
||||
smaller metrics line below — temperature, humidity, average Zigbee signal
|
||||
and lights, each behind its own checkbox in the space settings (all off by
|
||||
default). Lights show On/Off, or **"1 of 3"** when only part of the room is
|
||||
lit. Rooms without an HA area keep showing just the name.
|
||||
- Cards are **resizable** in the Plan editor: hovering shows corner handles;
|
||||
dragging one scales the whole card uniformly (0.5×–3×). The scale is stored
|
||||
in the layout next to the card position, and dragging keeps it.
|
||||
- Fixed a latent v1.25 regression: room labels were not rendered as draggable
|
||||
HTML in the Plan editor at all (only a static SVG name), so moving them was
|
||||
impossible. The Plan editor now renders real cards (name only) that can be
|
||||
dragged and resized.
|
||||
- Two markup smokes (merge/split) still called a method removed in v1.25 —
|
||||
repaired.
|
||||
|
||||
## v1.30.4 — 2026-07-22
|
||||
- **Escape now closes every dialog** (general settings, icon rules, device
|
||||
editor, space dialog, opening editor, info cards), topmost first when
|
||||
stacked. Closing the space dialog with Esc abandons a floor-import queue,
|
||||
same as its Cancel button. Esc while drawing an outline still undoes the
|
||||
last point (dialogs take priority).
|
||||
|
||||
## v1.30.3 — 2026-07-22
|
||||
- The **General settings** (fill palette) gear in the header is now visible in
|
||||
every mode for users who can edit, not just in the Plan editor.
|
||||
|
||||
## v1.30.2 — 2026-07-22 (editor tabs redesign)
|
||||
- Mode tabs renamed and reduced to two: **"Plan editor"** and **"Device
|
||||
editor"**. View is no longer a tab — it is the implicit default state.
|
||||
- The Device editor now has its own bottom toolbar (add / show all / reset
|
||||
layout / icon rules moved out of the header), mirroring the Plan toolbar.
|
||||
- Both toolbars and the active tab itself got an **X** button that closes the
|
||||
editor and returns to View. Re-clicking the active tab does nothing;
|
||||
switching Plan↔Devices is direct.
|
||||
|
||||
## v1.30.1 — 2026-07-22 (space gear polish)
|
||||
- The gear icon next to the space name is now visible in **every mode** (not
|
||||
just Plan) for users who can edit, so space settings are always one click
|
||||
away. The "+" (add space) tab remains Plan-only.
|
||||
- Fixed the gear's vertical alignment — it sat noticeably lower than the space
|
||||
name (baseline-aligned web component); tabs are now flex-centered.
|
||||
|
||||
## v1.30.0 — 2026-07-22 (lock action in the opening info card)
|
||||
- The door/window info card (View mode) now offers an explicit **Unlock/Lock
|
||||
button** when a lock entity is bound and available. Unlock is styled red as a
|
||||
security-sensitive action; the button is disabled during locking/unlocking and
|
||||
hidden when the lock is unavailable.
|
||||
- The security rule is untouched: **tapping a lock icon on the plan still never
|
||||
toggles it** — the action lives only behind a deliberate, clearly labeled
|
||||
button, same interaction contract as HA's more-info dialog.
|
||||
|
||||
## v1.29.0 — 2026-07-22 (the "new device" flag)
|
||||
- **Devices that appear in HA after installation no longer show up silently**:
|
||||
an auto-placed device (or light group) gets a big red dot at the top-right of
|
||||
its icon. The flag is stored server-side (`settings.new_device_ids`), so every
|
||||
client sees it — and it disappears everywhere the first time someone opens
|
||||
that device's editor.
|
||||
- The baseline of known devices is seeded silently on the first run after the
|
||||
update, so **existing devices never flood the plan with dots**. Hand-made
|
||||
markers (virtual/rebindings) are never flagged — the user just created them.
|
||||
(Pure `diffNewDevices`, unit-tested; backend schema for the two new settings.)
|
||||
|
||||
## v1.28.1 — 2026-07-22 (View-mode polish: cursors and inert openings)
|
||||
- **Device icons in View no longer show the grab cursor** (drag lives in the
|
||||
Devices mode); they show a pointer — clicking still works exactly as before.
|
||||
- **Doors and windows in View are pure drawings**: no grab cursor, no hover
|
||||
outline, no hit target, no click — regardless of what is bound to them.
|
||||
- **The lock badge is the one exception**: when a lock is bound, the badge is
|
||||
shown and clickable in View (pointer cursor, click opens the door/lock info
|
||||
card). It stays inert in Plan so it does not fight editing.
|
||||
- In Plan an opening remains fully interactive: grab cursor + hover outline,
|
||||
dragging along walls, and a click with ANY tool opens its properties dialog.
|
||||
|
||||
## v1.28.0 — 2026-07-21 (sub-area rooms: manual placement without an HA area; issue #3)
|
||||
- **Devices can now be placed into rooms that have no Home Assistant area.**
|
||||
The marker dialog's room list includes area-less rooms (marked "no area,
|
||||
manual"); pick one and the device/virtual marker lands at its centre. The
|
||||
marker stores `room_id`, so the choice survives edits and re-opens. The
|
||||
laundry-cupboard case from issue #3: a decorative room inside a larger area
|
||||
can now hold its own door sensor and light.
|
||||
- Room reference parsing extracted as pure `parseRoomRef` (`space#area` /
|
||||
`space#@roomId`), unit-tested; backend schema for `room_id`.
|
||||
|
||||
## v1.27.0 — 2026-07-21 (RGB light colors + alarm pulse; issue #3)
|
||||
- **RGB lights show their actual color**: an "on" light with a color tints its
|
||||
bulb icon, glow and — unless a custom ripple color is set — its presence ripple.
|
||||
Brightness is deliberately ignored (a dim red bulb still reads red); off,
|
||||
white-only and unavailable lights look as before. (Pure `lightColorOf`.)
|
||||
- **Emergencies pulse red**: leak / smoke / gas / CO / safety / tamper / problem
|
||||
binary sensors and sirens in `on` get a red pulsing ring over any display mode.
|
||||
`unavailable`/`unknown` never alarm — an outage is not a fire. Honours
|
||||
`prefers-reduced-motion`. (Pure `isAlarmState`.)
|
||||
|
||||
## v1.26.0 — 2026-07-21 (state-reflecting icons + value display; issue #3)
|
||||
- **Auto icons now reflect live state**, like core HA: door/window/garage sensors
|
||||
swap open↔closed variants, locks show locked/unlocked, a bulb lights up as
|
||||
`lightbulb-on`. Conservative by design: only well-known pairs, never when the
|
||||
user picked a custom icon, and `unavailable`/`unknown` keep the base icon.
|
||||
Gated by the existing "live states" card option. (Pure `stateIcon`, unit-tested.)
|
||||
- **New marker display mode "Value instead of an icon"**: the measurement itself
|
||||
(temperature °, humidity %, or any numeric state with its unit) becomes the
|
||||
marker body — the small corner badges disappear for such markers. Direct
|
||||
request from issue #3. Non-numeric entities fall back to the icon.
|
||||
|
||||
## v1.25.0 — 2026-07-21 (three interaction modes: View / Plan / Devices)
|
||||
The approved UX redesign (docs/UX-MODES.md), confirmed by user feedback (#3):
|
||||
|
||||
|
||||
+5
-5
@@ -74,12 +74,12 @@ 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)
|
||||
## Phase 11 — UX modes redesign ✅ DONE (v1.25.0–v1.28.0, 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.
|
||||
Shipped: the three-tab interaction model (View / Plan / Devices, v1.25.0),
|
||||
state-reflecting icons + value display (v1.26.0), RGB light colors + alarm pulse
|
||||
(v1.27.0), sub-area rooms (v1.28.0). Remaining backlog from issue #3: music
|
||||
notes for players, directional TV ripples (not planned).
|
||||
|
||||
## Explicit non-goals (for now)
|
||||
|
||||
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
# Product scope — what House Plan is and is not
|
||||
|
||||
*Fixed with the owner on 2026-07-22. This document is a guard rail: features are
|
||||
built, improved and accepted **only** if they serve a job listed here. When a new
|
||||
idea appears, first find its row in this file; if there is none — it belongs to
|
||||
HA core, to another card, or nowhere. Companion documents: PRODUCT.md (market),
|
||||
ROADMAP.md (order of work), UX-MODES.md (interaction model).*
|
||||
|
||||
## Mission
|
||||
|
||||
House Plan is the **spatial "at a glance + quick act" layer** for a Home
|
||||
Assistant home. Upload or draw a floor plan, outline rooms bound to HA areas,
|
||||
and the home's devices appear on a live, tappable map: states, climate, alerts,
|
||||
guarded quick actions. Setup is GUI-only — no Inkscape, no YAML, no external
|
||||
editors. Everything that is not "look at the home spatially and act on the
|
||||
obvious" is somebody else's job.
|
||||
|
||||
## Target audience
|
||||
|
||||
| Persona | Role | Surface |
|
||||
|---|---|---|
|
||||
| **Home admin** (primary) | HA enthusiast, house/large flat, 20–200 devices, several floors; sets up and maintains the plan | Desktop browser (both editors live here) |
|
||||
| **Household members** | Non-technical; consume the plan daily, never edit | Wall tablet (kiosk), phone (companion app) |
|
||||
| **Guests / kiosk** | View-only glance at the home | Wall tablet |
|
||||
|
||||
Design consequence: **View mode is the product** for two of the three personas.
|
||||
Editors are admin-only tools and must never leak interactions into View
|
||||
(established by UX-MODES; lock guard, inert openings, no drag in View).
|
||||
|
||||
## Core user jobs — the component must close these
|
||||
|
||||
| # | Job (user's words) | Status |
|
||||
|---|---|---|
|
||||
| J1 | "Show the whole home and what's happening right now" — live spatial overview: device states, room fills (light/temp/LQI), values, multi-floor tabs | **Closed** |
|
||||
| J2 | "Something is wrong — show me *where*" — leak/smoke/gas pulse, open doors/windows, unlocked locks, red dot on devices HA added silently | **Closed** |
|
||||
| J3 | "Let me act on the obvious right from the plan" — tap-to-toggle for safe domains, info cards, guarded lock action (explicit button only, never a plan tap) | **Closed** |
|
||||
| J4 | "From zero to a working plan in one evening, no Inkscape/YAML" — image/PDF/draw, floors-import wizard, room polygons bound to areas, curated auto-placement, editable icon rules | **Closed**; onboarding polish is *partial* (no registry-driven room suggestions) |
|
||||
| J5 | "Room climate at a glance" — per-room temperature/humidity, comfort-range fills, room-card metrics | **Closed** |
|
||||
| J6 | "Keep the plan true as the home evolves" — new-device flag, two editors, drag/resize, merge/split, multi-client live sync, optimistic locking | **Closed** |
|
||||
| J7 | "Is my Zigbee mesh healthy *here*?" — LQI badges, per-room average, LQI fill | **Closed** (kept deliberately: cheap, spatial by nature, no in-plan competitor) |
|
||||
|
||||
## Partially covered — improvement backlog stays inside these
|
||||
|
||||
- **Touch ergonomics of the editors**: corner handles and grid clicks are small on
|
||||
tablets; editors are desktop-first today. Improve, don't redesign.
|
||||
- **Value display**: single current value per device; units/precision follow HA
|
||||
formatting only.
|
||||
- **Accessibility**: `prefers-reduced-motion` only; no keyboard navigation in
|
||||
editors, no ARIA labelling of the plan.
|
||||
- **Docs/screenshots**: README predates the two-editor redesign.
|
||||
|
||||
## Known gaps that fit the mission (build only on owner's request)
|
||||
|
||||
- Person/presence shown in rooms (classic floorplan ask; pure J1).
|
||||
- Plan-level "security glance": one badge for "all locked / N open" (J2).
|
||||
- Threshold colouring for room-card metrics (J5).
|
||||
|
||||
## Out of scope — never build, point users to the right tool
|
||||
|
||||
- Automations, scenes, scripts, notifications → HA core.
|
||||
- Device/entity administration (rename, reassign area, disable) → HA registry
|
||||
UIs. We *read* the registry, we never manage it.
|
||||
- History, graphs, statistics → recorder/history cards. We show now, not then.
|
||||
- Camera streams, media controls → their own cards; our more-info opens HA's.
|
||||
- Energy monitoring/analytics → HA Energy.
|
||||
- 3D / isometric / photorealistic rendering, furniture drawing → niche tools
|
||||
(easy-floorplan et al.); we are a schematic live map, not an interior editor.
|
||||
- A general dashboard framework (menus, popups, theming engine) → Bubble Card,
|
||||
Dwains and friends.
|
||||
|
||||
## Excess-functionality audit (2026-07-22)
|
||||
|
||||
- **Device metadata: links & PDF manuals** — not spatial, but tiny, server-side,
|
||||
and used in real installs. Verdict: **keep, frozen** (no growth).
|
||||
- **Virtual devices** — placeholders for not-yet-installed hardware; serves J6.
|
||||
Verdict: **keep, frozen**.
|
||||
- **LQI diagnostics** — promoted to J7 (differentiator), not excess.
|
||||
- Nothing found that warrants removal; the mode redesign already moved every
|
||||
interaction to where it belongs.
|
||||
|
||||
## The component in one breath (README/HACS copy)
|
||||
|
||||
> **House Plan** turns your Home Assistant into a live map of your home. Upload
|
||||
> a plan image (or draw one), outline rooms and bind them to HA areas — your
|
||||
> devices appear in place, automatically, with live states. Glance at the wall
|
||||
> tablet: what's on, what's open, what's too cold, what's leaking, what's new.
|
||||
> Tap to act — safely: locks never toggle by accident. Two built-in editors
|
||||
> (plan and devices) mean no Inkscape, no YAML, no external tools — ever.
|
||||
|
||||
**Tasks it closes:** whole-home live overview · spatial alerts (leak/smoke/open/
|
||||
unlocked/new device) · safe quick actions · per-room climate · Zigbee mesh
|
||||
health · zero-to-plan GUI onboarding · keeping the plan true over years.
|
||||
|
||||
**Pains it removes:** hand-crafted SVG + YAML floorplans · entity-list
|
||||
dashboards that hide *where* things happen · silent device sprawl · accidental
|
||||
toggles of security devices · per-device dashboards that non-technical family
|
||||
members can't read.
|
||||
+7
-1
@@ -13,7 +13,7 @@
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.25.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
||||
| Version | **v1.27.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…**v1.23.1** (latest published 2026-07-17, bundle auto-attached by release.yml) |
|
||||
| CI | `.github/workflows/validate.yml` (hacs + hassfest + frontend + backend) — **fully green** since v1.11.1; `release.yml` auto-attaches the card bundle (needs `permissions: contents: write`, fixed) |
|
||||
| HACS | Works as custom repository (id 1290210112 on the home instance). **Inclusion PR: https://github.com/hacs/default/pull/9004** (queue ≈2 months as of 2026-07). Lesson: #8995 was auto-closed by hacs-bot — the PR body MUST be their exact template with every checkbox ticked and all 3 links (release, HACS action run, hassfest run); a custom body gets closed without discussion |
|
||||
@@ -129,3 +129,9 @@
|
||||
md5-verify after every deploy, restart HA via
|
||||
`nohup ha core restart >/dev/null 2>&1 </dev/null &` (otherwise the SSH session hangs).
|
||||
5. GitHub pushes need a PAT (create via the user's Chrome: settings/tokens, repo+workflow scope).
|
||||
|
||||
## Product scope
|
||||
|
||||
docs/SCOPE.md (fixed 2026-07-22) is the guard rail for all feature work: mission,
|
||||
personas, jobs J1–J7, partial/out-of-scope lists, excess audit. Check it before
|
||||
accepting or proposing any feature.
|
||||
|
||||
+91
-2
@@ -40,8 +40,16 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
- [ ] 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]
|
||||
- [ ] Openings in View (v1.28.1): the door/window itself is a pure drawing — no
|
||||
cursor change, no hover outline, no hit target, no click, regardless of
|
||||
bindings [auto]
|
||||
- [ ] The LOCK BADGE is the one exception: when a lock is bound it is shown and
|
||||
clickable in View (pointer cursor, click → door/lock info card); inert in
|
||||
Plan so it does not fight editing [auto]
|
||||
- [ ] Device icons in View show a pointer cursor (no grab); grab only in the
|
||||
Devices mode [auto]
|
||||
- [ ] In Plan an opening is interactive: grab cursor, hover outline, drag along
|
||||
walls, click (any tool) opens its properties [auto]
|
||||
|
||||
## Onboarding ★
|
||||
|
||||
@@ -124,6 +132,84 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
- [ ] ↺ reset restores auto layout after confirm
|
||||
- [ ] Temperature badge on thermometers; LQI value under zigbee icons with red→green color
|
||||
- [ ] Live states: light on = yellow, open cover/lock/door = orange, unavailable = faded
|
||||
- [ ] State icons (v1.26.0): auto icons morph with state — door/window/garage open↔closed,
|
||||
lock locked↔unlocked, bulb on; custom icons and unavailable states never morph [auto]
|
||||
- [ ] display "Value instead of an icon": the marker shows the measurement (°/%/unit)
|
||||
as its body, small badges hidden; non-numeric fallback keeps the icon [auto]
|
||||
- [ ] RGB lights (v1.27.0): an on light with a color tints its icon/glow and the ripple
|
||||
(explicit ripple color still wins); off/white lights unchanged [auto]
|
||||
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
|
||||
display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static
|
||||
- [ ] Island rooms (v1.34.0): a contour drawn fully inside an existing room
|
||||
(or around one) saves as a nested room — column in a ring, inner room;
|
||||
the parent's fill renders with an evenodd hole so the ring paints
|
||||
correctly; the island stays clickable; partial overlaps and duplicate
|
||||
outlines are still rejected at closing [auto]
|
||||
- [ ] Icon stays on edit (v1.33.4): rebinding a device (HA device/entity) or
|
||||
changing its room within the same space never moves the icon — the saved
|
||||
or auto position migrates to the new marker id; only a brand-new icon or
|
||||
a move to another space centers it in the target room [auto]
|
||||
- [ ] Icon picker placeholder (v1.33.3): with no explicit icon the device
|
||||
dialog's icon picker shows the auto-derived icon as its placeholder, plus
|
||||
an "Auto: mdi:..." hint line with the icon preview; the hint disappears
|
||||
once an explicit icon is picked [auto]
|
||||
- [ ] No Reset button (v1.33.2): the Device editor toolbar has three tools —
|
||||
add, show all, icon rules; the layout-wiping Reset is gone [auto]
|
||||
- [ ] Grid in all editors + decor fade (v1.33.1): the dot grid shows in the
|
||||
Device and Background editors too (instant "I'm editing" cue), not in
|
||||
View; in the Background editor rooms/devices/openings/labels fade to 35%
|
||||
while decor shapes stay fully opaque; no fade in the other editors [auto]
|
||||
- [ ] Background editor (v1.33.0): third tab with its own toolbar (select /
|
||||
line / rect / oval / text / erase + color, width, fill, X); shapes drag-
|
||||
drawn with grid snap and live preview; degenerate shapes dropped; text
|
||||
via dialog (S/M/L, color; dblclick re-edits); Select moves (snap), Delete
|
||||
removes, Erase deletes on click; Esc: draft → selection → select tool →
|
||||
View; decor renders under rooms, visible everywhere, inert outside the
|
||||
editor; stored in space.decor (rev/lock, backend schema) [auto]
|
||||
- [ ] Opening hover preview (v1.32.1): with the Opening tool, hovering near a
|
||||
wall shows a dashed 90 cm ghost snapped onto the wall (with a center
|
||||
dot); no ghost far from walls, over an existing opening (click = edit),
|
||||
or in other tools [auto]
|
||||
- [ ] Split polyline + cursors + Esc (v1.32.0): Merge shows a pointer cursor,
|
||||
Split shows pointer until a room is picked then crosshair; the cut can be
|
||||
a polyline — start on a wall, intermediate clicks inside the room, finish
|
||||
on a wall (path drawn live, walls/self-crossing rejected); Esc walks back:
|
||||
last cut point → room pick → back to the Draw tool (same for Merge:
|
||||
selection → tool) [auto]
|
||||
- [ ] Merge/split pick highlight (v1.31.2): the first room clicked with the
|
||||
Merge tool (and the split-selected room) gets an amber outline + fill;
|
||||
visible over the blue markup outlines [auto]
|
||||
- [ ] Card vs tool conflict (v1.31.1): in the Plan editor, dragging/resizing or
|
||||
clicking a room card never feeds the active tool (no draw point, no
|
||||
delete-room confirm, no merge/split pick); clicks past the card work [auto]
|
||||
- [ ] Room cards (v1.31.0): with metrics enabled in space settings (4
|
||||
checkboxes: temperature, humidity, avg Zigbee, lights) the room name gets
|
||||
a smaller metrics line under it; lights show On/Off or "1 of 3" when
|
||||
partially lit; rooms without an HA area show the name only; in the Plan
|
||||
editor cards show the name only, are draggable and resizable via corner
|
||||
handles on hover (uniform scale 0.5–3, stored in layout, survives drag);
|
||||
View mode has no handles/hover [auto]
|
||||
- [ ] Esc closes dialogs (v1.30.4): Escape closes the topmost dialog (opening
|
||||
info, device info, icon rules, general settings, device editor, opening
|
||||
editor, space dialog incl. abandoning an import queue); stacked dialogs
|
||||
close one per press; Esc while drawing still undoes the last point [auto]
|
||||
- [ ] General settings gear (v1.30.3): the header cog is visible in every mode
|
||||
(admins), opens the palette dialog from View too [auto]
|
||||
- [ ] Editor tabs (v1.30.2): only two tabs — "Plan editor" / "Device editor"
|
||||
(no View button; View is the default state); clicking a tab opens its
|
||||
bottom toolbar (Devices got its own bar with add/show-all/reset/rules);
|
||||
the bar and the active tab both show an X that returns to View; re-click
|
||||
on the active tab does nothing; Plan↔Devices switches directly [auto]
|
||||
- [ ] Space gear (v1.30.1): the cog next to the space name is visible in every
|
||||
mode (admins only), vertically centered with the tab text; clicking it
|
||||
opens space settings without switching the tab; "+" tab stays Plan-only [auto]
|
||||
- [ ] Lock action (v1.30.0): opening info card (View) shows Unlock (red) when
|
||||
locked / Lock when unlocked; button calls the lock service; disabled while
|
||||
locking/unlocking; hidden when unavailable; plan-icon tap still never
|
||||
toggles a lock [auto]
|
||||
- [ ] New-device flag (v1.29.0): a device added to HA after install gets a big red
|
||||
dot top-right of its icon (all clients); opening its editor clears it
|
||||
everywhere; upgrade/first-run seeds the baseline silently — no dot flood [auto]
|
||||
- [ ] No devices at all in HA (fresh instance) → plan renders, "0 dev.", no console errors [auto]
|
||||
|
||||
## Device dialog (markers) ★
|
||||
@@ -131,6 +217,9 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
- [ ] Open via info card → Edit; all fields persist (name, icon, model, link, description)
|
||||
- [ ] Rebind to another device/entity/helper: search filters; already-placed candidates excluded; old position cleaned up [auto backend]
|
||||
- [ ] Virtual device: requires name; room required; renders dashed
|
||||
- [ ] Sub-area rooms (v1.28.0): a room WITHOUT an HA area appears in the marker
|
||||
room list ("no area, manual"); a device placed there lands at its centre,
|
||||
the marker stores room_id, reopening the dialog restores the choice [auto]
|
||||
- [ ] Room override moves the icon to the room center
|
||||
- [ ] Tap-action override select (default/info/more-info/toggle) saves and applies
|
||||
- [ ] PDF/manual upload: ok path; >50 MB → readable error; .exe → bad-ext error [auto backend]; traversal names sanitized [auto backend]
|
||||
|
||||
+15
-5
@@ -12,17 +12,27 @@ A segmented control in the card header with three tabs; the active one is visual
|
||||
highlighted, and edit modes add a colored frame around the stage so the mode is
|
||||
obvious at a glance:
|
||||
|
||||
**[ 👁 View ] [ 📐 Plan ] [ 🔧 Devices ]**
|
||||
**[ 📐 Plan editor ] [ 🔧 Device editor ] [ ✏️ Background editor ]** — View has
|
||||
NO tab (since v1.30.2). The Background editor (v1.33.0) manages a purely visual
|
||||
decor layer (lines/rects/ovals/text in `space.decor`, drawn under the rooms,
|
||||
inert everywhere outside its editor).
|
||||
|
||||
- **View** is the 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** is the implicit default state: no editor tab is active. It is the only
|
||||
state after every load (edit modes are never restored across reloads).
|
||||
- Activating an editor tab highlights it and opens that editor's bottom toolbar
|
||||
(both editors have one since v1.30.2). The toolbar and the active tab each
|
||||
carry an **X** that closes the editor back to View; re-clicking the active tab
|
||||
does nothing; Plan↔Devices switches directly.
|
||||
- **Plan editor** and **Device editor** are shown only to admins when
|
||||
`admin_only` is on.
|
||||
|
||||
## View — display and device interaction only
|
||||
|
||||
Allowed: pan/zoom (wheel, pinch, buttons), switching spaces, device tap
|
||||
(info / more-info / toggle per settings), long-press → info card, opening tap →
|
||||
door/lock info card, room tap → HA area, hover tooltips (name, temperature, signal).
|
||||
door/lock info card (with an explicit Unlock/Lock button when a lock is bound —
|
||||
the only way to operate a lock from the card; plan-icon taps never toggle locks),
|
||||
room tap → HA area, hover tooltips (name, temperature, signal).
|
||||
|
||||
Removed from this mode (they move, not die):
|
||||
- icon dragging ("drag anywhere", v1.9 — consciously reversed),
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.25.0",
|
||||
"version": "1.34.0",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -343,6 +343,43 @@ export function areaLights(hass: any, devices: { area: string; entities: string[
|
||||
return seen ? 'off' : 'none';
|
||||
}
|
||||
|
||||
/** Average humidity across the area's climate-ish devices (integer %, or null). */
|
||||
export function areaHum(
|
||||
hass: any,
|
||||
devices: { area: string; icon?: string; entities: string[] }[],
|
||||
area: string,
|
||||
): number | null {
|
||||
const vals: number[] = [];
|
||||
for (const dv of devices) {
|
||||
if (dv.area !== area) continue;
|
||||
// same curation idea as areaTemp: climate sensors only, not fridges/plugs
|
||||
if (dv.icon !== 'mdi:thermometer' && dv.icon !== 'mdi:air-filter' && dv.icon !== 'mdi:water-percent') continue;
|
||||
const h = humFor(hass, dv.entities);
|
||||
if (h != null) vals.push(h);
|
||||
}
|
||||
if (!vals.length) return null;
|
||||
return Math.round(vals.reduce((a, b) => a + b, 0) / vals.length);
|
||||
}
|
||||
|
||||
/** How many of the area's lights are on: {on, total}, or null without lights. */
|
||||
export function areaLightStats(
|
||||
hass: any,
|
||||
devices: { area: string; entities: string[] }[],
|
||||
area: string,
|
||||
): { on: number; total: number } | null {
|
||||
const seen = new Set<string>();
|
||||
let on = 0;
|
||||
for (const dv of devices) {
|
||||
if (dv.area !== area) continue;
|
||||
for (const eid of dv.entities) {
|
||||
if (!eid.startsWith('light.') || seen.has(eid)) continue;
|
||||
seen.add(eid);
|
||||
if (hass.states[eid]?.state === 'on') on++;
|
||||
}
|
||||
}
|
||||
return seen.size ? { on, total: seen.size } : null;
|
||||
}
|
||||
|
||||
/** Average temperature across the area's devices (null when nothing reports one). */
|
||||
/** Average zigbee signal (LQI) across an area's non-virtual devices, or null. */
|
||||
export function areaLqi(hass: any, devices: { area: string; virtual?: boolean; entities: string[] }[], area: string): number | null {
|
||||
|
||||
+755
-100
File diff suppressed because it is too large
Load Diff
+46
-8
@@ -22,7 +22,6 @@
|
||||
"title.zoom_reset": "Reset zoom",
|
||||
"title.add_device": "Add a device to the plan",
|
||||
"title.show_all": "Show all area devices (no curation)",
|
||||
"title.reset_layout": "Reset icon positions to auto layout",
|
||||
"title.markup": "Room markup: grid, lines, outlines",
|
||||
"title.configure_space": "Configure space",
|
||||
"title.add_space": "Add space",
|
||||
@@ -115,7 +114,6 @@
|
||||
"device.light_group": "light group",
|
||||
"device.fallback": "device",
|
||||
"device.virtual": "virtual device",
|
||||
"confirm.reset_layout": "Reset all icon positions to the auto layout?",
|
||||
"confirm.delete_room": "Delete room \"{name}\"?",
|
||||
"confirm.remove_marker": "Remove \"{name}\" from the plan?",
|
||||
"confirm.delete_space": "Delete space \"{title}\" with all its rooms and markup?",
|
||||
@@ -124,12 +122,11 @@
|
||||
"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",
|
||||
"toast.split_pick_wall": "Start the cut on the room’s wall",
|
||||
"toast.split_bad_cut": "The cut must run wall to wall inside the room, without crossing walls or itself",
|
||||
"merge.header": "Merge rooms",
|
||||
"merge.hint": "The merged room keeps one name and one area. The other area is released — its devices leave the plan until another room claims it.",
|
||||
"merge.keep": "Keep",
|
||||
@@ -240,7 +237,48 @@
|
||||
"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"
|
||||
"mode.plan": "Plan editor",
|
||||
"mode.devices": "Device editor",
|
||||
"display.value": "Value instead of an icon",
|
||||
"marker.subarea": "no area, manual",
|
||||
"device.new": "New device — open its editor to dismiss",
|
||||
"opening.unlock_action": "Unlock",
|
||||
"opening.lock_action": "Lock",
|
||||
"opening.lock_pending": "Working…",
|
||||
"title.close_editor": "Close editor (back to view)",
|
||||
"devbar.add": "Add",
|
||||
"devbar.show_all": "Show all",
|
||||
"devbar.rules": "Icon rules",
|
||||
"space.roomcard_section": "Room card shows:",
|
||||
"space.label_temp": "Temperature",
|
||||
"space.label_hum": "Humidity",
|
||||
"space.label_lqi": "Average Zigbee signal",
|
||||
"space.label_light": "Lights on/off",
|
||||
"roomcard.light_on": "On",
|
||||
"roomcard.light_off": "Off",
|
||||
"roomcard.light_partial": "{on} of {total}",
|
||||
"toast.split_pick_inside": "Intermediate cut points must be inside the room",
|
||||
"mode.decor": "Background editor",
|
||||
"decor.select": "Select",
|
||||
"decor.line": "Line",
|
||||
"decor.rect": "Rectangle",
|
||||
"decor.ellipse": "Oval",
|
||||
"decor.text": "Text",
|
||||
"decor.erase": "Erase",
|
||||
"decor.color": "Color",
|
||||
"decor.width": "Line width",
|
||||
"decor.w_thin": "Thin",
|
||||
"decor.w_mid": "Medium",
|
||||
"decor.w_thick": "Thick",
|
||||
"decor.fill": "Fill",
|
||||
"decor.text_title": "Text label",
|
||||
"decor.text_label": "Text",
|
||||
"decor.text_size": "Size",
|
||||
"decor.size_s": "Small",
|
||||
"decor.size_m": "Medium",
|
||||
"decor.size_l": "Large",
|
||||
"marker.icon_auto": "Auto: {icon} (by icon rules; pick one to override)",
|
||||
"mode.plan_tip": "Plan editor — the geometry of the home: draw and split/merge rooms, bind them to HA areas, place doors and windows, move room cards, set the scale",
|
||||
"mode.devices_tip": "Device editor — everything about icons: drag to position, click to edit binding/icon/display, add virtual devices, icon rules",
|
||||
"mode.decor_tip": "Background editor — purely visual decor under the plan: lines, rectangles, ovals and text labels that never react to clicks"
|
||||
}
|
||||
|
||||
+46
-8
@@ -22,7 +22,6 @@
|
||||
"title.zoom_reset": "Сбросить масштаб",
|
||||
"title.add_device": "Добавить устройство на план",
|
||||
"title.show_all": "Показывать все устройства зоны (без курирования)",
|
||||
"title.reset_layout": "Сбросить позиции значков к авто-раскладке",
|
||||
"title.markup": "Разметка комнат: сетка, линии, контуры",
|
||||
"title.configure_space": "Настроить пространство",
|
||||
"title.add_space": "Добавить пространство",
|
||||
@@ -115,7 +114,6 @@
|
||||
"device.light_group": "группа света",
|
||||
"device.fallback": "устройство",
|
||||
"device.virtual": "виртуальное устройство",
|
||||
"confirm.reset_layout": "Сбросить позиции всех иконок к авто-раскладке?",
|
||||
"confirm.delete_room": "Удалить комнату «{name}»?",
|
||||
"confirm.remove_marker": "Убрать «{name}» с плана?",
|
||||
"confirm.delete_space": "Удалить пространство «{title}» со всеми комнатами и разметкой?",
|
||||
@@ -124,12 +122,11 @@
|
||||
"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": "Разрез — прямая от стены до стены внутри комнаты",
|
||||
"toast.split_pick_wall": "Начните разрез на стене комнаты",
|
||||
"toast.split_bad_cut": "Разрез — от стены до стены внутри комнаты, без пересечения стен и самого себя",
|
||||
"merge.header": "Объединение комнат",
|
||||
"merge.hint": "У объединённой комнаты одно имя и одна зона. Вторая зона освобождается — её устройства уйдут с плана, пока их не заберёт другая комната.",
|
||||
"merge.keep": "Оставить",
|
||||
@@ -240,7 +237,48 @@
|
||||
"gs.saved": "Общие настройки сохранены",
|
||||
"space.show_lqi": "Показывать зигби-сигнал (LQI) у устройств",
|
||||
"gs.light_none": "Нет источников света",
|
||||
"mode.view": "Просмотр",
|
||||
"mode.plan": "План",
|
||||
"mode.devices": "Устройства"
|
||||
"mode.plan": "Редактор плана",
|
||||
"mode.devices": "Редактор устройств",
|
||||
"display.value": "Значение вместо иконки",
|
||||
"marker.subarea": "без зоны, вручную",
|
||||
"device.new": "Новое устройство — откройте его редактор, чтобы снять отметку",
|
||||
"opening.unlock_action": "Открыть замок",
|
||||
"opening.lock_action": "Закрыть замок",
|
||||
"opening.lock_pending": "Выполняется…",
|
||||
"title.close_editor": "Закрыть редактор (вернуться к просмотру)",
|
||||
"devbar.add": "Добавить",
|
||||
"devbar.show_all": "Показать все",
|
||||
"devbar.rules": "Правила иконок",
|
||||
"space.roomcard_section": "В карточке комнаты:",
|
||||
"space.label_temp": "Температура",
|
||||
"space.label_hum": "Влажность",
|
||||
"space.label_lqi": "Средний Zigbee-сигнал",
|
||||
"space.label_light": "Свет вкл/выкл",
|
||||
"roomcard.light_on": "Вкл",
|
||||
"roomcard.light_off": "Выкл",
|
||||
"roomcard.light_partial": "{on} из {total}",
|
||||
"toast.split_pick_inside": "Промежуточные точки разреза — внутри комнаты",
|
||||
"mode.decor": "Редактор подложки",
|
||||
"decor.select": "Выбрать",
|
||||
"decor.line": "Линия",
|
||||
"decor.rect": "Прямоугольник",
|
||||
"decor.ellipse": "Овал",
|
||||
"decor.text": "Надпись",
|
||||
"decor.erase": "Стереть",
|
||||
"decor.color": "Цвет",
|
||||
"decor.width": "Толщина линии",
|
||||
"decor.w_thin": "Тонкая",
|
||||
"decor.w_mid": "Средняя",
|
||||
"decor.w_thick": "Толстая",
|
||||
"decor.fill": "Залить",
|
||||
"decor.text_title": "Надпись",
|
||||
"decor.text_label": "Текст",
|
||||
"decor.text_size": "Размер",
|
||||
"decor.size_s": "Мелкий",
|
||||
"decor.size_m": "Средний",
|
||||
"decor.size_l": "Крупный",
|
||||
"marker.icon_auto": "Авто: {icon} (по правилам иконок; выберите свою, чтобы заменить)",
|
||||
"mode.plan_tip": "Редактор плана — геометрия дома: рисование и объединение/разделение комнат, привязка к зонам HA, двери и окна, карточки комнат, масштаб",
|
||||
"mode.devices_tip": "Редактор устройств — всё про значки: перетаскивание, клик — настройка привязки/иконки/отображения, виртуальные устройства, правила иконок",
|
||||
"mode.decor_tip": "Редактор подложки — чисто визуальный декор под планом: линии, прямоугольники, овалы и надписи, не реагирующие на клики"
|
||||
}
|
||||
|
||||
+174
-14
@@ -241,19 +241,47 @@ function coversArea(a: number[][], b: number[][], eps: number): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Is `inner` fully contained in `outer` (edges may touch, never cross)? */
|
||||
export function polyContainsPoly(outer: number[][], inner: number[][], eps = 1e-6): boolean {
|
||||
if (!outer || !inner || outer.length < 3 || inner.length < 3) return false;
|
||||
for (let i = 0; i < inner.length; i++)
|
||||
for (let j = 0; j < outer.length; j++)
|
||||
if (segmentsProperlyCross(inner[i], inner[(i + 1) % inner.length], outer[j], outer[(j + 1) % outer.length]))
|
||||
return false;
|
||||
for (const v of inner)
|
||||
if (!pointStrictlyInside(v, outer, eps) && !pointOnBoundary(v, outer, eps)) return false;
|
||||
// identical/traced outlines are NOT containment — probe the centroid strictness both ways
|
||||
const c = [
|
||||
inner.reduce((s, p) => s + p[0], 0) / inner.length,
|
||||
inner.reduce((s, p) => s + p[1], 0) / inner.length,
|
||||
];
|
||||
return pointStrictlyInside(c, outer, eps) && polygonArea(inner) < polygonArea(outer) - eps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do two room outlines 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.
|
||||
* Do two room outlines ILLEGALLY share floor area? Sharing a wall (fully or
|
||||
* partially) and touching at a corner are normal. Since v1.34.0 full nesting is
|
||||
* legal too (island rooms: a column inside a ring, an inner room) — only edge
|
||||
* crossings and PARTIAL overlaps are rejected.
|
||||
*/
|
||||
export function roomsOverlap(a: number[][], b: number[][], eps = 1e-6): boolean {
|
||||
if (!a || !b || a.length < 3 || b.length < 3) return false;
|
||||
for (let i = 0; i < a.length; i++)
|
||||
for (let j = 0; j < b.length; j++)
|
||||
if (segmentsProperlyCross(a[i], a[(i + 1) % a.length], b[j], b[(j + 1) % b.length])) return true;
|
||||
if (polyContainsPoly(a, b, eps) || polyContainsPoly(b, a, eps)) return false;
|
||||
return coversArea(a, b, eps) || coversArea(b, a, eps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct islands of `poly` among `others`: outlines fully inside it that are not
|
||||
* themselves inside a bigger island (those are subtracted by their parent).
|
||||
*/
|
||||
export function islandsOf(poly: number[][], others: number[][][], eps = 1e-6): number[][][] {
|
||||
const inside = others.filter((o) => polyContainsPoly(poly, o, eps));
|
||||
return inside.filter((o) => !inside.some((p) => p !== o && polyContainsPoly(p, o, eps)));
|
||||
}
|
||||
|
||||
/** Shoelace area of an outline (absolute value). */
|
||||
export function polygonArea(poly: number[][]): number {
|
||||
if (!poly || poly.length < 3) return 0;
|
||||
@@ -310,27 +338,50 @@ function dropRepeats(pts: number[][], eps: number): number[][] {
|
||||
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;
|
||||
return splitRoomPath(poly, [a, b], eps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a room along a polyline: first and last points on walls, intermediate
|
||||
* vertices strictly inside the room. A two-point path is the classic straight
|
||||
* chord. Returns the two parts, or null when the path is not a clean cut.
|
||||
*/
|
||||
export function splitRoomPath(
|
||||
poly: number[][], pts: number[][], eps = 1e-6,
|
||||
): [number[][], number[][]] | null {
|
||||
if (!poly || poly.length < 3 || !pts || pts.length < 2) return null;
|
||||
const a = pts[0];
|
||||
const b = pts[pts.length - 1];
|
||||
if (samePoint(a, b, eps)) return null;
|
||||
const ia = edgeIndexOf(poly, a, eps);
|
||||
const ib = edgeIndexOf(poly, b, eps);
|
||||
if (ia < 0 || ib < 0) return null; // an end is not on a wall
|
||||
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 mids = pts.slice(1, -1);
|
||||
for (const m of mids) if (!pointStrictlyInside(m, poly, eps)) return null;
|
||||
// no path segment may cross a wall
|
||||
for (let sI = 0; sI < pts.length - 1; sI++)
|
||||
for (let i = 0; i < poly.length; i++)
|
||||
if (segmentsProperlyCross(pts[sI], pts[sI + 1], poly[i], poly[(i + 1) % poly.length])) return null;
|
||||
// the path may not properly self-intersect
|
||||
for (let sI = 0; sI < pts.length - 1; sI++)
|
||||
for (let t = sI + 2; t < pts.length - 1; t++)
|
||||
if (segmentsProperlyCross(pts[sI], pts[sI + 1], pts[t], pts[t + 1])) return null;
|
||||
// a straight chord lying along a wall has its midpoint ON the outline, not inside
|
||||
if (pts.length === 2 && !pointStrictlyInside([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], poly, eps))
|
||||
return null;
|
||||
const walk = (from: number[], fromIdx: number, to: number[], toIdx: number): number[][] => {
|
||||
const pts: number[][] = [from];
|
||||
const acc: number[][] = [from];
|
||||
let i = (fromIdx + 1) % poly.length;
|
||||
for (let guard = 0; guard <= poly.length; guard++) {
|
||||
pts.push(poly[i]);
|
||||
acc.push(poly[i]);
|
||||
if (i === toIdx) break;
|
||||
i = (i + 1) % poly.length;
|
||||
}
|
||||
pts.push(to);
|
||||
return dropRepeats(pts, eps);
|
||||
acc.push(to);
|
||||
return dropRepeats(acc, eps);
|
||||
};
|
||||
const p1 = walk(a, ia, b, ib);
|
||||
const p2 = walk(b, ib, a, ia);
|
||||
const p1 = dropRepeats([...walk(a, ia, b, ib), ...[...mids].reverse()], eps);
|
||||
const p2 = dropRepeats([...walk(b, ib, a, ia), ...mids], eps);
|
||||
if (p1.length < 3 || p2.length < 3) return null;
|
||||
if (polygonArea(p1) <= eps || polygonArea(p2) <= eps) return null;
|
||||
return [p1, p2];
|
||||
@@ -497,6 +548,11 @@ export interface SpaceDisplay {
|
||||
tempMax: number; // comfort range upper bound, °C
|
||||
/** Per-space LQI badges near zigbee devices; null = follow the card option. */
|
||||
showLqi: boolean | null;
|
||||
/** Room-card metrics under the room name (all default off). */
|
||||
labelTemp: boolean;
|
||||
labelHum: boolean;
|
||||
labelLqi: boolean;
|
||||
labelLight: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_ROOM_COLOR = '#3ea6ff';
|
||||
@@ -517,6 +573,10 @@ export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
|
||||
tempMin: typeof s.temp_min === 'number' ? s.temp_min : DEFAULT_TEMP_MIN,
|
||||
tempMax: typeof s.temp_max === 'number' ? s.temp_max : DEFAULT_TEMP_MAX,
|
||||
showLqi: typeof s.show_lqi === 'boolean' ? s.show_lqi : null,
|
||||
labelTemp: s.label_temp === true,
|
||||
labelHum: s.label_hum === true,
|
||||
labelLqi: s.label_lqi === true,
|
||||
labelLight: s.label_light === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -649,3 +709,103 @@ export function roomFillColor(
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------- state-reflecting icons ----------------
|
||||
|
||||
/**
|
||||
* Swap the auto icon for a state variant (open door, unlocked lock…), like core
|
||||
* HA does. Conservative: only well-known pairs, only when the user has NOT set
|
||||
* a custom icon, and unknown/unavailable states keep the base icon.
|
||||
*/
|
||||
export function stateIcon(
|
||||
base: string,
|
||||
domain: string | null | undefined,
|
||||
deviceClass: string | null | undefined,
|
||||
state: string | null | undefined,
|
||||
hasCustomIcon: boolean,
|
||||
): string {
|
||||
if (hasCustomIcon || !state || state === 'unavailable' || state === 'unknown') return base;
|
||||
if (domain === 'binary_sensor') {
|
||||
if (deviceClass === 'door') return state === 'on' ? 'mdi:door-open' : 'mdi:door-closed';
|
||||
if (deviceClass === 'window') return state === 'on' ? 'mdi:window-open' : 'mdi:window-closed';
|
||||
if (deviceClass === 'garage_door') return state === 'on' ? 'mdi:garage-open-variant' : 'mdi:garage-variant';
|
||||
}
|
||||
if (domain === 'lock') return state === 'locked' ? 'mdi:lock' : 'mdi:lock-open-variant';
|
||||
if (domain === 'light' && base === 'mdi:lightbulb') return state === 'on' ? 'mdi:lightbulb-on' : base;
|
||||
return base;
|
||||
}
|
||||
|
||||
// ---------------- light color & alarm states ----------------
|
||||
|
||||
/**
|
||||
* The current color of a light entity as a CSS color, or null when it is off,
|
||||
* unavailable or reports no usable color. rgb_color is the source of truth
|
||||
* (HA normalizes hs/xy into it); brightness is deliberately ignored — a dim
|
||||
* red bulb should still read as red on the plan.
|
||||
*/
|
||||
export function lightColorOf(state: any): string | null {
|
||||
if (!state || state.state !== 'on') return null;
|
||||
const rgb = state.attributes?.rgb_color;
|
||||
if (Array.isArray(rgb) && rgb.length >= 3 && rgb.every((v: any) => Number.isFinite(v))) {
|
||||
return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Device classes whose active state is an emergency, not a status. */
|
||||
const ALARM_CLASSES = new Set(['smoke', 'gas', 'carbon_monoxide', 'moisture', 'safety', 'tamper', 'problem']);
|
||||
|
||||
/**
|
||||
* An alarm is firing: leak/smoke/gas/CO/safety binary sensors in `on`, or a
|
||||
* siren that is on. Unavailable/unknown never alarm (an outage is not a fire).
|
||||
*/
|
||||
export function isAlarmState(
|
||||
domain: string | null | undefined,
|
||||
deviceClass: string | null | undefined,
|
||||
state: string | null | undefined,
|
||||
): boolean {
|
||||
if (state !== 'on') return false;
|
||||
if (domain === 'siren') return true;
|
||||
return domain === 'binary_sensor' && !!deviceClass && ALARM_CLASSES.has(deviceClass);
|
||||
}
|
||||
|
||||
// ---------------- room references ----------------
|
||||
|
||||
/**
|
||||
* Parse a marker's room reference. Two shapes:
|
||||
* - `space#area` — a room bound to an HA area (historical form)
|
||||
* - `space#@roomId` — a room WITHOUT an area (sub-area rooms, issue #3):
|
||||
* devices are placed into it manually, by room id.
|
||||
*/
|
||||
export function parseRoomRef(
|
||||
v: string | null | undefined,
|
||||
): { space: string; area: string | null; roomId: string | null } | null {
|
||||
if (!v) return null;
|
||||
const i = v.indexOf('#');
|
||||
if (i <= 0) return null;
|
||||
const space = v.slice(0, i);
|
||||
const rest = v.slice(i + 1);
|
||||
if (!rest) return null;
|
||||
if (rest.startsWith('@')) {
|
||||
const roomId = rest.slice(1);
|
||||
return roomId ? { space, area: null, roomId } : null;
|
||||
}
|
||||
return { space, area: rest, roomId: null };
|
||||
}
|
||||
|
||||
// ---------------- new-device detection ----------------
|
||||
|
||||
/**
|
||||
* Which auto-appearing device ids are NEW against the known baseline.
|
||||
* No baseline yet (first run / upgrade) → nothing is new: every current id
|
||||
* becomes the baseline silently, so an update never floods the plan with dots.
|
||||
*/
|
||||
export function diffNewDevices(
|
||||
currentIds: string[],
|
||||
known: string[] | null | undefined,
|
||||
): { fresh: string[]; known: string[] } {
|
||||
if (!Array.isArray(known)) return { fresh: [], known: [...currentIds] };
|
||||
const knownSet = new Set(known);
|
||||
const fresh = currentIds.filter((id) => !knownSet.has(id));
|
||||
return { fresh, known: fresh.length ? [...known, ...fresh] : known };
|
||||
}
|
||||
|
||||
+216
-19
@@ -80,6 +80,8 @@ export const cardStyles = css`
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--hp-muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 13px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
@@ -242,22 +244,26 @@ export const cardStyles = css`
|
||||
transition: opacity 0.15s;
|
||||
pointer-events: none;
|
||||
}
|
||||
g.opening:hover .op-outline {
|
||||
.stage.markup g.opening:hover .op-outline {
|
||||
opacity: 0.9;
|
||||
}
|
||||
/* openings are pure status graphics outside Plan mode: no cursor, no hover,
|
||||
no hit target — View must not interact with them at all */
|
||||
.op-hit {
|
||||
fill: transparent;
|
||||
cursor: grab;
|
||||
pointer-events: auto;
|
||||
touch-action: none; /* drags, not scrolls, on touch */
|
||||
}
|
||||
.op-hit:active {
|
||||
cursor: grabbing;
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
.stage.markup .op-hit {
|
||||
pointer-events: none; /* markup clicks go to the stage tools */
|
||||
pointer-events: auto;
|
||||
cursor: grab;
|
||||
touch-action: none; /* drags, not scrolls, on touch */
|
||||
}
|
||||
.stage.markup .op-hit:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.oplock {
|
||||
pointer-events: none; /* inert while editing; clickable in View (rule below) */
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
width: calc(var(--icon-size, 2.5cqw) * 0.62);
|
||||
@@ -268,9 +274,11 @@ export const cardStyles = css`
|
||||
justify-content: center;
|
||||
background: var(--hp-bg);
|
||||
border: 1px solid var(--hp-line);
|
||||
z-index: 1;
|
||||
}
|
||||
.stage.mode-view .oplock {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
}
|
||||
.oplock ha-icon {
|
||||
--mdc-icon-size: calc(var(--icon-size, 2.5cqw) * 0.4);
|
||||
@@ -280,6 +288,18 @@ export const cardStyles = css`
|
||||
.oplock.locked { color: #66d17a; border-color: #66d17a; }
|
||||
.oplock.unlocked { color: var(--hp-open); border-color: var(--hp-open); }
|
||||
.oplock.unknown { color: var(--hp-muted); }
|
||||
.btn.lockact {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.btn.lockact.warn {
|
||||
color: var(--error-color, #d33);
|
||||
border-color: var(--error-color, #d33);
|
||||
}
|
||||
.oprow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -344,14 +364,61 @@ export const cardStyles = css`
|
||||
pointer-events: none; /* draggable only in plan mode (rule below) */
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: calc(var(--icon-size, 2.5cqw) * 0.5);
|
||||
font-size: calc(var(--icon-size, 2.5cqw) * 0.5 * var(--rl-scale, 1));
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.15em;
|
||||
text-align: center;
|
||||
}
|
||||
.roomlabel .rlmetrics {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55em;
|
||||
font-size: 0.62em;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.roomlabel .rlm {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.12em;
|
||||
}
|
||||
.roomlabel .rlm ha-icon {
|
||||
--mdc-icon-size: 1.05em;
|
||||
display: inline-flex;
|
||||
}
|
||||
.roomlabel .rlm.lit { opacity: 1; }
|
||||
.iconauto {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
.iconauto ha-icon { --mdc-icon-size: 18px; }
|
||||
.rlhandle {
|
||||
display: none;
|
||||
position: absolute;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 2px;
|
||||
background: var(--hp-accent);
|
||||
border: 1px solid var(--card-background-color, #fff);
|
||||
z-index: 2;
|
||||
}
|
||||
.rlhandle.tl { left: -6px; top: -6px; cursor: nwse-resize; }
|
||||
.rlhandle.br { right: -6px; bottom: -6px; cursor: nwse-resize; }
|
||||
.rlhandle.tr { right: -6px; top: -6px; cursor: nesw-resize; }
|
||||
.rlhandle.bl { left: -6px; bottom: -6px; cursor: nesw-resize; }
|
||||
.stage.markup .roomlabel:hover .rlhandle { display: block; }
|
||||
.stage.markup .roomlabel { pointer-events: auto; }
|
||||
.roomlabel:active { cursor: grabbing; }
|
||||
.measurelayer {
|
||||
@@ -373,6 +440,68 @@ export const cardStyles = css`
|
||||
user-select: none;
|
||||
z-index: 3;
|
||||
}
|
||||
/* decor (background) layer */
|
||||
.decorlayer .dshape { pointer-events: none; }
|
||||
.stage.mode-decor .decorlayer .dshape {
|
||||
pointer-events: visiblePainted;
|
||||
cursor: pointer;
|
||||
}
|
||||
.stage.mode-decor.dtool-select .decorlayer .dshape { cursor: move; }
|
||||
.decorlayer .dsel {
|
||||
filter: drop-shadow(0 0 3px var(--hp-accent));
|
||||
}
|
||||
.decorlayer .ddraft {
|
||||
opacity: 0.75;
|
||||
stroke-dasharray: 6 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.decorlayer text {
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
dominant-baseline: middle;
|
||||
text-anchor: middle;
|
||||
}
|
||||
.stage.mode-decor {
|
||||
outline: 2px solid #26a69a;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.stage.mode-decor.dtool-line, .stage.mode-decor.dtool-rect,
|
||||
.stage.mode-decor.dtool-ellipse, .stage.mode-decor.dtool-text {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.stage.mode-decor .room, .stage.mode-decor .devlayer { pointer-events: none; }
|
||||
.stage.mode-decor .oplock { pointer-events: none; }
|
||||
/* decor mode: everything but the decor itself fades back */
|
||||
.stage.mode-decor .room,
|
||||
.stage.mode-decor .devlayer,
|
||||
.stage.mode-decor .opening,
|
||||
.stage.mode-decor .rlabel {
|
||||
opacity: 0.35;
|
||||
}
|
||||
.decorbar .dcolor {
|
||||
width: 30px; height: 26px; padding: 0; border: none; background: none; cursor: pointer;
|
||||
}
|
||||
.decorbar .dwidth {
|
||||
font-family: inherit; font-size: 12px; border-radius: 6px;
|
||||
background: var(--hp-bg2, transparent); color: var(--hp-txt); border: 1px solid var(--hp-muted);
|
||||
padding: 3px 5px;
|
||||
}
|
||||
.decorbar .dfill {
|
||||
display: inline-flex; align-items: center; gap: 4px; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.opghost {
|
||||
stroke: var(--hp-open, #ff9800);
|
||||
stroke-width: 5;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 7 6;
|
||||
opacity: 0.85;
|
||||
pointer-events: none;
|
||||
}
|
||||
.opghost-dot {
|
||||
fill: var(--hp-open, #ff9800);
|
||||
opacity: 0.85;
|
||||
pointer-events: none;
|
||||
}
|
||||
.rlabel {
|
||||
fill: var(--hp-muted);
|
||||
font-size: 15px;
|
||||
@@ -386,6 +515,12 @@ export const cardStyles = css`
|
||||
.stage.markup {
|
||||
cursor: crosshair;
|
||||
}
|
||||
/* room-picking stages: merge (both clicks) and split before a room is chosen */
|
||||
.stage.markup.tool-merge,
|
||||
.stage.markup.tool-split.pickstage,
|
||||
.stage.markup.tool-delroom {
|
||||
cursor: pointer;
|
||||
}
|
||||
.stage.markup .room {
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -423,6 +558,20 @@ export const cardStyles = css`
|
||||
font-family: inherit;
|
||||
}
|
||||
.modetab ha-icon { --mdc-icon-size: 15px; }
|
||||
.modetab .closex {
|
||||
--mdc-icon-size: 13px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 2px;
|
||||
opacity: 0.75;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.modetab .closex:hover { opacity: 1; }
|
||||
.editbar .barclose {
|
||||
padding: 4px 6px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.modetab.active {
|
||||
background: var(--hp-accent);
|
||||
color: var(--text-primary-color, #fff);
|
||||
@@ -430,15 +579,16 @@ export const cardStyles = css`
|
||||
@media (max-width: 720px) {
|
||||
.modetab .ml { display: none; }
|
||||
}
|
||||
.room.outlined {
|
||||
stroke: rgba(62, 166, 255, 0.55);
|
||||
fill: rgba(62, 166, 255, 0.06);
|
||||
}
|
||||
/* AFTER .outlined: same specificity — source order decides (gotcha x4) */
|
||||
.room.picked {
|
||||
stroke: #ffc14d;
|
||||
stroke-width: 3;
|
||||
fill: rgba(255, 193, 77, 0.25);
|
||||
}
|
||||
.room.outlined {
|
||||
stroke: rgba(62, 166, 255, 0.55);
|
||||
fill: rgba(62, 166, 255, 0.06);
|
||||
}
|
||||
.griddot {
|
||||
fill: var(--hp-accent);
|
||||
opacity: 0.75;
|
||||
@@ -485,6 +635,53 @@ export const cardStyles = css`
|
||||
.namein {
|
||||
width: 130px;
|
||||
}
|
||||
.dev.valonly {
|
||||
width: auto;
|
||||
min-width: var(--dev-size, var(--icon-size, 2.5cqw));
|
||||
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.16);
|
||||
}
|
||||
.dev.valonly .valtext {
|
||||
font-size: calc(var(--icon-size, 2.5cqw) * 0.45);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* RGB lights: the bulb takes the light's actual color */
|
||||
.dev.rgb ha-icon { color: var(--light-color); }
|
||||
.dev.rgb.on {
|
||||
box-shadow: 0 0 10px var(--light-color);
|
||||
border-color: var(--light-color);
|
||||
background: var(--hp-bg);
|
||||
color: var(--light-color);
|
||||
}
|
||||
/* alarms pulse red over everything */
|
||||
.dev.alarm::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: calc(var(--icon-size, 2.5cqw) * -0.35);
|
||||
border: 3px solid #f25a4a;
|
||||
border-radius: 50%;
|
||||
animation: hp-alarm 1s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes hp-alarm {
|
||||
0% { transform: scale(0.7); opacity: 1; }
|
||||
100% { transform: scale(1.25); opacity: 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dev.alarm::after { animation: none; opacity: 0.9; }
|
||||
}
|
||||
.dev .newdot {
|
||||
position: absolute;
|
||||
top: calc(var(--icon-size, 2.5cqw) * -0.12);
|
||||
right: calc(var(--icon-size, 2.5cqw) * -0.12);
|
||||
width: calc(var(--icon-size, 2.5cqw) * 0.34);
|
||||
height: calc(var(--icon-size, 2.5cqw) * 0.34);
|
||||
border-radius: 50%;
|
||||
background: #f0301f;
|
||||
border: 2px solid var(--card-background-color, var(--hp-bg));
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.devlayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -505,7 +702,7 @@ export const cardStyles = css`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--hp-txt);
|
||||
cursor: grab;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: background 0.15s, border-color 0.15s, opacity 0.2s;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45);
|
||||
@@ -518,9 +715,8 @@ export const cardStyles = css`
|
||||
justify-content: center;
|
||||
line-height: 0;
|
||||
}
|
||||
.dev:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.stage.mode-devices .dev { cursor: grab; }
|
||||
.stage.mode-devices .dev:active { cursor: grabbing; }
|
||||
.dev:hover {
|
||||
background: var(--hp-accent);
|
||||
color: var(--text-primary-color, #fff);
|
||||
@@ -606,9 +802,10 @@ export const cardStyles = css`
|
||||
}
|
||||
.tab .tabedit {
|
||||
--mdc-icon-size: 13px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 6px;
|
||||
opacity: 0.4;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.tab:hover .tabedit {
|
||||
opacity: 0.9;
|
||||
|
||||
+2
-1
@@ -38,7 +38,8 @@ export interface Marker {
|
||||
description?: string | null;
|
||||
pdfs?: PdfRef[];
|
||||
tap_action?: string | null; // per-device override: 'info' | 'more-info' | 'toggle'
|
||||
display?: 'badge' | 'ripple' | 'icon_ripple' | null; // how the device is drawn
|
||||
room_id?: string | null; // manual placement into a room WITHOUT an HA area (sub-area rooms)
|
||||
display?: 'badge' | 'ripple' | 'icon_ripple' | 'value' | null; // how the device is drawn
|
||||
ripple_color?: string | null;
|
||||
ripple_size?: number | null; // max ring diameter, in icon diameters (default 3)
|
||||
size?: number | null; // icon size multiplier (default 1)
|
||||
|
||||
+30
-1
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp } from '../test-build/devices.js';
|
||||
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats } from '../test-build/devices.js';
|
||||
import { compileIconRules } from '../test-build/rules.js';
|
||||
|
||||
/** Minimal fake hass around the pieces buildDevices reads. */
|
||||
@@ -280,3 +280,32 @@ test('buildDevices: humidity badge is gated on device_class, not the icon (name
|
||||
assert.notEqual(d.icon, 'mdi:water-percent'); // name rule won the icon
|
||||
assert.equal(d.hum, 45); // …but the humidity value is still shown (gated on device_class)
|
||||
});
|
||||
|
||||
test('areaLightStats: counts unique lights that are on', () => {
|
||||
const hass = { states: {
|
||||
'light.a': { state: 'on' }, 'light.b': { state: 'off' }, 'light.c': { state: 'on' },
|
||||
} };
|
||||
const devs = [
|
||||
{ area: 'living', entities: ['light.a', 'sensor.x'] },
|
||||
{ area: 'living', entities: ['light.b', 'light.a'] }, // light.a duplicated on purpose
|
||||
{ area: 'other', entities: ['light.c'] },
|
||||
];
|
||||
assert.deepEqual(areaLightStats(hass, devs, 'living'), { on: 1, total: 2 });
|
||||
assert.deepEqual(areaLightStats(hass, devs, 'other'), { on: 1, total: 1 });
|
||||
assert.equal(areaLightStats(hass, devs, 'empty'), null);
|
||||
});
|
||||
|
||||
test('areaHum: averages climate sensors only, integer %', () => {
|
||||
const hass = { states: {
|
||||
'sensor.t1_humidity': { state: '41', attributes: { device_class: 'humidity', unit_of_measurement: '%' } },
|
||||
'sensor.t2_humidity': { state: '46', attributes: { device_class: 'humidity', unit_of_measurement: '%' } },
|
||||
'sensor.fridge_humidity': { state: '90', attributes: { device_class: 'humidity', unit_of_measurement: '%' } },
|
||||
} };
|
||||
const devs = [
|
||||
{ area: 'living', icon: 'mdi:thermometer', entities: ['sensor.t1_humidity'] },
|
||||
{ area: 'living', icon: 'mdi:air-filter', entities: ['sensor.t2_humidity'] },
|
||||
{ area: 'living', icon: 'mdi:fridge', entities: ['sensor.fridge_humidity'] }, // curated out
|
||||
];
|
||||
assert.equal(areaHum(hass, devs, 'living'), 44);
|
||||
assert.equal(areaHum(hass, devs, 'nothing'), null);
|
||||
});
|
||||
|
||||
+121
-6
@@ -3,8 +3,9 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
|
||||
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
|
||||
splitRoomPath, polyContainsPoly, islandsOf,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle,
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
|
||||
} from '../test-build/logic.js';
|
||||
import {
|
||||
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
||||
@@ -324,14 +325,32 @@ test('roomsOverlap: sharing a wall or a corner is legal; real overlap is not', (
|
||||
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));
|
||||
test('roomsOverlap v1.34: nesting is legal (islands), duplicates and crossings are not', () => {
|
||||
// nested & enclosing — legal since island rooms
|
||||
assert.ok(!roomsOverlap(SQ, [[0.5, 0.5], [1.5, 0.5], [1.5, 1.5], [0.5, 1.5]]));
|
||||
assert.ok(!roomsOverlap([[-1, -1], [3, -1], [3, 3], [-1, 3]], SQ));
|
||||
// a duplicate outline is still an overlap
|
||||
assert.ok(roomsOverlap(SQ, SQ));
|
||||
// a cross: no vertex of either lies inside the other, but the edges cross
|
||||
assert.ok(roomsOverlap([[0, 0.5], [3, 0.5], [3, 1.5], [0, 1.5]],
|
||||
[[0.5, -1], [1.5, -1], [1.5, 3], [0.5, 3]]));
|
||||
// genuine partial overlap is still rejected
|
||||
assert.ok(roomsOverlap(SQ, [[1, 1], [3, 1], [3, 3], [1, 3]]));
|
||||
});
|
||||
|
||||
test('polyContainsPoly & islandsOf', () => {
|
||||
const outer = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
const col = [[4, 4], [6, 4], [6, 6], [4, 6]];
|
||||
const tiny = [[4.5, 4.5], [5, 4.5], [5, 5], [4.5, 5]]; // внутри col
|
||||
const partial = [[8, 8], [12, 8], [12, 12], [8, 12]];
|
||||
assert.ok(polyContainsPoly(outer, col));
|
||||
assert.ok(!polyContainsPoly(col, outer));
|
||||
assert.ok(!polyContainsPoly(outer, outer)); // дубликат — не вложенность
|
||||
assert.ok(!polyContainsPoly(outer, partial));
|
||||
// прямые острова: col — да; tiny — нет (он остров col, не outer)
|
||||
const isl = islandsOf(outer, [col, tiny, partial]);
|
||||
assert.deepEqual(isl, [col]);
|
||||
assert.deepEqual(islandsOf(col, [tiny]), [tiny]);
|
||||
});
|
||||
|
||||
test('roomPoly: polygon rooms as-is, legacy rect rooms as four corners', () => {
|
||||
@@ -483,3 +502,99 @@ test('roomFillStyle light_none: alpha 0 keeps no-fill; a custom color fills ligh
|
||||
const c = fillColorsOf({ fill_colors: { light_none: { c: '#123456', a: 0.2 } } });
|
||||
assert.deepEqual(roomFillStyle('light', null, 'none', null, 20, 25, c), { c: '#123456', a: 0.2 });
|
||||
});
|
||||
|
||||
test('stateIcon: doors/locks/bulbs reflect state; custom icons and outages never morph', () => {
|
||||
assert.equal(stateIcon('mdi:door', 'binary_sensor', 'door', 'on', false), 'mdi:door-open');
|
||||
assert.equal(stateIcon('mdi:door', 'binary_sensor', 'door', 'off', false), 'mdi:door-closed');
|
||||
assert.equal(stateIcon('mdi:window-closed', 'binary_sensor', 'window', 'on', false), 'mdi:window-open');
|
||||
assert.equal(stateIcon('mdi:garage-variant', 'binary_sensor', 'garage_door', 'on', false), 'mdi:garage-open-variant');
|
||||
assert.equal(stateIcon('mdi:lock', 'lock', undefined, 'unlocked', false), 'mdi:lock-open-variant');
|
||||
assert.equal(stateIcon('mdi:lightbulb', 'light', undefined, 'on', false), 'mdi:lightbulb-on');
|
||||
assert.equal(stateIcon('mdi:lightbulb', 'light', undefined, 'off', false), 'mdi:lightbulb');
|
||||
assert.equal(stateIcon('mdi:door', 'binary_sensor', 'door', 'unavailable', false), 'mdi:door');
|
||||
assert.equal(stateIcon('mdi:custom', 'lock', undefined, 'unlocked', true), 'mdi:custom'); // user icon wins
|
||||
assert.equal(stateIcon('mdi:cctv', 'camera', undefined, 'recording', false), 'mdi:cctv'); // unknown pair
|
||||
});
|
||||
|
||||
test('lightColorOf: rgb of an on light; off/unavailable/no-color → null', () => {
|
||||
assert.equal(lightColorOf({ state: 'on', attributes: { rgb_color: [255, 20, 40] } }), 'rgb(255, 20, 40)');
|
||||
assert.equal(lightColorOf({ state: 'off', attributes: { rgb_color: [255, 20, 40] } }), null);
|
||||
assert.equal(lightColorOf({ state: 'on', attributes: {} }), null);
|
||||
assert.equal(lightColorOf({ state: 'unavailable', attributes: { rgb_color: [1, 2, 3] } }), null);
|
||||
assert.equal(lightColorOf(undefined), null);
|
||||
});
|
||||
|
||||
test('isAlarmState: leak/smoke/gas/siren fire; doors and outages do not', () => {
|
||||
assert.ok(isAlarmState('binary_sensor', 'moisture', 'on'));
|
||||
assert.ok(isAlarmState('binary_sensor', 'smoke', 'on'));
|
||||
assert.ok(isAlarmState('siren', undefined, 'on'));
|
||||
assert.ok(!isAlarmState('binary_sensor', 'door', 'on'));
|
||||
assert.ok(!isAlarmState('binary_sensor', 'smoke', 'off'));
|
||||
assert.ok(!isAlarmState('binary_sensor', 'smoke', 'unavailable'));
|
||||
});
|
||||
|
||||
test('parseRoomRef: area rooms, sub-area rooms by id, malformed refs', () => {
|
||||
assert.deepEqual(parseRoomRef('f1#kitchen'), { space: 'f1', area: 'kitchen', roomId: null });
|
||||
assert.deepEqual(parseRoomRef('f1#@r7'), { space: 'f1', area: null, roomId: 'r7' });
|
||||
assert.equal(parseRoomRef(''), null);
|
||||
assert.equal(parseRoomRef('f1#'), null);
|
||||
assert.equal(parseRoomRef('f1#@'), null);
|
||||
assert.equal(parseRoomRef('#kitchen'), null);
|
||||
assert.equal(parseRoomRef(null), null);
|
||||
});
|
||||
|
||||
test('diffNewDevices: first run seeds the baseline silently; later additions are fresh', () => {
|
||||
const first = diffNewDevices(['a', 'b'], undefined);
|
||||
assert.deepEqual(first, { fresh: [], known: ['a', 'b'] });
|
||||
const none = diffNewDevices(['a', 'b'], ['a', 'b']);
|
||||
assert.deepEqual(none.fresh, []);
|
||||
assert.equal(none.known.length, 2);
|
||||
const added = diffNewDevices(['a', 'b', 'c'], ['a', 'b']);
|
||||
assert.deepEqual(added.fresh, ['c']);
|
||||
assert.deepEqual(added.known, ['a', 'b', 'c']);
|
||||
// removed devices stay in known (harmless, keeps the list append-only)
|
||||
const removed = diffNewDevices(['a'], ['a', 'b']);
|
||||
assert.deepEqual(removed.fresh, []);
|
||||
});
|
||||
|
||||
test('spaceDisplayOf: room-card metric flags default to off', () => {
|
||||
const d0 = spaceDisplayOf({ plan_url: 'x', settings: {} });
|
||||
assert.deepEqual([d0.labelTemp, d0.labelHum, d0.labelLqi, d0.labelLight], [false, false, false, false]);
|
||||
const d1 = spaceDisplayOf({ plan_url: 'x', settings: { label_temp: true, label_light: true } });
|
||||
assert.deepEqual([d1.labelTemp, d1.labelHum, d1.labelLqi, d1.labelLight], [true, false, false, true]);
|
||||
});
|
||||
|
||||
test('splitRoomPath: polyline cut of a square', () => {
|
||||
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
// Г-образный разрез: верхняя стена (4,0) → внутрь (4,6) → правая стена (10,6)
|
||||
const parts = splitRoomPath(sq, [[4, 0], [4, 6], [10, 6]], 1e-6);
|
||||
assert.ok(parts);
|
||||
const [p1, p2] = parts;
|
||||
const area = (p) => Math.abs(p.reduce((a, _, i) => {
|
||||
const [x1, y1] = p[i], [x2, y2] = p[(i + 1) % p.length];
|
||||
return a + x1 * y2 - x2 * y1;
|
||||
}, 0)) / 2;
|
||||
assert.ok(Math.abs(area(p1) + area(p2) - 100) < 1e-6);
|
||||
// меньшая часть — прямоугольник 6x6 минус... фактически площадь 4*6+... проверим что обе > 0
|
||||
assert.ok(area(p1) > 0 && area(p2) > 0);
|
||||
});
|
||||
|
||||
test('splitRoomPath: two points == classic straight chord', () => {
|
||||
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
const parts = splitRoomPath(sq, [[5, 0], [5, 10]], 1e-6);
|
||||
assert.ok(parts);
|
||||
});
|
||||
|
||||
test('splitRoomPath rejects bad paths', () => {
|
||||
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
// промежуточная точка снаружи
|
||||
assert.equal(splitRoomPath(sq, [[4, 0], [4, -3], [10, 6]], 1e-6), null);
|
||||
// сегмент пересекает стену (выходит и возвращается)
|
||||
assert.equal(splitRoomPath(sq, [[4, 0], [14, 5], [10, 6]], 1e-6), null);
|
||||
// конец не на стене
|
||||
assert.equal(splitRoomPath(sq, [[4, 0], [5, 5]], 1e-6), null);
|
||||
// самопересечение пути
|
||||
assert.equal(splitRoomPath(sq, [[2, 0], [8, 8], [8, 2], [2, 8], [0, 4]], 1e-6), null);
|
||||
// менее двух точек
|
||||
assert.equal(splitRoomPath(sq, [[4, 0]], 1e-6), null);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user