Compare commits

...
33 Commits
Author SHA1 Message Date
Matysh 849117eb27 Merge dev: v1.31.1-v1.31.2 (plan editor fixes) 2026-07-22 13:31:00 +03:00
Matysh 37b39e7a1e fix v1.31.2: merge/split room pick highlight visible again
- .room.picked moved after .room.outlined: equal specificity, source
  order silently killed the amber highlight in markup (gotcha x4,
  documented inline)
- smoke_merge_highlight.mjs (waits out the 0.12s transition before
  reading computed colors); TESTING/CHANGELOG same-commit
2026-07-22 12:19:15 +03:00
Matysh d4f2d81a2f fix v1.31.1: room card interactions no longer feed the active markup tool
- _markupClick ignores clicks originating from .roomlabel/.rlhandle
  (composedPath) and anything during an active drag/resize
- smoke_card_tool_conflict.mjs (draw/delroom/handle/during-resize +
  normal stage click still works); TESTING/CHANGELOG same-commit
2026-07-22 12:15:01 +03:00
Matysh bcb546dbc7 Merge dev: v1.30.2..v1.31.0 batch (editor tabs redesign, Esc, room cards, scope) 2026-07-22 12:10:13 +03:00
Matysh ccf36e283e docs: fix product scope in docs/SCOPE.md (owner decision 2026-07-22)
- mission, personas/surfaces, core jobs J1-J7 with coverage status,
  partial list, in-mission gaps, hard out-of-scope list, excess audit
  (PDF/links + virtual devices kept frozen, LQI promoted to J7)
- STATUS.md points to SCOPE.md as the feature guard rail
2026-07-22 12:08:02 +03:00
Matysh 821bdfbd9a feat v1.31.0: room cards — metrics line + proportional resize
- room label becomes a card: name on top, optional metrics below
  (temperature / humidity / avg zigbee / lights), four checkboxes in
  space settings, all off by default; lights render On/Off or '1 of 3'
- areaHum + areaLightStats pure helpers (+3 unit tests, 90 total)
- resize via corner handles in the Plan editor (hover), uniform 0.5-3x,
  scale stored as layout k next to the position; drag preserves it
- fix latent v1.25 regression: draggable HTML labels were never rendered
  in the Plan editor (static SVG only) — real name-only cards now render
  there, draggable and resizable
- repair merge/split smokes still calling removed _toggleMarkup
- validation.py: 4 label_* bools; smoke_room_cards.mjs; docs same-commit
2026-07-22 12:01:14 +03:00
Matysh 22992b256f ux v1.30.4: Escape closes all dialogs
- Esc closes the topmost dialog: opening/device info, icon rules, general
  settings, marker dialog, opening editor, space dialog (abandons import
  queue like Cancel); draw-undo via Esc preserved with dialog priority
- smoke_esc_dialogs.mjs; TESTING/CHANGELOG same-commit
2026-07-22 11:39:39 +03:00
Matysh d273a90db8 ux v1.30.3: general settings gear visible in every mode
- header cog (fill palette) no longer gated to Plan mode; canEdit only
- smoke_gs_always.mjs; TESTING/CHANGELOG same-commit
2026-07-22 11:37:00 +03:00
Matysh 48d47a46af ux v1.30.2: editor tabs redesign
- two tabs only: 'Plan editor' / 'Device editor'; View is the implicit
  default state, no tab
- Device editor gets its own bottom toolbar (add/show-all/reset/rules
  moved from the header), mirroring the Plan toolbar
- X button on both toolbars and inside the active tab returns to View;
  re-click on active tab is a no-op; Plan<->Devices switches directly
- smoke_editor_tabs.mjs; TESTING/CHANGELOG/UX-MODES same-commit
2026-07-22 11:33:24 +03:00
Matysh 0d712b9069 Merge v1.30.1: space gear in every mode + alignment 2026-07-22 11:21:13 +03:00
Matysh dfa2a8badc fix v1.30.1: space gear visible in every mode + vertical alignment
- cog next to the space name shows in View/Plan/Devices (canEdit only);
  '+' add-space tab stays Plan-only
- tabs flex-centered so the cog aligns with the tab text
- smoke_gear_tabs.mjs; TESTING.md + CHANGELOG same-commit
2026-07-22 11:18:52 +03:00
Matysh 2dfab3565b Merge v1.30.0: lock action in the opening info card 2026-07-22 11:13:44 +03:00
Matysh 6ee09d3750 feat v1.30.0: explicit Unlock/Lock action in the opening info card
- View-mode door/window info card gets an Unlock (red) / Lock button when a
  lock entity is bound and available; disabled while locking/unlocking,
  hidden when unavailable
- plan-icon taps still never toggle locks (hard block untouched);
  smoke_lock_action.mjs covers the full matrix incl. the block
- TESTING.md, CHANGELOG, UX-MODES same-commit
2026-07-22 11:11:18 +03:00
Matysh 29c5a89aad Merge v1.29.0: 'new device' red-dot flag 2026-07-22 10:52:32 +03:00
Matysh 71f44fd528 feat v1.29.0: 'new device' flag with a red dot
- auto devices/light groups appearing after install get a server-side 'new'
  flag (settings.new_device_ids) and a red dot top-right of the icon; opening
  the device's editor clears it for every client
- known_devices baseline seeded silently on first run — upgrades never flood
  the plan with dots; hand-made markers never flagged
- pure diffNewDevices + unit tests; backend schema; smoke_new_device.mjs;
  TESTING.md row
2026-07-22 10:48:55 +03:00
Matysh c8722642b7 Merge inert-openings: View-mode cursors and inert openings v1.28.1 2026-07-22 10:44:23 +03:00
Matysh 0a7b9a627f fix v1.28.1 (revised per owner spec): View-mode cursors and inert openings
- device icons: pointer cursor in View (grab only in Devices mode); click
  behavior untouched
- openings in View: pure drawings always — no cursor/hover/hit/click
- lock badge: the one exception — visible and clickable in View (info card),
  inert in Plan; removed a duplicate pointer-events in .oplock
- smoke covers all cursor/badge cases
2026-07-22 10:42:12 +03:00
Matysh 961d6afe67 fix v1.28.1: openings fully inert in View mode
- op-hit: pointer-events none + default cursor by default; interactive (grab,
  hover outline, drag) only inside Plan; lock badge is a pure indicator
- opening click edits in Plan with any tool; no interaction whatsoever in View
- smoke_inert_openings.mjs; TESTING.md updated
2026-07-22 10:34:03 +03:00
Matysh b9fecbdd95 Merge subarea-rooms: manual placement without an HA area v1.28.0 2026-07-22 10:27:31 +03:00
Matysh 9fd36bd710 feat v1.28.0: sub-area rooms — manual device placement without an HA area (issue #3)
- area-less rooms appear in the marker room list ('no area, manual'); markers
  store room_id and land at the room centre; dialog reopen restores the choice
- pure parseRoomRef (space#area / space#@roomId) + unit tests; backend schema
- ROADMAP phase 11 closed; TESTING.md row; smoke_subarea.mjs
2026-07-22 10:25:20 +03:00
Matysh a9346c9c14 Merge rgb-alarm: RGB light colors + alarm pulse v1.27.0 2026-07-22 10:23:15 +03:00
Matysh 794b02b84f feat v1.27.0: RGB light colors + red alarm pulse (issue #3)
- lightColorOf(): on+rgb_color tints icon/glow/ripple (explicit ripple color wins);
  brightness ignored by design; off/white/unavailable unchanged
- isAlarmState(): leak/smoke/gas/CO/safety/tamper/problem sensors and sirens in
  'on' pulse a red ring over any display mode; outages never alarm;
  prefers-reduced-motion honoured
- +2 unit tests, smoke_rgb_alarm.mjs, TESTING.md rows
2026-07-22 10:21:08 +03:00
Matysh 3bc25ad5ae Merge state-icons: state-reflecting icons + value display v1.26.0 2026-07-22 10:19:42 +03:00
Matysh d1a79cd0b4 feat v1.26.0: state-reflecting icons + 'value instead of an icon' display (issue #3)
- stateIcon(): door/window/garage open-closed, lock locked-unlocked, bulb on;
  custom icons and unavailable/unknown never morph; gated by live_states
- marker display 'value': the measurement (with unit) is the marker body,
  corner badges hidden; numeric fallback chain temp - hum - primary state
- TESTING.md rows, smoke_state_value.mjs, +1 unit test
2026-07-22 10:17:32 +03:00
Matysh 5522399a3a Merge ux-modes: three interaction modes v1.25.0 2026-07-22 10:13:08 +03:00
Matysh 185604396d feat v1.25.0: three interaction modes — View / Plan / Devices (docs/UX-MODES.md)
- View (always the default): display + device interaction only; no dragging of
  icons/labels/openings; panning can start on an icon; clean header
- Plan: markup tools, openings editing (click-to-edit, drag along walls), label
  drag, space dialogs, fill palette; orange frame; icons hidden but labels visible
- Devices: icon drag only here, click opens the marker editor directly; add/
  show-all/reset/rules buttons; accent frame
- segmented mode control (admins only); removed: markup toggle button, view-mode
  opening drag/dblclick (v1.23.1), 'drag anywhere' (v1.9 reversed)
- README en+ru updated; TESTING.md Modes section; smoke_modes.mjs
2026-07-22 10:11:02 +03:00
Matysh 9d0e2cab00 docs: approved UX-modes design (View/Plan/Devices tabs) — docs/UX-MODES.md + ROADMAP phase 11
Research only, no product changes. Confirms and structures the owner's mandate
(view mode must be display-only) and issue #3 feedback; lists deprecations and
approved follow-up features with implementation iterations.
2026-07-22 10:02:55 +03:00
Matysh 73f176ea2f Merge light-none: 'no light sources' fill color v1.24.2 2026-07-22 09:40:05 +03:00
Matysh b7599c642c feat v1.24.2: 'no light sources' color in the lights fill group
Default opacity 0 preserves the historical no-fill behavior; assigning an
opacity tints lightless rooms distinguishably from 'all lights off'.
2026-07-22 09:38:05 +03:00
Matysh 4ec7e7081f Merge tab-gear: gear icon on space tabs v1.24.1 2026-07-22 09:35:34 +03:00
Matysh ee5f65752a style v1.24.1: gear instead of pencil on space tabs (the dialog is settings, not renaming) 2026-07-22 09:33:32 +03:00
Matysh 574d3b8f9a Merge general-settings: global fill palette + per-space LQI toggle v1.24.0 2026-07-22 09:30:38 +03:00
Matysh 90c558eee7 feat v1.24.0: general settings (global fill palette) + per-space LQI toggle
- General settings dialog: fill colors grouped by mode (light on/off, temp
  cold/ok/hot, lqi weak/strong), each with its own opacity; lqi fill lerps
  between the endpoints; stored in settings.fill_colors (defaults omitted);
  space-card uses the same palette
- per-space show_lqi toggle (badges + room tooltip line), inherits the card's
  show_signal when unset
- fillColorsOf/lerpColor/roomFillStyle helpers (+4 tests), backend schemas,
  smoke_general_settings; TESTING.md updated in the same commit
2026-07-22 09:28:38 +03:00
44 changed files with 3527 additions and 493 deletions
+2 -2
View File
@@ -119,7 +119,7 @@ Later you can add as many spaces as you like (floors, yard, garage) with the **
### Step 2. Outline the rooms
After the first space is added, the card switches to markup mode by itself. Click grid points, connecting them with lines, and close the room outline by clicking the first point.
After the first space is added, the card switches to the **Plan** tab by itself. The card has three mode tabs in the header — **View** (default: display and device control only, nothing can be moved or edited), **Plan** (rooms, openings, labels, space settings) and **Devices** (placing and configuring markers); the edit tabs are shown to administrators. In Plan, click grid points, connecting them with lines, and close the room outline by clicking the first point.
As soon as the outline is closed, the room-save dialog appears. Here you need to **bind the room to a Home Assistant area** — this is exactly what enables the automation. For utility rooms with no devices (hall, sauna) there is a **"No area"** button.
@@ -166,7 +166,7 @@ The mouse wheel or the **- / ⊹ / +** buttons zoom the plan in and out; on
### Step 5. Put the icons in their places
Device icons can be **dragged with the mouse at any time** — no separate "edit mode" needs to be enabled. Positions are saved on the server and are identical in all browsers and devices. The **↺** button in the header restores the automatic layout.
Switch to the **Devices** tab to arrange icons: drag them with the mouse, click one to open its editor. In **View** mode nothing can be moved — panning the map never displaces a sensor (a top user request). Positions are saved on the server and are identical in all browsers and devices. The **↺** button restores the automatic layout.
![Dragging icons — available at all times](docs/images/06-edit.png)
+1 -1
View File
@@ -168,7 +168,7 @@ title: План дома
### Шаг 5. Расставьте значки по местам
Значки устройств можно **перетаскивать мышью в любой момент** — отдельный «режим правки» включать не нужно. Позиции сохраняются на сервере и одинаковы во всех браузерах и устройствах. Кнопка **↺** в шапке возвращает автоматическую раскладку.
Расставлять значки нужно на вкладке **«Устройства»**: там они перетаскиваются мышью, а клик открывает редактор. В режиме **«Просмотр»** ничего сдвинуть нельзя — панорамирование карты больше не сдвигает датчики (главная просьба пользователей). Позиции сохраняются на сервере и одинаковы во всех браузерах и устройствах. Кнопка **↺** возвращает автоматическую раскладку.
![Перетаскивание значков — доступно всегда](docs/images/06-edit.png)
+1 -1
View File
@@ -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.23.2"
VERSION = "1.31.2"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.23.2"
"version": "1.31.2"
}
+23 -1
View File
@@ -87,6 +87,11 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp"]),
vol.Optional("temp_min"): vol.Coerce(float),
vol.Optional("temp_max"): vol.Coerce(float),
vol.Optional("show_lqi"): bool,
vol.Optional("label_temp"): bool,
vol.Optional("label_hum"): bool,
vol.Optional("label_lqi"): bool,
vol.Optional("label_light"): bool,
},
extra=vol.ALLOW_EXTRA,
)
@@ -139,6 +144,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),
@@ -154,7 +160,23 @@ CONFIG_SCHEMA = vol.Schema(
{
vol.Required("spaces"): [SPACE_SCHEMA],
vol.Optional("markers", default=list): [MARKER_SCHEMA],
vol.Optional("settings", default=dict): vol.Schema({}, extra=vol.ALLOW_EXTRA),
vol.Optional("settings", default=dict): vol.Schema(
{
vol.Optional("known_devices"): [str],
vol.Optional("new_device_ids"): [str],
vol.Optional("fill_colors"): vol.Schema(
{
str: vol.Schema(
{
vol.Required("c"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
}
)
}
),
},
extra=vol.ALLOW_EXTRA,
),
},
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
)
+51
View File
@@ -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();
+39
View File
@@ -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 === 2;
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;
// 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();
+35
View File
@@ -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();
+35
View File
@@ -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();
+30
View File
@@ -0,0 +1,30 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// 1) диалог общих настроек: 7 цветовых строк, 3 группы
c._openSettingsDialog(); await c.updateComplete;
out.rows = sr().querySelectorAll('.gsrow').length;
out.groups = [...sr().querySelectorAll('.dialog .dispsection')].map((l) => l.textContent.trim());
// 2) сменить цвет light_on и сохранить
c._setFillColor('light_on', { c: '#ff00ff', a: 0.5 });
await c._saveSettingsDialog(); await c.updateComplete;
out.saved = c._serverCfg.settings.fill_colors?.light_on;
// 3) заливка light использует кастомный цвет
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({ ...s,
settings: { ...(s.settings||{}), show_borders: true, fill_mode: 'light' } })) };
c.requestUpdate(); await c.updateComplete;
const styles = [...sr().querySelectorAll('.room.styled')].map((r) => r.getAttribute('style') || '');
out.customFillUsed = styles.some((st) => st.includes('#ff00ff') && st.includes('0.500'));
// 4) show_lqi=false у пространства скрывает LQI-бейджи
out.lqiBefore = sr().querySelectorAll('.dev .lqi').length;
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({ ...s,
settings: { ...(s.settings||{}), show_lqi: false } })) };
c.requestUpdate(); await c.updateComplete;
out.lqiAfter = sr().querySelectorAll('.dev .lqi').length;
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+18
View File
@@ -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();
+47
View File
@@ -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();
+51
View File
@@ -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();
+24
View File
@@ -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 }; 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();
+1 -1
View File
@@ -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]
+79
View File
@@ -0,0 +1,79 @@
// UX modes shell (v1.25.0): view is display-only; plan/devices gate the tools.
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const out = {};
const q = (sel) => page.evaluate((s) => (window.__card.shadowRoot || window.__card.renderRoot).querySelectorAll(s).length, sel);
const st = () => page.evaluate(() => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
return {
mode: c._mode,
modeTabs: sr.querySelectorAll('.modetab').length,
active: sr.querySelector('.modetab.active')?.textContent.trim(),
editBtns: sr.querySelectorAll('.head .btn:not(.zb)').length,
gears: sr.querySelectorAll('.tabedit').length,
markupBar: !!sr.querySelector('.editbar'),
stageClass: sr.querySelector('.stage').className,
};
});
// 1) старт: view, чистая шапка
out.start = await st();
// 2) drag в view ничего не двигает и не мешает пану
await page.evaluate(() => {
const c = window.__card;
const d = c._devices.find((x) => x.space === 'f1');
const before = { ...c._pos(d) };
c._pointerDown({ preventDefault(){}, clientX: 10, clientY: 10, target: { setPointerCapture(){} }, pointerId: 1 }, d);
c._pointerMove({ clientX: 90, clientY: 60 }, d);
c._pointerUp({}, d);
const after = { ...c._pos(d) };
window.__viewDragMoved = Math.abs(after.x - before.x) + Math.abs(after.y - before.y) > 0.5;
clearTimeout(c._holdTimer);
});
out.viewDragMoved = await page.evaluate(() => window.__viewDragMoved);
// long-press в view открывает инфо
await page.evaluate(async () => {
const c = window.__card;
const d = c._devices.find((x) => x.space === 'f1');
c._pointerDown({ preventDefault(){}, clientX: 10, clientY: 10, target: { setPointerCapture(){} }, pointerId: 2 }, d);
await new Promise((r) => setTimeout(r, 700));
});
out.viewHoldInfo = await page.evaluate(() => { const r = !!window.__card._infoCard; window.__card._infoCard = null; window.__card._holdFired = false; return r; });
// 3) режим План
await page.evaluate(() => window.__card._setMode('plan'));
await page.waitForTimeout(200);
out.plan = await st();
out.planIconsHidden = await page.evaluate(() => {
const sr = window.__card.shadowRoot || window.__card.renderRoot;
const dev = sr.querySelector('.dev');
return dev ? getComputedStyle(dev).display === 'none' : 'no-dev';
});
// 4) режим Устройства: drag работает, клик открывает редактор
await page.evaluate(() => window.__card._setMode('devices'));
await page.waitForTimeout(200);
out.devices = await st();
out.devDragWorks = await page.evaluate(() => {
const c = window.__card;
const d = c._devices.find((x) => x.space === 'f1');
const before = { ...c._pos(d) };
c._pointerDown({ preventDefault(){}, clientX: 10, clientY: 10, target: { setPointerCapture(){} }, pointerId: 3 }, d);
c._pointerMove({ clientX: 100, clientY: 70 }, d);
c._pointerUp({}, d);
const after = { ...c._pos(d) };
return Math.abs(after.x - before.x) + Math.abs(after.y - before.y) > 0.5;
});
out.devClickOpensEditor = await page.evaluate(async () => {
const c = window.__card;
c._drag = null;
const d = c._devices.find((x) => x.space === 'f1');
c._clickDevice({ stopPropagation(){} }, d);
await c.updateComplete;
const r = !!c._markerDialog;
c._markerDialog = null;
return r;
});
// 5) назад в view
await page.evaluate(() => window.__card._setMode('view'));
out.backToView = (await st()).mode;
console.log(JSON.stringify(out, null, 1));
await browser.close();
+32
View File
@@ -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();
+32
View File
@@ -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();
+61
View File
@@ -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();
+1 -1
View File
@@ -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)
+34
View File
@@ -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();
+35
View File
@@ -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();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+355 -109
View File
File diff suppressed because one or more lines are too long
+159
View File
@@ -1,5 +1,164 @@
# Changelog
## v1.31.2 — 2026-07-22
- Plan editor: the room picked with the **Merge** tool (and the room selected
for **Split**) is highlighted amber again. The `.outlined` markup style,
added later in the stylesheet, was silently overriding the `.picked`
highlight at equal specificity (source-order gotcha #4 — rule order fixed
and documented in the stylesheet).
## v1.31.1 — 2026-07-22
- Plan editor: interacting with a room card (drag, corner-resize or a plain
click) no longer leaks into the active markup tool — previously the click
after a resize could add an outline point, pick a merge/split room or even
prompt to delete the room under the card.
## v1.31.0 — 2026-07-22 (room cards)
- Room labels grew into **room cards**: the name on top, and an optional
smaller metrics line below — temperature, humidity, average Zigbee signal
and lights, each behind its own checkbox in the space settings (all off by
default). Lights show On/Off, or **"1 of 3"** when only part of the room is
lit. Rooms without an HA area keep showing just the name.
- Cards are **resizable** in the Plan editor: hovering shows corner handles;
dragging one scales the whole card uniformly (0.5×–3×). The scale is stored
in the layout next to the card position, and dragging keeps it.
- Fixed a latent v1.25 regression: room labels were not rendered as draggable
HTML in the Plan editor at all (only a static SVG name), so moving them was
impossible. The Plan editor now renders real cards (name only) that can be
dragged and resized.
- Two markup smokes (merge/split) still called a method removed in v1.25 —
repaired.
## v1.30.4 — 2026-07-22
- **Escape now closes every dialog** (general settings, icon rules, device
editor, space dialog, opening editor, info cards), topmost first when
stacked. Closing the space dialog with Esc abandons a floor-import queue,
same as its Cancel button. Esc while drawing an outline still undoes the
last point (dialogs take priority).
## v1.30.3 — 2026-07-22
- The **General settings** (fill palette) gear in the header is now visible in
every mode for users who can edit, not just in the Plan editor.
## v1.30.2 — 2026-07-22 (editor tabs redesign)
- Mode tabs renamed and reduced to two: **"Plan editor"** and **"Device
editor"**. View is no longer a tab — it is the implicit default state.
- The Device editor now has its own bottom toolbar (add / show all / reset
layout / icon rules moved out of the header), mirroring the Plan toolbar.
- Both toolbars and the active tab itself got an **X** button that closes the
editor and returns to View. Re-clicking the active tab does nothing;
switching Plan↔Devices is direct.
## v1.30.1 — 2026-07-22 (space gear polish)
- The gear icon next to the space name is now visible in **every mode** (not
just Plan) for users who can edit, so space settings are always one click
away. The "+" (add space) tab remains Plan-only.
- Fixed the gear's vertical alignment — it sat noticeably lower than the space
name (baseline-aligned web component); tabs are now flex-centered.
## v1.30.0 — 2026-07-22 (lock action in the opening info card)
- The door/window info card (View mode) now offers an explicit **Unlock/Lock
button** when a lock entity is bound and available. Unlock is styled red as a
security-sensitive action; the button is disabled during locking/unlocking and
hidden when the lock is unavailable.
- The security rule is untouched: **tapping a lock icon on the plan still never
toggles it** — the action lives only behind a deliberate, clearly labeled
button, same interaction contract as HA's more-info dialog.
## v1.29.0 — 2026-07-22 (the "new device" flag)
- **Devices that appear in HA after installation no longer show up silently**:
an auto-placed device (or light group) gets a big red dot at the top-right of
its icon. The flag is stored server-side (`settings.new_device_ids`), so every
client sees it — and it disappears everywhere the first time someone opens
that device's editor.
- The baseline of known devices is seeded silently on the first run after the
update, so **existing devices never flood the plan with dots**. Hand-made
markers (virtual/rebindings) are never flagged — the user just created them.
(Pure `diffNewDevices`, unit-tested; backend schema for the two new settings.)
## v1.28.1 — 2026-07-22 (View-mode polish: cursors and inert openings)
- **Device icons in View no longer show the grab cursor** (drag lives in the
Devices mode); they show a pointer — clicking still works exactly as before.
- **Doors and windows in View are pure drawings**: no grab cursor, no hover
outline, no hit target, no click — regardless of what is bound to them.
- **The lock badge is the one exception**: when a lock is bound, the badge is
shown and clickable in View (pointer cursor, click opens the door/lock info
card). It stays inert in Plan so it does not fight editing.
- In Plan an opening remains fully interactive: grab cursor + hover outline,
dragging along walls, and a click with ANY tool opens its properties dialog.
## v1.28.0 — 2026-07-21 (sub-area rooms: manual placement without an HA area; issue #3)
- **Devices can now be placed into rooms that have no Home Assistant area.**
The marker dialog's room list includes area-less rooms (marked "no area,
manual"); pick one and the device/virtual marker lands at its centre. The
marker stores `room_id`, so the choice survives edits and re-opens. The
laundry-cupboard case from issue #3: a decorative room inside a larger area
can now hold its own door sensor and light.
- Room reference parsing extracted as pure `parseRoomRef` (`space#area` /
`space#@roomId`), unit-tested; backend schema for `room_id`.
## v1.27.0 — 2026-07-21 (RGB light colors + alarm pulse; issue #3)
- **RGB lights show their actual color**: an "on" light with a color tints its
bulb icon, glow and — unless a custom ripple color is set — its presence ripple.
Brightness is deliberately ignored (a dim red bulb still reads red); off,
white-only and unavailable lights look as before. (Pure `lightColorOf`.)
- **Emergencies pulse red**: leak / smoke / gas / CO / safety / tamper / problem
binary sensors and sirens in `on` get a red pulsing ring over any display mode.
`unavailable`/`unknown` never alarm — an outage is not a fire. Honours
`prefers-reduced-motion`. (Pure `isAlarmState`.)
## v1.26.0 — 2026-07-21 (state-reflecting icons + value display; issue #3)
- **Auto icons now reflect live state**, like core HA: door/window/garage sensors
swap open↔closed variants, locks show locked/unlocked, a bulb lights up as
`lightbulb-on`. Conservative by design: only well-known pairs, never when the
user picked a custom icon, and `unavailable`/`unknown` keep the base icon.
Gated by the existing "live states" card option. (Pure `stateIcon`, unit-tested.)
- **New marker display mode "Value instead of an icon"**: the measurement itself
(temperature °, humidity %, or any numeric state with its unit) becomes the
marker body — the small corner badges disappear for such markers. Direct
request from issue #3. Non-numeric entities fall back to the icon.
## v1.25.0 — 2026-07-21 (three interaction modes: View / Plan / Devices)
The approved UX redesign (docs/UX-MODES.md), confirmed by user feedback (#3):
- **View** (default, and the only mode after every load): display and device
interaction only — tap/long-press/tooltips/pan/zoom. Nothing can be dragged or
edited; panning may start on top of an icon and never displaces it. The header
carries only space tabs, the counter, zoom and the mode switcher.
- **Plan**: everything about geometry and appearance — room outline/delete/merge/
split tools, openings (placement, drag along walls, click-to-edit), room-label
dragging, per-space gear dialog, add space, the ⚙ fill palette. Orange stage frame.
- **Devices**: marker work — icon dragging lives ONLY here, a click opens the
editor directly; add device, show-all curation, reset layout, icon rules. Accent frame.
- The mode switcher is a segmented control shown to administrators; the standalone
markup toggle button is gone, "drag anywhere" (v1.9) is consciously reversed,
and the v1.23.1 view-mode opening drag/double-click moved into Plan.
## v1.24.2 — 2026-07-16 (lights fill: a color for rooms with no light sources)
- The "Fill: lights" group in General settings gained a third color — **"No light
sources"**. Its default opacity is 0, so rooms without any lights stay unfilled
exactly as before; give it an opacity and such rooms get their own tint,
distinguishable from "all lights off".
## v1.24.1 — 2026-07-16 (space tab: gear instead of pencil)
- The small icon next to a space name in the tabs is now a gear (was a pencil) —
the dialog it opens is space *settings* (plan, display, scale), not just renaming.
## v1.24.0 — 2026-07-16 (general settings: fill palette; per-space LQI toggle)
- **New "General settings" dialog** (⚙ in the header): the fill colors used by every
space, grouped by mode — lights (on / all off), temperature (cold / comfortable /
hot) and zigbee signal (weak / strong endpoints of the gradient). Every color has
its **own opacity slider**; the zigbee fill interpolates between the two configured
endpoint colors. Stored server-side in `settings.fill_colors` (defaults are not
persisted); the static `houseplan-space-card` uses the same palette.
- **Per-space "Show zigbee signal (LQI)" toggle** in the space dialog: hides or shows
the LQI badges next to zigbee devices (and the signal line in room tooltips) for
that space; when never touched, the card-level `show_signal` option applies as before.
- Fill opacity is now governed by the per-color setting; the space "Opacity" slider
keeps controlling borders and names.
- New pure helpers `fillColorsOf` / `lerpColor` / `roomFillStyle` (+4 tests: 77 → 81);
backend schema for `fill_colors` and `show_lqi`; smoke `smoke_general_settings`.
## v1.23.2 — 2026-07-16 (manual upload limit raised to 50 MB)
- The per-file limit for attached manuals (PDF etc.) is now **50 MB** (was 25).
The limit is still enforced while the multipart body streams in, so an oversized
+7
View File
@@ -74,6 +74,13 @@ Track progress in `custom_components/houseplan/quality_scale.yaml` (done/exempt
- [ ] GitHub: issue templates, discussions on, CONTRIBUTING.md (build/test in 5 minutes).
- [ ] Semantic versioning discipline; keep release notes user-facing (they show in HACS).
## Phase 11 — UX modes redesign ✅ DONE (v1.25.0–v1.28.0, see docs/UX-MODES.md)
Shipped: the three-tab interaction model (View / Plan / Devices, v1.25.0),
state-reflecting icons + value display (v1.26.0), RGB light colors + alarm pulse
(v1.27.0), sub-area rooms (v1.28.0). Remaining backlog from issue #3: music
notes for players, directional TV ripples (not planned).
## Explicit non-goals (for now)
- 3D plans (.glb) — separate market (3Dash), heavy dependencies.
Executable
+97
View File
@@ -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
View File
@@ -13,7 +13,7 @@
| Item | State |
|---|---|
| Version | **v1.23.2** 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.
+74
View File
@@ -29,6 +29,28 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
- [ ] Removal: delete entry → Lovelace resource entry disappears; `.storage/houseplan.*` survives; reinstall picks the old config up
- [ ] Diagnostics download works; personal fields (name/link/description/pdfs) are `**REDACTED**` [auto]
## Modes (v1.25.0) ★
- [ ] The card always loads in **View**; edit modes are never restored [auto]
- [ ] View: pan/zoom/space-switch/tap/long-press/tooltips only — dragging an icon,
label or opening does nothing; panning may start on top of an icon [auto]
- [ ] View header: space tabs + count + zoom + mode tabs, ZERO edit buttons [auto]
- [ ] Plan: markup toolbar, space gears, +space, ⚙ palette; device icons hidden,
labels/openings draggable; orange stage frame [auto]
- [ ] Devices: icon drag works, click opens the marker editor directly; +/👁/↺/⬡
buttons; accent stage frame [auto]
- [ ] Mode tabs hidden for non-admin users; segmented control highlights the active mode
- [ ] Openings in View (v1.28.1): the door/window itself is a pure drawing — no
cursor change, no hover outline, no hit target, no click, regardless of
bindings [auto]
- [ ] The LOCK BADGE is the one exception: when a lock is bound it is shown and
clickable in View (pointer cursor, click → door/lock info card); inert in
Plan so it does not fight editing [auto]
- [ ] Device icons in View show a pointer cursor (no grab); grab only in the
Devices mode [auto]
- [ ] In Plan an opening is interactive: grab cursor, hover outline, drag along
walls, click (any tool) opens its properties [auto]
## Onboarding ★
- [ ] Empty config, HA has floors → floors-import wizard offers them sorted by level [auto]
@@ -57,6 +79,13 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
smart-plug chip temperatures (`*_device_temperature`) and diagnostic-category temps are excluded [auto]
- [ ] Space dialog is 500 px wide; the comfort-bounds inputs are compact (56 px)
- [ ] The scale (cm per cell) input is compact (72 px), not full-width [auto]
- [ ] General settings (⚙ in the header): fill colors grouped by mode (lights on/off/none,
temp cold/comfy/hot, LQI weak/strong), each with its own opacity slider [auto];
Reset restores defaults; saving defaults stores nothing [auto]
- [ ] Custom fill colors apply to the full card AND the static space-card
- [ ] LQI gradient interpolates between the configured weak/strong colors [auto]
- [ ] Per-space "Show zigbee signal (LQI)" toggle hides/shows the badges next to
devices and the signal line in room tooltips for that space only [auto]
- [ ] Device icon badge is centred exactly on its point (no 1 px down-right drift) [auto]
- [ ] Device glyph is centred within its badge (no vertical drift — real ha-icon is block+line-height) [auto]
- [ ] Room hover highlight still works when custom borders/fills are on
@@ -103,6 +132,48 @@ 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
- [ ] 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) ★
@@ -110,6 +181,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]
+83
View File
@@ -0,0 +1,83 @@
# UX redesign: three modes (approved 2026-07-21)
> Approved design for reorganizing all card interactions into three tab-like modes.
> Driven by the owner's mandate and confirmed by real user feedback
> ([issue #3](https://github.com/Matysh/houseplan-card/issues/3): *"When moving the
> map around, I sometimes move the doors/sensors around"*). This document is the
> source of truth for the implementation iterations below. No code has been changed yet.
## Principle
A segmented control in the card header with three tabs; the active one is visually
highlighted, and edit modes add a colored frame around the stage so the mode is
obvious at a glance:
**[ 📐 Plan editor ] [ 🔧 Device editor ]** — View has NO tab (since v1.30.2).
- **View** is the implicit default state: no editor tab is active. It is the only
state after every load (edit modes are never restored across reloads).
- Activating an editor tab highlights it and opens that editor's bottom toolbar
(both editors have one since v1.30.2). The toolbar and the active tab each
carry an **X** that closes the editor back to View; re-clicking the active tab
does nothing; Plan↔Devices switches directly.
- **Plan editor** and **Device editor** are shown only to admins when
`admin_only` is on.
## View — display and device interaction only
Allowed: pan/zoom (wheel, pinch, buttons), switching spaces, device tap
(info / more-info / toggle per settings), long-press → info card, opening tap →
door/lock info card (with an explicit Unlock/Lock button when a lock is bound —
the only way to operate a lock from the card; plan-icon taps never toggle locks),
room tap → HA area, hover tooltips (name, temperature, signal).
Removed from this mode (they move, not die):
- icon dragging ("drag anywhere", v1.9 — consciously reversed),
- room-label dragging,
- opening dragging along walls and double-click properties (v1.23.1),
- every edit button in the header (+device, 👁 show-all, ↺ reset, ⬡ rules, ⚙ general,
per-space gear, markup toggle).
Header in View: space tabs, device count, zoom cluster. Nothing else.
## Plan — geometry and appearance of the space
- Toolbar tools: Outline room, Delete room, Merge, Split, Opening (place / drag along
walls / properties), Room labels (drag positions — labels are part of the plan).
- Space gear dialog (title, plan image / hand-drawn, scale, Display section, show_lqi),
add space, floors import, delete space.
- ⚙ General settings (fill palette) lives here — it is about the plan's appearance.
## Devices — placement and marker configuration
- Icon dragging (ONLY here). Click on a device opens the **edit dialog directly**
(binding, name, icon, size/angle, display badge/ripple + colors, tap override,
model/link/description/PDFs, room).
- + add device/entity/virtual, hide device, ↺ reset layout, 👁 show-all (curation
tool), ⬡ icon rules.
## Deprecations decided
1. "Drag anywhere" (v1.9) — reversed by this design.
2. Opening drag / dbl-click properties in view (v1.23.1) — moved into Plan.
3. The markup toggle button — replaced by the Plan tab.
4. Legacy localStorage mode (card without the integration) — candidate for removal
in the next major; adds branching for a half-working scenario.
## Approved follow-up features (from issue #3, by priority)
1. State-reflecting icons (open/closed door variants etc., like core HA).
2. `display: value` — show the measurement instead of an icon.
3. Light color in the icon/ripple (RGB lights).
4. Alarm visual (leak/smoke/doorbell): red pulse overlay.
5. Rooms as sub-areas without an HA area + manual device placement by room id.
6. Backlog (not planned): music notes for players, directional TV ripples.
## Implementation iterations
- **It.1 — mode shell:** the segmented control, mode state, View-mode gating of all
edit interactions/buttons (biggest UX win, smallest surface).
- **It.2 — Plan tab:** move markup tools + space dialogs + labels drag + openings
editing under Plan; colored frame indicator.
- **It.3 — Devices tab:** drag + direct-edit click + curation tools under Devices.
- **It.4+:** follow-up features 1–5 above, each its own release.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.23.1",
"version": "1.23.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.23.1",
"version": "1.23.2",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.23.2",
"version": "1.31.2",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+37
View File
@@ -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 {
+481 -105
View File
@@ -17,10 +17,12 @@ import {
pointOnBoundary, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary,
snapToWall, openingAmount,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
spaceDisplayOf, roomFillColor, isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors,
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
} from './logic';
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp } from './devices';
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats } from './devices';
import type {
OpeningCfg,
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
@@ -30,7 +32,7 @@ import './space-card';
import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.23.2';
const CARD_VERSION = '1.31.2';
const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -63,7 +65,7 @@ class HouseplanCard extends LitElement {
private _config?: CardConfig;
private _space = 'f1';
private _layout: Record<string, { x: number; y: number; s?: string }> = {};
private _layout: Record<string, { x: number; y: number; s?: string; k?: number }> = {};
private _serverStorage = false;
private _loadOk = false;
private _loading = false;
@@ -74,13 +76,27 @@ class HouseplanCard extends LitElement {
private _devices: DevItem[] = [];
private _regSignature = '';
private _defPos: Record<string, { x: number; y: number }> = {};
private _newSyncKey = '';
private _tip: { x: number; y: number; title: string; meta: string; lqi?: number | null; temp?: number | null } | null = null;
private _selId: string | null = null;
private _toast = '';
private _toastTimer?: number;
// --- room markup editor ---
private _markup = false;
/** Interaction mode (docs/UX-MODES.md): view = display only, plan = geometry
* editing, devices = marker placement/config. Never persisted — every load
* starts in view. */
private _mode: 'view' | 'plan' | 'devices' = 'view';
/** Edit tabs are offered to admins only (hass.user missing → assume admin). */
private get _canEdit(): boolean {
return this._norm && this.hass?.user?.is_admin !== false;
}
/** Legacy alias: markup machinery is active exactly in plan mode. */
private get _markup(): boolean {
return this._mode === 'plan';
}
private _tool: MarkupTool = 'draw';
private _path: number[][] = []; // current outline (render units, vertices snapped to the grid)
private _cursorPt: number[] | null = null;
@@ -98,7 +114,6 @@ class HouseplanCard extends LitElement {
} | null = null;
private _openingInfo: OpeningCfg | null = null;
private _opDrag: { id: string; moved: boolean } | null = null;
private _opClickTimer: number | null = null; // first room picked for a merge
private _mergeDialog: { aId: string; bId: string; poly: number[][]; pick: 'a' | 'b' } | null = null;
private _splitSel: { roomId: string; a: number[] | null } | null = null; // room being cut + first wall point
// a split is applied only when the new room's dialog is confirmed — cancel leaves the room intact
@@ -118,6 +133,7 @@ class HouseplanCard extends LitElement {
private _onboardingShown = false; // the auto space dialog is shown once per session
private _rulesDialog: { rules: IconRule[]; test: string; busy: boolean } | null = null;
private _settingsDialog: { colors: FillColors; busy: boolean } | null = null;
private _importDialog: { floors: (FloorInfo & { checked: boolean })[] } | null = null;
private _importQueue: string[] = []; // floor titles still to create
private _importTotal = 0;
@@ -131,7 +147,7 @@ class HouseplanCard extends LitElement {
binding: string; // 'device:<id>' | 'entity:<eid>' | 'virtual'
bindingFilter: string;
icon: string; // '' = auto
display: 'badge' | 'ripple' | 'icon_ripple';
display: 'badge' | 'ripple' | 'icon_ripple' | 'value';
rippleColor: string; // '' = accent
rippleSize: number; // in icon diameters
size: number; // icon size multiplier
@@ -159,6 +175,11 @@ class HouseplanCard extends LitElement {
fillMode: 'none' | 'lqi' | 'light' | 'temp';
tempMin: number;
tempMax: number;
showLqi: boolean;
labelTemp: boolean;
labelHum: boolean;
labelLqi: boolean;
labelLight: boolean;
cellCm: number; // real-world cm represented by one grid cell
busy: boolean;
} | null = null;
@@ -180,6 +201,7 @@ class HouseplanCard extends LitElement {
};
private _drag: { id: string; sx: number; sy: number; ox: number; oy: number; moved: boolean } | null = null;
private _rlResize: { id: string; space: string; k0: number; cx: number; cy: number; d0: number } | null = null;
private _holdTimer?: number;
private _holdFired = false;
@@ -193,7 +215,7 @@ class HouseplanCard extends LitElement {
_selId: { state: true },
_toast: { state: true },
_serverCfg: { state: true },
_markup: { state: true },
_mode: { state: true },
_tool: { state: true },
_path: { state: true },
_cursorPt: { state: true },
@@ -208,6 +230,7 @@ class HouseplanCard extends LitElement {
_spaceDialog: { state: true },
_infoCard: { state: true },
_rulesDialog: { state: true },
_settingsDialog: { state: true },
_importDialog: { state: true },
_markerDialog: { state: true },
_zoom: { state: true },
@@ -235,8 +258,20 @@ class HouseplanCard extends LitElement {
private _onKey(e: KeyboardEvent): void {
if (e.key === 'Escape') {
// close the topmost open dialog; info popups first, then editors
if (this._openingInfo) { this._openingInfo = null; return; }
if (this._infoCard) { this._infoCard = null; return; }
if (this._rulesDialog) { this._rulesDialog = null; return; }
if (this._settingsDialog) { this._settingsDialog = null; return; }
if (this._markerDialog) { this._markerDialog = null; return; }
if (this._openingDialog) { this._openingDialog = null; return; }
if (this._spaceDialog && !this._roomDialog) {
// same semantics as the dialog's Cancel: an import queue is abandoned
this._spaceDialog = null;
this._importQueue = [];
this._importTotal = 0;
return;
}
}
if (!this._markup) return;
const undo = e.key === 'Escape' || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z');
@@ -379,6 +414,11 @@ class HouseplanCard extends LitElement {
return this._rulesCompiled;
}
/** Global fill palette (config.settings.fill_colors over the defaults). */
private get _fillColors(): FillColors {
return fillColorsOf(this._settings);
}
private get _excluded(): Set<string> {
const list = this._settings.exclude_integrations;
return list ? new Set(list) : EXCLUDED_DOMAINS;
@@ -527,6 +567,51 @@ class HouseplanCard extends LitElement {
iconRules: this._iconRules,
});
this._defPos = this._defaultPositions();
this._syncNewDevices();
}
/**
* "New device" flag (server-side, shared by every client): an auto device
* that appears after the known baseline was recorded gets a red dot until
* someone opens its editor. The baseline is seeded silently on first run,
* so an upgrade never floods the plan with dots.
*/
private _syncNewDevices(): void {
if (!this._norm || !this._loadOk || !this._serverCfg) return;
// only auto-appearing icons: area devices and light groups; markers are user-made
const autoIds = this._devices.filter((d) => !d.marker && !d.virtual).map((d) => d.id).sort();
const key = autoIds.join(',');
if (key === this._newSyncKey) return; // same registry picture — nothing to do
this._newSyncKey = key;
const st: any = this._settings;
const { fresh, known } = diffNewDevices(autoIds, st.known_devices);
if (!Array.isArray(st.known_devices) || fresh.length) {
const newIds = [...new Set([...(st.new_device_ids || []), ...fresh])];
this._serverCfg = {
...this._serverCfg,
settings: { ...st, known_devices: known, new_device_ids: newIds },
};
// best-effort persist: non-admins under admin_only just keep the local view
this._saveConfig();
}
}
/** Ids currently flagged as new (drawn with the red dot). */
private get _newIds(): Set<string> {
const list = (this._settings as any).new_device_ids;
return new Set(Array.isArray(list) ? list : []);
}
/** First visit to the device's editor acknowledges its "new" flag. */
private _ackNewDevice(id: string): void {
if (!this._newIds.has(id) || !this._serverCfg) return;
const st: any = this._settings;
this._serverCfg = {
...this._serverCfg,
settings: { ...st, new_device_ids: (st.new_device_ids || []).filter((x: string) => x !== id) },
};
this._saveConfig();
this.requestUpdate();
}
/** Curation + light groups + overrides + virtual devices. */
@@ -613,9 +698,10 @@ class HouseplanCard extends LitElement {
const gx = Math.round(x / g) * g;
const gy = Math.round(y / g) * g;
const aspect = this._serverCfg!.spaces.find((s: any) => s.id === d.space)?.aspect || 1;
const prevK = (this._layout[d.id] as any)?.k;
this._layout = {
...this._layout,
[d.id]: { s: d.space, x: gx / NORM_W, y: gy / (NORM_W / aspect) },
[d.id]: { s: d.space, x: gx / NORM_W, y: gy / (NORM_W / aspect), ...(prevK ? { k: prevK } : {}) },
};
} else {
this._layout = { ...this._layout, [d.id]: { x: Math.round(x), y: Math.round(y) } };
@@ -673,7 +759,11 @@ class HouseplanCard extends LitElement {
private _clickDevice(ev: MouseEvent, d: DevItem): void {
ev.stopPropagation();
if (this._drag?.moved || this._suppressClick || this._holdFired) return;
if (this._markup) return;
if (this._mode === 'plan') return;
if (this._mode === 'devices') {
this._openMarkerDialog(d);
return;
}
const domain = d.primary ? d.primary.split('.')[0] : null;
const action = resolveTapAction(d.tapAction, this._config?.tap_action, domain);
if (action === 'toggle' && d.primary) {
@@ -816,7 +906,7 @@ class HouseplanCard extends LitElement {
private _stagePointerDown(ev: PointerEvent): void {
// do not interfere with icon dragging and markup drawing
if (this._drag || this._markup) return;
if ((ev.target as HTMLElement).closest('.dev')) return;
if (this._mode === 'devices' && (ev.target as HTMLElement).closest('.dev')) return;
this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
const v = this._viewOr(this._spaceModel().vb);
if (this._pointers.size === 1) {
@@ -849,7 +939,10 @@ class HouseplanCard extends LitElement {
} else if (this._panStart) {
const ddx = ev.clientX - this._panStart.sx;
const ddy = ev.clientY - this._panStart.sy;
if (Math.abs(ddx) + Math.abs(ddy) > 4) this._suppressClick = true;
if (Math.abs(ddx) + Math.abs(ddy) > 4) {
this._suppressClick = true;
clearTimeout(this._holdTimer);
}
if (this._zoom > 1 && this._view) {
const stage = this._stageEl!;
const v = this._view;
@@ -883,23 +976,23 @@ class HouseplanCard extends LitElement {
}
private _pointerDown(ev: PointerEvent, d: DevItem): void {
if (this._markup) return; // in markup mode icons are not dragged
if (this._mode === 'plan') return; // icons are hidden in plan mode anyway
if (this._mode === 'view') {
// view: no drag, no capture — panning may start on an icon; only the
// long-press timer runs (cancelled by stage movement)
this._holdFired = false;
clearTimeout(this._holdTimer);
this._holdTimer = window.setTimeout(() => {
this._holdFired = true;
this._infoCard = d;
}, 600);
return;
}
ev.preventDefault();
const p = this._pos(d);
this._drag = { id: d.id, sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false };
(ev.target as HTMLElement).setPointerCapture(ev.pointerId);
this._tip = null;
// long-press always opens the info card — keeps metadata reachable
// even when the tap action is set to toggle
this._holdFired = false;
clearTimeout(this._holdTimer);
this._holdTimer = window.setTimeout(() => {
if (this._drag && this._drag.id === d.id && !this._drag.moved) {
this._holdFired = true;
this._drag = null;
this._infoCard = d;
}
}, 600);
}
private _pointerMove(ev: PointerEvent, d: DevItem): void {
@@ -989,12 +1082,13 @@ class HouseplanCard extends LitElement {
return roomEdges(sp?.rooms || []).map((s) => [s[0] * NORM_W, s[1] * H, s[2] * NORM_W, s[3] * H]);
}
private _toggleMarkup(): void {
if (!this._norm) {
private _setMode(mode: 'view' | 'plan' | 'devices'): void {
if (this._mode === mode) return;
if (mode === 'plan' && !this._norm) {
this._showToast(this._t('toast.markup_needs_server'));
return;
}
this._markup = !this._markup;
this._mode = mode;
this._path = [];
this._cursorPt = null;
this._tool = 'draw';
@@ -1002,8 +1096,11 @@ class HouseplanCard extends LitElement {
this._mergeDialog = null;
this._splitSel = null;
this._pendingSplit = null;
this._selId = null;
this._tip = null;
}
private _svgPoint(ev: MouseEvent): number[] {
const stage = this.renderRoot.querySelector('.stage') as HTMLElement;
const r = stage.getBoundingClientRect();
@@ -1074,6 +1171,13 @@ class HouseplanCard extends LitElement {
private _markupClick(ev: MouseEvent): void {
if (!this._markup) return;
// Room cards swallow markup clicks: dragging, resizing or just clicking a
// card must not feed the active tool (draw point, delete room, merge/split
// pick, opening placement). The drag itself already stops pointer events,
// but the synthesized `click` afterwards still bubbles to the stage.
if (this._drag || this._rlResize) return;
const path = (ev.composedPath?.() || []) as any[];
if (path.some((n) => n?.classList?.contains?.('roomlabel') || n?.classList?.contains?.('rlhandle'))) return;
const raw = this._svgPoint(ev);
if (this._tool === 'delroom') {
const space = this._spaceModel();
@@ -1189,7 +1293,7 @@ class HouseplanCard extends LitElement {
/** Drag an opening along the walls (view mode): it re-snaps continuously. */
private _opPointerDown(ev: PointerEvent, o: OpeningCfg): void {
if (this._markup) return;
if (this._mode !== 'plan') return;
ev.preventDefault();
ev.stopPropagation();
try {
@@ -1227,25 +1331,10 @@ class HouseplanCard extends LitElement {
/** Click: the status card (delayed so a double click can cancel it). */
private _opClick(ev: MouseEvent, o: OpeningCfg & { rx: number; ry: number; rlen: number }): void {
ev.stopPropagation();
if (this._markup) return;
if (this._opDrag?.moved) return; // that click was the tail of a drag
if (this._opClickTimer) window.clearTimeout(this._opClickTimer);
this._opClickTimer = window.setTimeout(() => {
this._opClickTimer = null;
this._openingInfo = o;
}, 250);
}
/** Double click: the properties dialog. */
private _opDblClick(ev: MouseEvent, o: OpeningCfg & { rx: number; ry: number; rlen: number }): void {
ev.stopPropagation();
if (this._markup) return;
if (this._opClickTimer) {
window.clearTimeout(this._opClickTimer);
this._opClickTimer = null;
}
this._openingInfo = null;
this._editOpening(o);
// openings are inert outside Plan mode (owner's decision: View must not
// interact with them at all); in Plan any click on an opening edits it
if (this._mode === 'plan') this._editOpening(o);
}
private _saveOpening(): void {
@@ -1530,6 +1619,7 @@ class HouseplanCard extends LitElement {
// ================= DEVICE EDITOR (markers) =================
private _openMarkerDialog(d?: DevItem): void {
if (d) this._ackNewDevice(d.id);
if (!this._norm) {
this._showToast(this._t('toast.marker_needs_server'));
return;
@@ -1551,7 +1641,9 @@ class HouseplanCard extends LitElement {
link: d.link || '',
description: d.description || '',
pdfs: [...(d.pdfs || [])],
room: d.space && d.area ? d.space + '#' + d.area : '',
room: d.marker?.room_id
? d.space + '#@' + d.marker.room_id
: d.space && d.area ? d.space + '#' + d.area : '',
busy: false,
};
} else {
@@ -1637,8 +1729,15 @@ class HouseplanCard extends LitElement {
const res: { value: string; label: string }[] = [];
for (const sp of this._serverCfg?.spaces || []) {
for (const r of sp.rooms || []) {
if (!r.area) continue;
res.push({ value: sp.id + '#' + r.area, label: (sp.title || sp.id) + ' · ' + r.name });
if (r.area) {
res.push({ value: sp.id + '#' + r.area, label: (sp.title || sp.id) + ' · ' + r.name });
} else if (r.id) {
// sub-area room (no HA area): manual placement by room id — issue #3
res.push({
value: sp.id + '#@' + r.id,
label: (sp.title || sp.id) + ' · ' + r.name + ' · ' + this._t('marker.subarea'),
});
}
}
}
return res;
@@ -1727,9 +1826,10 @@ class HouseplanCard extends LitElement {
// determine the marker id
let id: string;
// a manually chosen room overrides the space/area for any icon
const [roomSp, roomAr] = dlg.room ? dlg.room.split('#') : ['', ''];
let space: string | null = roomSp || null;
let area: string | null = roomAr || null;
const roomRef = parseRoomRef(dlg.room);
let space: string | null = roomRef?.space || null;
let area: string | null = roomRef?.area || null;
const roomId: string | null = roomRef?.roomId || null;
if (dlg.binding === 'virtual' && !space) space = this._space;
id = markerIdForBinding(dlg.binding, dlg.devId, () => 'v_' + Date.now().toString(36));
const oldId = dlg.devId;
@@ -1753,10 +1853,13 @@ class HouseplanCard extends LitElement {
if (dlg.binding === 'virtual' || dlg.room) {
marker.space = space;
marker.area = area;
marker.room_id = roomId;
}
// the room changed → move the icon to its center
const prevDev = oldId ? this._devices.find((x) => x.id === oldId) : null;
const roomChanged = !!dlg.room && prevDev != null && (prevDev.space !== space || prevDev.area !== area);
const prevRoomId = prevDev?.marker?.room_id ?? null;
const roomChanged = !!dlg.room && prevDev != null
&& (prevDev.space !== space || prevDev.area !== area || prevRoomId !== roomId);
// remove the previous marker (by the old id and by the new id)
cfg.markers = cfg.markers.filter((m) => m.id !== id && m.id !== oldId);
cfg.markers.push(marker);
@@ -1768,10 +1871,12 @@ class HouseplanCard extends LitElement {
const spm = this._spaceModel(space || undefined);
let cx = spm.vb[0] + spm.vb[2] / 2;
let cy = spm.vb[1] + spm.vb[3] / 2;
if (area) {
const room = spm.rooms.find((r) => r.area === area);
if (room) [cx, cy] = this._roomCenter(room);
}
const room = roomId
? spm.rooms.find((r) => r.id === roomId)
: area
? spm.rooms.find((r) => r.area === area)
: undefined;
if (room) [cx, cy] = this._roomCenter(room);
newPos = this._normPos(space || this._space, cx, cy);
this._layout = { ...this._layout, [id]: newPos };
}
@@ -1853,6 +1958,9 @@ class HouseplanCard extends LitElement {
showBorders: disp.showBorders, showNames: disp.showNames,
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill,
tempMin: disp.tempMin, tempMax: disp.tempMax,
showLqi: disp.showLqi ?? this._config?.show_signal ?? true,
labelTemp: disp.labelTemp, labelHum: disp.labelHum,
labelLqi: disp.labelLqi, labelLight: disp.labelLight,
cellCm: Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5,
busy: false,
};
@@ -1863,6 +1971,8 @@ class HouseplanCard extends LitElement {
showBorders: false, showNames: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
showLqi: this._config?.show_signal ?? true,
labelTemp: false, labelHum: false, labelLqi: false, labelLight: false,
cellCm: 5,
busy: false,
};
@@ -1946,6 +2056,11 @@ class HouseplanCard extends LitElement {
fill_mode: d.fillMode,
temp_min: Number.isFinite(d.tempMin) ? Math.min(d.tempMin, d.tempMax) : DEFAULT_TEMP_MIN,
temp_max: Number.isFinite(d.tempMax) ? Math.max(d.tempMin, d.tempMax) : DEFAULT_TEMP_MAX,
show_lqi: d.showLqi,
label_temp: d.labelTemp,
label_hum: d.labelHum,
label_lqi: d.labelLqi,
label_light: d.labelLight,
};
sp.cell_cm = Number.isFinite(d.cellCm) && d.cellCm > 0 ? d.cellCm : 5;
await this._saveConfigNow();
@@ -1960,7 +2075,7 @@ class HouseplanCard extends LitElement {
// guide the user onward: straight into room markup mode
this._importTotal = 0;
this._space = this._serverCfg!.spaces[0]?.id || this._space;
this._markup = true;
this._mode = 'plan';
this._tool = 'draw';
this._path = [];
this._cursorPt = null;
@@ -2037,6 +2152,8 @@ class HouseplanCard extends LitElement {
showBorders: false, showNames: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
showLqi: this._config?.show_signal ?? true,
labelTemp: false, labelHum: false, labelLqi: false, labelLight: false,
cellCm: 5,
busy: false,
};
@@ -2049,7 +2166,7 @@ class HouseplanCard extends LitElement {
else if (this._importTotal > 0 && this._model.length) {
this._importTotal = 0;
this._space = this._serverCfg!.spaces[0]?.id || this._space;
this._markup = true;
this._mode = 'plan';
this._showToast(this._t('import.done'));
}
}
@@ -2088,6 +2205,86 @@ class HouseplanCard extends LitElement {
</div>`;
}
// ================= GENERAL SETTINGS =================
private _openSettingsDialog = (): void => {
if (!this._norm) return;
// deep copy so the dialog edits do not leak into the live palette
this._settingsDialog = { colors: JSON.parse(JSON.stringify(this._fillColors)), busy: false };
};
private _setFillColor(key: keyof FillColors, patch: Partial<{ c: string; a: number }>): void {
const d = this._settingsDialog!;
this._settingsDialog = { ...d, colors: { ...d.colors, [key]: { ...d.colors[key], ...patch } } };
}
private async _saveSettingsDialog(): Promise<void> {
const d = this._settingsDialog;
if (!d || d.busy) return;
this._settingsDialog = { ...d, busy: true };
try {
const cfg = this._serverCfg!;
const isDefault = JSON.stringify(d.colors) === JSON.stringify(DEFAULT_FILL_COLORS);
const settings: any = { ...cfg.settings };
if (isDefault) delete settings.fill_colors;
else settings.fill_colors = d.colors;
this._serverCfg = { ...cfg, settings };
await this._saveConfigNow();
this._settingsDialog = null;
this.requestUpdate();
this._showToast(this._t('gs.saved'));
} catch (e: any) {
this._settingsDialog = { ...this._settingsDialog!, busy: false };
this._showToast(this._t('toast.error', { err: this._errText(e) }));
}
}
private _renderColorRow(key: keyof FillColors, labelKey: string): TemplateResult {
const d = this._settingsDialog!;
const v = d.colors[key];
return html`<div class="colorrow gsrow">
<span class="gsl">${this._t(labelKey as any)}</span>
<input type="color" .value=${v.c}
@input=${(e: Event) => this._setFillColor(key, { c: (e.target as HTMLInputElement).value })} />
<input type="range" min="0" max="100" .value=${String(Math.round(v.a * 100))}
@input=${(e: Event) => this._setFillColor(key, { a: Number((e.target as HTMLInputElement).value) / 100 })} />
<span class="opv">${Math.round(v.a * 100)}%</span>
</div>`;
}
private _renderSettingsDialog(): TemplateResult {
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog wide" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:cog-outline"></ha-icon>${this._t('gs.title')}</div>
<div class="body">
<div class="rhint">${this._t('gs.hint')}</div>
<label class="dispsection">${this._t('gs.light_group')}</label>
${this._renderColorRow('light_on', 'gs.light_on')}
${this._renderColorRow('light_off', 'gs.light_off')}
${this._renderColorRow('light_none', 'gs.light_none')}
<label class="dispsection">${this._t('gs.temp_group')}</label>
${this._renderColorRow('temp_cold', 'gs.temp_cold')}
${this._renderColorRow('temp_ok', 'gs.temp_ok')}
${this._renderColorRow('temp_hot', 'gs.temp_hot')}
<label class="dispsection">${this._t('gs.lqi_group')}</label>
${this._renderColorRow('lqi_low', 'gs.lqi_low')}
${this._renderColorRow('lqi_high', 'gs.lqi_high')}
</div>
<div class="row">
<button class="btn ghost" @click=${() =>
(this._settingsDialog = { ...this._settingsDialog!, colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)) })}>
${this._t('gs.reset')}
</button>
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._settingsDialog = null)}>${this._t('btn.cancel')}</button>
<button class="btn on" @click=${this._saveSettingsDialog} ?disabled=${this._settingsDialog!.busy}>
<ha-icon icon="mdi:check"></ha-icon>${this._settingsDialog!.busy ? '…' : this._t('btn.save')}
</button>
</div>
</div>
</div>`;
}
// ================= ICON RULES EDITOR =================
private _openRulesDialog = (): void => {
@@ -2221,6 +2418,7 @@ class HouseplanCard extends LitElement {
const vb = space.vb;
const devs = this._devices.filter((d) => d.space === space.id);
const disp = spaceDisplayOf(this._curSpaceCfg);
const showLqi = disp.showLqi ?? this._config.show_signal ?? true;
const cfgSize = this._config.icon_size ?? 2.5;
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
const view = this._viewOr(vb);
@@ -2243,8 +2441,8 @@ class HouseplanCard extends LitElement {
this._restoreZoom();
}}
>
${s.title}${this._norm
? html`<ha-icon class="tabedit" icon="mdi:pencil"
${s.title}${this._norm && this._canEdit
? html`<ha-icon class="tabedit" icon="mdi:cog-outline"
title=${this._t('title.configure_space')}
@click=${(e: Event) => {
e.stopPropagation();
@@ -2253,13 +2451,28 @@ class HouseplanCard extends LitElement {
: nothing}
</button>`,
)}
${this._norm
${this._norm && this._mode === 'plan'
? html`<button class="tab tabadd" title=${this._t('title.add_space')}
@click=${() => this._openSpaceDialog('create')}>
<ha-icon icon="mdi:plus"></ha-icon>
</button>`
: nothing}
</div>
${this._canEdit
? html`<div class="modes">
${([['plan', 'mdi:floor-plan'], ['devices', 'mdi:tune-variant']] as const).map(
([m, ic]) => html`<button class="modetab ${this._mode === m ? 'active' : ''}"
title=${this._t(('mode.' + m) as any)}
@click=${() => { if (this._mode !== m) this._setMode(m); }}>
<ha-icon icon=${ic}></ha-icon><span class="ml">${this._t(('mode.' + m) as any)}</span>
${this._mode === m
? html`<ha-icon class="closex" icon="mdi:close" title=${this._t('title.close_editor')}
@click=${(e: Event) => { e.stopPropagation(); this._setMode('view'); }}></ha-icon>`
: nothing}
</button>`,
)}
</div>`
: nothing}
<span class="count">${this._t('count.devices', { n: devs.length })}</span>
<span class="spacer"></span>
<div class="zoomctl">
@@ -2268,33 +2481,16 @@ class HouseplanCard extends LitElement {
title=${this._t('title.zoom_reset')}><ha-icon icon="mdi:fit-to-page-outline"></ha-icon></button>
<button class="btn zb" @click=${() => this._stepZoom(1)} title=${this._t('title.zoom_in')}><ha-icon icon="mdi:plus"></ha-icon></button>
</div>
${this._norm
? html`<button class="btn" @click=${() => this._openMarkerDialog()}
title=${this._t('title.add_device')}>
<ha-icon icon="mdi:plus-box-outline"></ha-icon>
${this._norm && this._canEdit
? html`<button class="btn" @click=${this._openSettingsDialog} title=${this._t('title.general_settings')}>
<ha-icon icon="mdi:cog-outline"></ha-icon>
</button>`
: nothing}
${this._norm
? html`<button class="btn ${this._showAll ? 'on' : ''}" @click=${this._toggleShowAll}
title=${this._t('title.show_all')}>
<ha-icon icon="${this._showAll ? 'mdi:eye' : 'mdi:eye-off-outline'}"></ha-icon>
</button>
<button class="btn" @click=${this._resetLayout} title=${this._t('title.reset_layout')}>
<ha-icon icon="mdi:backup-restore"></ha-icon>
</button>
<button class="btn" @click=${this._openRulesDialog} title=${this._t('title.icon_rules')}>
<ha-icon icon="mdi:shape-plus-outline"></ha-icon>
</button>`
: nothing}
<button class="btn ${this._markup ? 'on' : ''}" @click=${this._toggleMarkup}
title=${this._t('title.markup')}>
<ha-icon icon="mdi:vector-square-edit"></ha-icon>
</button>
</div>
${this._markup ? this._renderMarkupBar() : nothing}
${this._markup ? this._renderMarkupBar() : this._mode === 'devices' ? this._renderDevicesBar() : nothing}
</div>
<div class="stage ${this._markup ? 'markup' : ''} ${space.bg ? '' : 'noplan'}"
<div class="stage ${this._markup ? 'markup' : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}"
style="height:calc(100dvh - 118px)"
@click=${(e: MouseEvent) => this._markupClick(e)}
@wheel=${(e: WheelEvent) => this._onWheel(e)}
@@ -2319,26 +2515,27 @@ class HouseplanCard extends LitElement {
// keep the stroke colour even when borders are hidden, so hover can reveal it
st.push(`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`);
const fillC = r.area
? roomFillColor(
? roomFillStyle(
disp.fill,
disp.fill === 'lqi' ? this._roomLqi(r.area) : null,
disp.fill === 'light' ? areaLights(this.hass, this._devices, r.area) : 'none',
disp.fill === 'temp' ? areaTemp(this.hass, this._devices, r.area) : null,
disp.tempMin,
disp.tempMax,
this._fillColors,
)
: null;
if (fillC) {
cls += ' filled';
st.push(`--room-fill:${fillC}`, `--room-fill-op:${(0.3 * disp.opacity).toFixed(3)}`);
st.push(`--room-fill:${fillC.c}`, `--room-fill-op:${fillC.a.toFixed(3)}`);
} else st.push('--room-fill:transparent', '--room-fill-op:0');
style = st.join(';');
}
const tip = (e: MouseEvent) =>
this._showTip(e, r.name, this._t('tip.room'),
this._config?.show_signal ? this._roomLqi(r.area) : null,
showLqi ? this._roomLqi(r.area) : null,
r.area ? areaTemp(this.hass, this._devices, r.area) : null);
const label = (!space.bg && !disp.showNames) || this._markup;
const label = !space.bg && !disp.showNames && !this._markup;
const c = this._roomCenter(r);
const shape = r.poly
? svg`<polygon class="${cls}" style="${style}" points="${r.poly.map((p) => p.join(',')).join(' ')}"
@@ -2354,9 +2551,9 @@ class HouseplanCard extends LitElement {
${this._renderOpenings(disp)}
</svg>
<div class="devlayer" style="--icon-size:${((iconPct * vb[2]) / view.w).toFixed(3)}cqw">
${devs.map((d) => this._renderDevice(d, view))}
${devs.map((d) => this._renderDevice(d, view, showLqi))}
${this._renderOpeningLocks(view)}
${disp.showNames && !this._markup
${disp.showNames || this._markup
? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp))
: nothing}
</div>
@@ -2377,6 +2574,7 @@ class HouseplanCard extends LitElement {
${this._markerDialog ? this._renderMarkerDialog() : nothing}
${this._infoCard ? this._renderInfoCard() : nothing}
${this._rulesDialog ? this._renderRulesDialog() : nothing}
${this._settingsDialog ? this._renderSettingsDialog() : nothing}
${this._importDialog ? this._renderImportDialog() : nothing}
${this._tip
? html`<div class="tip" style="left:${this._tip.x + 12}px;top:${this._tip.y + 12}px">
@@ -2395,17 +2593,36 @@ class HouseplanCard extends LitElement {
`;
}
private _renderDevice(d: DevItem, view: { x: number; y: number; w: number; h: number }): TemplateResult {
private _renderDevice(d: DevItem, view: { x: number; y: number; w: number; h: number }, showLqi = true): TemplateResult {
const p = this._pos(d);
const left = ((p.x - view.x) / view.w) * 100;
const top = ((p.y - view.y) / view.h) * 100;
const cls = this._stateClass(d);
const temp = this._liveTemp(d);
const hum = this._liveHum(d);
const lqi = this._config?.show_signal && !d.virtual ? lqiFor(this.hass, d.entities) : null;
const lqi = showLqi && !d.virtual ? lqiFor(this.hass, d.entities) : null;
const m = d.marker;
const disp = m?.display || 'badge';
const ripple = disp === 'ripple' || disp === 'icon_ripple';
// value-only display: the measurement IS the marker
const primarySt = d.primary ? this.hass.states[d.primary] : undefined;
const valText = disp === 'value'
? (temp != null ? temp + '°'
: hum != null ? hum + '%'
: primarySt && !isNaN(parseFloat(primarySt.state))
? parseFloat(primarySt.state) + (primarySt.attributes?.unit_of_measurement ? ' ' + primarySt.attributes.unit_of_measurement : '')
: null)
: null;
// live state variants of the auto icon (doors, locks, bulbs), like core HA
const domain = d.primary ? d.primary.split('.')[0] : null;
const icon = this._config?.live_states
? stateIcon(d.icon, domain, primarySt?.attributes?.device_class, primarySt?.state, !!m?.icon)
: d.icon;
// RGB lights color the icon (and the ripple, unless a custom ripple color is set)
const lightC = this._config?.live_states && domain === 'light' ? lightColorOf(primarySt) : null;
// emergencies (leak/smoke/gas/CO/siren) pulse red regardless of display mode
const alarm = this._config?.live_states
&& isAlarmState(domain, primarySt?.attributes?.device_class, primarySt?.state);
const active = ripple && !!d.primary && isActiveState(this.hass.states[d.primary]?.state);
const scale = Number(m?.size) > 0 ? Number(m!.size) : 1;
const angle = Number(m?.angle) || 0;
@@ -2415,9 +2632,11 @@ class HouseplanCard extends LitElement {
if (ripple) {
st.push(`--ripple-scale:${rScale}`);
if (m?.ripple_color) st.push(`--ripple-color:${m.ripple_color}`);
else if (lightC) st.push(`--ripple-color:${lightC}`);
}
if (lightC) st.push(`--light-color:${lightC}`);
return html`<div
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''} ${disp === 'ripple' ? 'noicon' : ''}"
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''} ${disp === 'ripple' ? 'noicon' : ''} ${valText != null ? 'valonly' : ''} ${lightC ? 'rgb' : ''} ${alarm ? 'alarm' : ''}"
style="${st.join(';')}"
@click=${(e: MouseEvent) => this._clickDevice(e, d)}
@mousemove=${(e: MouseEvent) =>
@@ -2432,11 +2651,14 @@ class HouseplanCard extends LitElement {
${ripple
? html`<span class="ripple ${active ? 'active' : ''}"><i></i><i></i><i></i></span>`
: nothing}
${disp !== 'ripple'
? html`<ha-icon icon="${d.icon}" style=${angle ? `transform:rotate(${angle}deg)` : nothing}></ha-icon>`
: nothing}
${temp != null ? html`<span class="tval">${temp}°</span>` : nothing}
${hum != null ? html`<span class="hval">${hum}%</span>` : nothing}
${this._newIds.has(d.id) ? html`<span class="newdot" title=${this._t('device.new')}></span>` : nothing}
${valText != null
? html`<span class="valtext">${valText}</span>`
: disp !== 'ripple'
? html`<ha-icon icon="${icon}" style=${angle ? `transform:rotate(${angle}deg)` : nothing}></ha-icon>`
: nothing}
${temp != null && valText == null ? html`<span class="tval">${temp}°</span>` : nothing}
${hum != null && valText == null ? html`<span class="hval">${hum}%</span>` : nothing}
${lqi != null ? html`<span class="lqi" style="color:${lqiColor(lqi)}">${lqi}</span>` : nothing}
</div>`;
}
@@ -2454,7 +2676,7 @@ class HouseplanCard extends LitElement {
/** Room-name labels are dragged exactly like device icons (same layout store). */
private _labelDown(ev: PointerEvent, r: RoomCfg, spaceId: string): void {
if (this._markup) return;
if (this._mode !== 'plan') return;
ev.preventDefault();
ev.stopPropagation();
const p = this._labelPos(r, spaceId);
@@ -2488,6 +2710,57 @@ class HouseplanCard extends LitElement {
if (moved) window.setTimeout(() => (this._drag = null), 0);
}
/** Saved room-card scale (layout key rl_<roomId>, field k), clamped 0.5..3. */
private _labelScale(r: RoomCfg): number {
const k = (this._layout['rl_' + (r.id || '')] as any)?.k;
return typeof k === 'number' && Number.isFinite(k) ? Math.min(3, Math.max(0.5, k)) : 1;
}
private _rlResizeDown(ev: PointerEvent, r: RoomCfg, spaceId: string): void {
if (this._mode !== 'plan') return;
ev.preventDefault();
ev.stopPropagation();
const card = (ev.target as HTMLElement).closest('.roomlabel') as HTMLElement | null;
if (!card) return;
const b = card.getBoundingClientRect();
const cx = b.left + b.width / 2;
const cy = b.top + b.height / 2;
const d0 = Math.max(8, Math.hypot(ev.clientX - cx, ev.clientY - cy));
this._rlResize = { id: 'rl_' + (r.id || ''), space: spaceId, k0: this._labelScale(r), cx, cy, d0 };
(ev.target as HTMLElement).setPointerCapture(ev.pointerId);
}
private _rlResizeMove(ev: PointerEvent): void {
const rs = this._rlResize;
if (!rs) return;
ev.stopPropagation();
const dist = Math.max(8, Math.hypot(ev.clientX - rs.cx, ev.clientY - rs.cy));
const k = Math.min(3, Math.max(0.5, rs.k0 * (dist / rs.d0)));
const rec: any = this._layout[rs.id];
if (!rec) {
// the card was never dragged: pin its current default position first
const roomId = rs.id.slice(3);
const sp = this._spaceModel(rs.space);
const room = sp.rooms.find((x) => x.id === roomId);
if (!room) return;
const p = this._labelPos(room, rs.space);
const aspect = this._serverCfg!.spaces.find((x: any) => x.id === rs.space)?.aspect || 1;
this._layout = {
...this._layout,
[rs.id]: { s: rs.space, x: p.x / NORM_W, y: p.y / (NORM_W / aspect), k },
};
} else {
this._layout = { ...this._layout, [rs.id]: { ...rec, k } };
}
this._dirtyPos.add(rs.id);
}
private _rlResizeUp(): void {
if (!this._rlResize) return;
this._rlResize = null;
this._persistLayout();
}
private _renderRoomLabel(
r: RoomCfg, space: SpaceModel, view: { x: number; y: number; w: number; h: number }, disp: SpaceDisplay,
): TemplateResult | typeof nothing {
@@ -2496,12 +2769,52 @@ class HouseplanCard extends LitElement {
const left = ((p.x - view.x) / view.w) * 100;
const top = ((p.y - view.y) / view.h) * 100;
const op = Math.min(1, disp.opacity + 0.25);
return html`<div class="roomlabel" style="left:${left}%;top:${top}%;color:${disp.color};opacity:${op}"
const k = this._labelScale(r);
// optional metrics row (needs an HA area; sub-area rooms show the name only)
const rows: TemplateResult[] = [];
if (r.area && !this._markup) {
if (disp.labelTemp) {
const t = areaTemp(this.hass, this._devices, r.area);
if (t != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:thermometer"></ha-icon>${t}°</span>`);
}
if (disp.labelHum) {
const hm = areaHum(this.hass, this._devices, r.area);
if (hm != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:water-percent"></ha-icon>${hm}%</span>`);
}
if (disp.labelLqi) {
const l = this._roomLqi(r.area);
if (l != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:zigbee"></ha-icon>${l}</span>`);
}
if (disp.labelLight) {
const ls = areaLightStats(this.hass, this._devices, r.area);
if (ls) {
const txt = ls.on === 0
? this._t('roomcard.light_off')
: ls.on === ls.total
? this._t('roomcard.light_on')
: this._t('roomcard.light_partial', { on: ls.on, total: ls.total });
rows.push(html`<span class="rlm ${ls.on ? 'lit' : ''}"><ha-icon icon=${ls.on ? 'mdi:lightbulb-on' : 'mdi:lightbulb-outline'}></ha-icon>${txt}</span>`);
}
}
}
return html`<div class="roomlabel ${rows.length ? 'card' : ''}"
style="left:${left}%;top:${top}%;color:${disp.color};opacity:${op};--rl-scale:${k}"
@pointerdown=${(e: PointerEvent) => this._labelDown(e, r, space.id)}
@pointermove=${(e: PointerEvent) => this._labelMove(e, r, space.id)}
@pointerup=${() => this._labelUp(r)}
@pointercancel=${() => this._labelUp(r)}
>${r.name}</div>`;
><span class="rlname">${r.name}</span>
${rows.length ? html`<span class="rlmetrics">${rows}</span>` : nothing}
${this._mode === 'plan'
? ['tl', 'tr', 'bl', 'br'].map(
(c) => html`<span class="rlhandle ${c}"
@pointerdown=${(e: PointerEvent) => this._rlResizeDown(e, r, space.id)}
@pointermove=${(e: PointerEvent) => this._rlResizeMove(e)}
@pointerup=${() => this._rlResizeUp()}
@pointercancel=${() => this._rlResizeUp()}></span>`,
)
: nothing}
</div>`;
}
/** Where the live measurement starts: the last outline point, or the first split point. */
@@ -2594,7 +2907,6 @@ class HouseplanCard extends LitElement {
<rect class="op-outline" x="${-half - 10}" y="-16" width="${o.rlen + 20}" height="32" rx="6"></rect>
<rect class="op-hit" x="${-half - 12}" y="-20" width="${o.rlen + 24}" height="40"
@click=${(e: MouseEvent) => this._opClick(e, o)}
@dblclick=${(e: MouseEvent) => this._opDblClick(e, o)}
@pointerdown=${(e: PointerEvent) => this._opPointerDown(e, o)}
@pointermove=${(e: PointerEvent) => this._opPointerMove(e, o)}
@pointerup=${(e: PointerEvent) => this._opPointerUp(e, o)}
@@ -2620,12 +2932,22 @@ class HouseplanCard extends LitElement {
const top = ((py - view.y) / view.h) * 100;
return html`<div class="oplock ${locked ? 'locked' : known ? 'unlocked' : 'unknown'}"
style="left:${left}%;top:${top}%"
@click=${(e: MouseEvent) => { e.stopPropagation(); if (!this._markup) this._openingInfo = o; }}>
@click=${(e: MouseEvent) => { e.stopPropagation(); if (this._mode === 'view') this._openingInfo = o; }}>
<ha-icon icon="${locked ? 'mdi:lock' : known ? 'mdi:lock-open-variant' : 'mdi:lock-question'}"></ha-icon>
</div>`;
})}`;
}
/**
* Explicit lock/unlock from the opening info card. This does NOT violate the
* "locks never toggle from the plan" rule: that rule guards against ACCIDENTAL
* taps on plan icons; here the user has opened the info card and pressed a
* clearly labeled action button — same interaction contract as HA's more-info.
*/
private _lockAction(entityId: string, action: 'lock' | 'unlock'): void {
this.hass?.callService?.('lock', action, { entity_id: entityId });
}
private _renderOpeningInfoCard(): TemplateResult {
const o = this._openingInfo!;
const cSt = o.contact ? this.hass.states[o.contact]?.state : null;
@@ -2654,6 +2976,18 @@ class HouseplanCard extends LitElement {
: this._t('opening.state_unknown'),
lSt === 'locked' ? 'ok' : 'warn')
: nothing}
${o.lock && (lSt === 'locked' || ['unlocked', 'open'].includes(String(lSt)))
? html`<button
class="btn lockact ${lSt === 'locked' ? 'warn' : ''}"
@click=${() => this._lockAction(o.lock!, lSt === 'locked' ? 'unlock' : 'lock')}>
<ha-icon icon=${lSt === 'locked' ? 'mdi:lock-open-variant' : 'mdi:lock'}></ha-icon>
${this._t(lSt === 'locked' ? 'opening.unlock_action' : 'opening.lock_action')}
</button>`
: o.lock && ['locking', 'unlocking'].includes(String(lSt))
? html`<button class="btn lockact" disabled>
<ha-icon icon="mdi:timer-sand"></ha-icon>${this._t('opening.lock_pending')}
</button>`
: nothing}
${!o.contact && !o.lock ? html`<p class="muted">${this._t('opening.no_entities')}</p>` : nothing}
</div>
<div class="row">
@@ -2798,6 +3132,34 @@ class HouseplanCard extends LitElement {
: this._t('markup.hint_start')}</span>
${this._path.length ? html`<button class="btn ghost" @click=${this._cancelPath}>${this._t('btn.reset')}</button>` : nothing}`
: nothing}
<button class="btn barclose" title=${this._t('title.close_editor')}
@click=${() => this._setMode('view')}>
<ha-icon icon="mdi:close"></ha-icon>
</button>
</div>`;
}
private _renderDevicesBar(): TemplateResult {
return html`<div class="editbar devbar">
<ha-icon icon="mdi:tune-variant" class="warn"></ha-icon>
<button class="btn" @click=${() => this._openMarkerDialog()} title=${this._t('title.add_device')}>
<ha-icon icon="mdi:plus-box-outline"></ha-icon>${this._t('devbar.add')}
</button>
<button class="btn ${this._showAll ? 'on' : ''}" @click=${this._toggleShowAll}
title=${this._t('title.show_all')}>
<ha-icon icon="${this._showAll ? 'mdi:eye' : 'mdi:eye-off-outline'}"></ha-icon>${this._t('devbar.show_all')}
</button>
<button class="btn" @click=${this._resetLayout} title=${this._t('title.reset_layout')}>
<ha-icon icon="mdi:backup-restore"></ha-icon>${this._t('devbar.reset')}
</button>
<button class="btn" @click=${this._openRulesDialog} title=${this._t('title.icon_rules')}>
<ha-icon icon="mdi:shape-plus-outline"></ha-icon>${this._t('devbar.rules')}
</button>
<span class="spacer"></span>
<button class="btn barclose" title=${this._t('title.close_editor')}
@click=${() => this._setMode('view')}>
<ha-icon icon="mdi:close"></ha-icon>
</button>
</div>`;
}
@@ -2917,11 +3279,11 @@ class HouseplanCard extends LitElement {
<label>${this._t('marker.display_label')}</label>
<select class="areasel"
@change=${(e: Event) => (this._markerDialog = { ...d, display: (e.target as HTMLSelectElement).value as any })}>
${[['badge', 'display.badge'], ['ripple', 'display.ripple'], ['icon_ripple', 'display.icon_ripple']].map(
${[['badge', 'display.badge'], ['ripple', 'display.ripple'], ['icon_ripple', 'display.icon_ripple'], ['value', 'display.value']].map(
([v, k]) => html`<option value=${v} ?selected=${d.display === v}>${this._t(k as any)}</option>`,
)}
</select>
${d.display !== 'badge'
${d.display === 'ripple' || d.display === 'icon_ripple'
? html`<div class="colorrow">
<input type="color" .value=${d.rippleColor || '#3ea6ff'}
@input=${(e: Event) => (this._markerDialog = { ...d, rippleColor: (e.target as HTMLInputElement).value })} />
@@ -3061,6 +3423,20 @@ class HouseplanCard extends LitElement {
@change=${(e: Event) => (this._spaceDialog = { ...d, showNames: (e.target as HTMLInputElement).checked })} />
<span>${this._t('space.show_names')}</span>
</label>
<label class="srcrow">
<input type="checkbox" .checked=${d.showLqi}
@change=${(e: Event) => (this._spaceDialog = { ...d, showLqi: (e.target as HTMLInputElement).checked })} />
<span>${this._t('space.show_lqi')}</span>
</label>
<label class="dispsection">${this._t('space.roomcard_section')}</label>
${([['labelTemp', 'space.label_temp'], ['labelHum', 'space.label_hum'],
['labelLqi', 'space.label_lqi'], ['labelLight', 'space.label_light']] as const).map(
([f, k]) => html`<label class="srcrow">
<input type="checkbox" .checked=${d[f]}
@change=${(e: Event) => (this._spaceDialog = { ...d, [f]: (e.target as HTMLInputElement).checked })} />
<span>${this._t(k)}</span>
</label>`,
)}
<label>${this._t('space.room_color')}</label>
<div class="colorrow">
<input type="color" .value=${d.roomColor}
+39 -1
View File
@@ -222,5 +222,43 @@
"editor.button_label": "Button label",
"editor.button_target": "Target dashboard path",
"editor.aspect_ratio": "Aspect ratio (e.g. 16:9 or auto)",
"marker.sub_entity": "entity"
"marker.sub_entity": "entity",
"title.general_settings": "General settings",
"gs.title": "General settings",
"gs.hint": "Fill colors apply to every space; each color has its own opacity. Which fill mode a space uses is set in that space's dialog.",
"gs.light_group": "Fill: lights",
"gs.light_on": "Lights on",
"gs.light_off": "All lights off",
"gs.temp_group": "Fill: temperature",
"gs.temp_cold": "Cold",
"gs.temp_ok": "Comfortable",
"gs.temp_hot": "Hot",
"gs.lqi_group": "Fill: zigbee signal",
"gs.lqi_low": "Weak signal",
"gs.lqi_high": "Strong signal",
"gs.reset": "Reset to defaults",
"gs.saved": "General settings saved",
"space.show_lqi": "Show zigbee signal (LQI) next to devices",
"gs.light_none": "No light sources",
"mode.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.reset": "Reset",
"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}"
}
+39 -1
View File
@@ -222,5 +222,43 @@
"editor.button_label": "Текст кнопки",
"editor.button_target": "Путь дашборда (куда вести)",
"editor.aspect_ratio": "Соотношение сторон (напр. 16:9 или auto)",
"marker.sub_entity": "сущность"
"marker.sub_entity": "сущность",
"title.general_settings": "Общие настройки",
"gs.title": "Общие настройки",
"gs.hint": "Цвета заливок действуют на все пространства; у каждого цвета своя прозрачность. Какой режим заливки использует пространство — задаётся в его диалоге.",
"gs.light_group": "Заливка: освещение",
"gs.light_on": "Свет включён",
"gs.light_off": "Весь свет выключен",
"gs.temp_group": "Заливка: температура",
"gs.temp_cold": "Холодно",
"gs.temp_ok": "Комфорт",
"gs.temp_hot": "Жарко",
"gs.lqi_group": "Заливка: зигби-сигнал",
"gs.lqi_low": "Слабый сигнал",
"gs.lqi_high": "Сильный сигнал",
"gs.reset": "Сбросить к умолчаниям",
"gs.saved": "Общие настройки сохранены",
"space.show_lqi": "Показывать зигби-сигнал (LQI) у устройств",
"gs.light_none": "Нет источников света",
"mode.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.reset": "Сброс",
"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}"
}
+209
View File
@@ -495,6 +495,13 @@ export interface SpaceDisplay {
fill: RoomFillMode;
tempMin: number; // comfort range lower bound, °C
tempMax: number; // comfort range upper bound, °C
/** Per-space LQI badges near zigbee devices; null = follow the card option. */
showLqi: boolean | null;
/** Room-card metrics under the room name (all default off). */
labelTemp: boolean;
labelHum: boolean;
labelLqi: boolean;
labelLight: boolean;
}
export const DEFAULT_ROOM_COLOR = '#3ea6ff';
@@ -514,9 +521,111 @@ export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
fill: ['lqi', 'light', 'temp'].includes(s.fill_mode) ? s.fill_mode : 'none',
tempMin: typeof s.temp_min === 'number' ? s.temp_min : DEFAULT_TEMP_MIN,
tempMax: typeof s.temp_max === 'number' ? s.temp_max : DEFAULT_TEMP_MAX,
showLqi: typeof s.show_lqi === 'boolean' ? s.show_lqi : null,
labelTemp: s.label_temp === true,
labelHum: s.label_hum === true,
labelLqi: s.label_lqi === true,
labelLight: s.label_light === true,
};
}
// ---------------- global fill colors ----------------
export interface FillColorEntry {
c: string; // #rrggbb
a: number; // 0..1 fill opacity
}
/** Global fill palette, grouped by fill mode; stored in config.settings.fill_colors. */
export interface FillColors {
light_on: FillColorEntry;
light_off: FillColorEntry;
/** Rooms with no light sources at all; alpha 0 (default) = no fill, as before. */
light_none: FillColorEntry;
temp_cold: FillColorEntry;
temp_ok: FillColorEntry;
temp_hot: FillColorEntry;
lqi_low: FillColorEntry;
lqi_high: FillColorEntry;
}
export const DEFAULT_FILL_COLORS: FillColors = {
light_on: { c: '#ffd45c', a: 0.18 },
light_off: { c: '#9aa0a6', a: 0.14 },
light_none: { c: '#6b7480', a: 0 },
temp_cold: { c: '#4fc3f7', a: 0.18 },
temp_ok: { c: '#66d17a', a: 0.18 },
temp_hot: { c: '#ffd45c', a: 0.18 },
lqi_low: { c: '#f25a4a', a: 0.18 },
lqi_high: { c: '#4bd28f', a: 0.18 },
};
const HEX_RE = /^#[0-9a-f]{6}$/i;
/** Merge stored overrides over the defaults, dropping malformed entries. */
export function fillColorsOf(settings: any): FillColors {
const out: any = {};
const src = settings?.fill_colors || {};
for (const k of Object.keys(DEFAULT_FILL_COLORS) as (keyof FillColors)[]) {
const d = DEFAULT_FILL_COLORS[k];
const v = src[k];
out[k] = {
c: v && typeof v.c === 'string' && HEX_RE.test(v.c) ? v.c : d.c,
a: v && typeof v.a === 'number' ? Math.min(1, Math.max(0, v.a)) : d.a,
};
}
return out as FillColors;
}
/** Linear RGB interpolation between two hex colors, t clamped to 0..1. */
export function lerpColor(a: string, b: string, t: number): string {
const tt = Math.min(1, Math.max(0, t));
const pa = [1, 3, 5].map((i) => parseInt(a.slice(i, i + 2), 16));
const pb = [1, 3, 5].map((i) => parseInt(b.slice(i, i + 2), 16));
const mix = pa.map((v, i) => Math.round(v + (pb[i] - v) * tt));
return '#' + mix.map((v) => v.toString(16).padStart(2, '0')).join('');
}
/**
* Room fill (color + opacity) for the selected mode, or null for "no fill",
* using the global palette. The LQI gradient interpolates lqi_low → lqi_high
* over the 40..180 LQI window (same thresholds as the badge color).
*/
export function roomFillStyle(
mode: RoomFillMode,
lqi: number | null,
lights: 'on' | 'off' | 'none',
temp: number | null | undefined,
tempMin: number,
tempMax: number,
colors: FillColors,
): FillColorEntry | null {
if (mode === 'lqi') {
if (lqi == null) return null;
const t = (lqi - 40) / 140;
return { c: lerpColor(colors.lqi_low.c, colors.lqi_high.c, t),
a: colors.lqi_low.a + (colors.lqi_high.a - colors.lqi_low.a) * Math.min(1, Math.max(0, t)) };
}
if (mode === 'light') {
if (lights === 'none') {
// configurable "no light sources" color; alpha 0 keeps the historical
// no-fill behavior (and the unfilled hover), so nothing changes until
// the user assigns an opacity
return colors.light_none.a > 0 ? colors.light_none : null;
}
return lights === 'on' ? colors.light_on : colors.light_off;
}
if (mode === 'temp') {
if (temp == null) return null;
const lo = Math.min(tempMin, tempMax);
const hi = Math.max(tempMin, tempMax);
if (temp < lo) return colors.temp_cold;
if (temp > hi) return colors.temp_hot;
return colors.temp_ok;
}
return null;
}
/**
* Room fill color for the selected mode, or null for "no fill".
* - lqi: red→green gradient by the room's average zigbee signal (null LQI → no fill)
@@ -549,3 +658,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 };
}
+4 -3
View File
@@ -8,7 +8,7 @@
*/
import { html, svg, nothing, type TemplateResult } from 'lit';
import { buildDevices, areaLqi, areaLights, areaTemp } from './devices';
import { spaceDisplayOf, roomFillColor } from './logic';
import { spaceDisplayOf, roomFillStyle, fillColorsOf } from './logic';
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
import { t, type Lang } from './i18n';
import type { ServerConfig } from './types';
@@ -71,18 +71,19 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const parts = [`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`];
// fill rendered exactly as configured on the full card (snapshot of current states)
const fillC = r.area
? roomFillColor(
? roomFillStyle(
disp.fill,
disp.fill === 'lqi' ? areaLqi(o.hass, devs, r.area) : null,
disp.fill === 'light' ? areaLights(o.hass, devs, r.area) : 'none',
disp.fill === 'temp' ? areaTemp(o.hass, devs, r.area) : null,
disp.tempMin,
disp.tempMax,
fillColorsOf(o.cfg?.settings),
)
: null;
if (fillC) {
cls += ' filled';
parts.push(`--room-fill:${fillC}`, `--room-fill-op:${(0.3 * disp.opacity).toFixed(3)}`);
parts.push(`--room-fill:${fillC.c}`, `--room-fill-op:${fillC.a.toFixed(3)}`);
} else {
parts.push('--room-fill:transparent', '--room-fill-op:0');
}
+187 -22
View File
@@ -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;
@@ -341,17 +361,57 @@ export const cardStyles = css`
.ripple.active i:nth-child(n + 2) { display: none; }
}
.roomlabel {
pointer-events: none; /* draggable only in plan mode (rule below) */
position: absolute;
transform: translate(-50%, -50%);
font-size: calc(var(--icon-size, 2.5cqw) * 0.5);
font-size: calc(var(--icon-size, 2.5cqw) * 0.5 * var(--rl-scale, 1));
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
cursor: grab;
pointer-events: auto;
user-select: none;
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; }
.rlhandle {
display: none;
position: absolute;
width: 9px;
height: 9px;
border-radius: 2px;
background: var(--hp-accent);
border: 1px solid var(--card-background-color, #fff);
z-index: 2;
}
.rlhandle.tl { left: -6px; top: -6px; cursor: nwse-resize; }
.rlhandle.br { right: -6px; bottom: -6px; cursor: nwse-resize; }
.rlhandle.tr { right: -6px; top: -6px; cursor: nesw-resize; }
.rlhandle.bl { left: -6px; bottom: -6px; cursor: nesw-resize; }
.stage.markup .roomlabel:hover .rlhandle { display: block; }
.stage.markup .roomlabel { pointer-events: auto; }
.roomlabel:active { cursor: grabbing; }
.measurelayer {
position: absolute;
@@ -388,18 +448,71 @@ export const cardStyles = css`
.stage.markup .room {
pointer-events: none;
}
.stage.markup .devlayer {
display: none; /* in markup mode icons must not get in the way */
.stage.markup .devlayer .dev {
display: none; /* in plan mode the icons do not get in the way; labels stay */
}
.room.picked {
stroke: #ffc14d;
stroke-width: 3;
fill: rgba(255, 193, 77, 0.25);
/* mode frames: the edit modes are visible at a glance */
.stage.mode-plan {
outline: 2px solid #ffc14d;
outline-offset: -2px;
}
.stage.mode-devices {
outline: 2px solid var(--hp-accent);
outline-offset: -2px;
}
.modes {
display: inline-flex;
gap: 2px;
background: rgba(127, 127, 127, 0.12);
border-radius: 10px;
padding: 3px;
}
.modetab {
display: inline-flex;
align-items: center;
gap: 5px;
border: 0;
background: transparent;
color: var(--hp-muted);
padding: 5px 10px;
border-radius: 8px;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
}
.modetab ha-icon { --mdc-icon-size: 15px; }
.modetab .closex {
--mdc-icon-size: 13px;
display: inline-flex;
align-items: center;
margin-left: 2px;
opacity: 0.75;
cursor: pointer;
border-radius: 4px;
}
.modetab .closex:hover { opacity: 1; }
.editbar .barclose {
padding: 4px 6px;
margin-left: 6px;
}
.modetab.active {
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
}
@media (max-width: 720px) {
.modetab .ml { display: none; }
}
.room.outlined {
stroke: rgba(62, 166, 255, 0.55);
fill: rgba(62, 166, 255, 0.06);
}
/* AFTER .outlined: same specificity — source order decides (gotcha x4) */
.room.picked {
stroke: #ffc14d;
stroke-width: 3;
fill: rgba(255, 193, 77, 0.25);
}
.griddot {
fill: var(--hp-accent);
opacity: 0.75;
@@ -446,6 +559,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;
@@ -466,7 +626,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);
@@ -479,9 +639,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);
@@ -567,9 +726,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;
@@ -874,6 +1034,11 @@ export const cardStyles = css`
.rrow .ract:hover { color: var(--hp-txt); }
.rrow .ract.del:hover { color: #ff7a5c; }
.gsrow .gsl {
min-width: 150px;
font-size: 12.5px;
color: var(--hp-muted);
}
.dialogwrap {
background: rgba(0, 0, 0, 0.45);
display: flex;
+2 -1
View File
@@ -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
View File
@@ -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);
});
+101 -1
View File
@@ -4,7 +4,7 @@ import {
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount,
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,
@@ -444,3 +444,103 @@ test('snapToWall: the angle is normalized to [-90, 90) so opposite edge directio
const v = snapToWall([10.3, 5], a, 1); // vertical wall
assert.ok(v.angle >= -90 && v.angle < 90);
});
test('fillColorsOf: defaults, merge, malformed entries dropped', () => {
const d = fillColorsOf({});
assert.equal(d.light_on.c, '#ffd45c');
const o = fillColorsOf({ fill_colors: { light_on: { c: '#ff0000', a: 0.5 }, temp_hot: { c: 'javascript:x', a: 9 } } });
assert.equal(o.light_on.c, '#ff0000');
assert.equal(o.light_on.a, 0.5);
assert.equal(o.temp_hot.c, '#ffd45c'); // malformed hex → default
assert.equal(o.temp_hot.a, 1); // clamped
});
test('lerpColor: endpoints and midpoint', () => {
assert.equal(lerpColor('#000000', '#ffffff', 0), '#000000');
assert.equal(lerpColor('#000000', '#ffffff', 1), '#ffffff');
assert.equal(lerpColor('#000000', '#ffffff', 0.5), '#808080');
assert.equal(lerpColor('#000000', '#ffffff', -5), '#000000'); // clamped
});
test('roomFillStyle: palette-driven fills, lqi gradient between custom endpoints', () => {
const colors = fillColorsOf({ fill_colors: { lqi_low: { c: '#000000', a: 0.1 }, lqi_high: { c: '#ffffff', a: 0.3 } } });
assert.equal(roomFillStyle('lqi', 110, 'none', null, 20, 25, colors).c, '#808080'); // mid of 40..180
assert.equal(roomFillStyle('lqi', null, 'none', null, 20, 25, colors), null);
assert.deepEqual(roomFillStyle('light', null, 'on', null, 20, 25, colors), colors.light_on);
assert.deepEqual(roomFillStyle('temp', 18, 'none', 18, 20, 25, colors), colors.temp_cold);
assert.equal(roomFillStyle('none', 100, 'on', 22, 20, 25, colors), null);
});
test('spaceDisplayOf: show_lqi tri-state (null = follow the card option)', () => {
assert.equal(spaceDisplayOf({}).showLqi, null);
assert.equal(spaceDisplayOf({ settings: { show_lqi: false } }).showLqi, false);
assert.equal(spaceDisplayOf({ settings: { show_lqi: true } }).showLqi, true);
});
test('roomFillStyle light_none: alpha 0 keeps no-fill; a custom color fills lightless rooms', () => {
const def = fillColorsOf({});
assert.equal(roomFillStyle('light', null, 'none', null, 20, 25, def), null); // default: unchanged
const c = fillColorsOf({ fill_colors: { light_none: { c: '#123456', a: 0.2 } } });
assert.deepEqual(roomFillStyle('light', null, 'none', null, 20, 25, c), { c: '#123456', a: 0.2 });
});
test('stateIcon: doors/locks/bulbs reflect state; custom icons and outages never morph', () => {
assert.equal(stateIcon('mdi:door', 'binary_sensor', 'door', 'on', false), 'mdi:door-open');
assert.equal(stateIcon('mdi:door', 'binary_sensor', 'door', 'off', false), 'mdi:door-closed');
assert.equal(stateIcon('mdi:window-closed', 'binary_sensor', 'window', 'on', false), 'mdi:window-open');
assert.equal(stateIcon('mdi:garage-variant', 'binary_sensor', 'garage_door', 'on', false), 'mdi:garage-open-variant');
assert.equal(stateIcon('mdi:lock', 'lock', undefined, 'unlocked', false), 'mdi:lock-open-variant');
assert.equal(stateIcon('mdi:lightbulb', 'light', undefined, 'on', false), 'mdi:lightbulb-on');
assert.equal(stateIcon('mdi:lightbulb', 'light', undefined, 'off', false), 'mdi:lightbulb');
assert.equal(stateIcon('mdi:door', 'binary_sensor', 'door', 'unavailable', false), 'mdi:door');
assert.equal(stateIcon('mdi:custom', 'lock', undefined, 'unlocked', true), 'mdi:custom'); // user icon wins
assert.equal(stateIcon('mdi:cctv', 'camera', undefined, 'recording', false), 'mdi:cctv'); // unknown pair
});
test('lightColorOf: rgb of an on light; off/unavailable/no-color → null', () => {
assert.equal(lightColorOf({ state: 'on', attributes: { rgb_color: [255, 20, 40] } }), 'rgb(255, 20, 40)');
assert.equal(lightColorOf({ state: 'off', attributes: { rgb_color: [255, 20, 40] } }), null);
assert.equal(lightColorOf({ state: 'on', attributes: {} }), null);
assert.equal(lightColorOf({ state: 'unavailable', attributes: { rgb_color: [1, 2, 3] } }), null);
assert.equal(lightColorOf(undefined), null);
});
test('isAlarmState: leak/smoke/gas/siren fire; doors and outages do not', () => {
assert.ok(isAlarmState('binary_sensor', 'moisture', 'on'));
assert.ok(isAlarmState('binary_sensor', 'smoke', 'on'));
assert.ok(isAlarmState('siren', undefined, 'on'));
assert.ok(!isAlarmState('binary_sensor', 'door', 'on'));
assert.ok(!isAlarmState('binary_sensor', 'smoke', 'off'));
assert.ok(!isAlarmState('binary_sensor', 'smoke', 'unavailable'));
});
test('parseRoomRef: area rooms, sub-area rooms by id, malformed refs', () => {
assert.deepEqual(parseRoomRef('f1#kitchen'), { space: 'f1', area: 'kitchen', roomId: null });
assert.deepEqual(parseRoomRef('f1#@r7'), { space: 'f1', area: null, roomId: 'r7' });
assert.equal(parseRoomRef(''), null);
assert.equal(parseRoomRef('f1#'), null);
assert.equal(parseRoomRef('f1#@'), null);
assert.equal(parseRoomRef('#kitchen'), null);
assert.equal(parseRoomRef(null), null);
});
test('diffNewDevices: first run seeds the baseline silently; later additions are fresh', () => {
const first = diffNewDevices(['a', 'b'], undefined);
assert.deepEqual(first, { fresh: [], known: ['a', 'b'] });
const none = diffNewDevices(['a', 'b'], ['a', 'b']);
assert.deepEqual(none.fresh, []);
assert.equal(none.known.length, 2);
const added = diffNewDevices(['a', 'b', 'c'], ['a', 'b']);
assert.deepEqual(added.fresh, ['c']);
assert.deepEqual(added.known, ['a', 'b', 'c']);
// removed devices stay in known (harmless, keeps the list append-only)
const removed = diffNewDevices(['a'], ['a', 'b']);
assert.deepEqual(removed.fresh, []);
});
test('spaceDisplayOf: room-card metric flags default to off', () => {
const d0 = spaceDisplayOf({ plan_url: 'x', settings: {} });
assert.deepEqual([d0.labelTemp, d0.labelHum, d0.labelLqi, d0.labelLight], [false, false, false, false]);
const d1 = spaceDisplayOf({ plan_url: 'x', settings: { label_temp: true, label_light: true } });
assert.deepEqual([d1.labelTemp, d1.labelHum, d1.labelLqi, d1.labelLight], [true, false, false, true]);
});