Merge dev: v1.30.2..v1.31.0 batch (editor tabs redesign, Esc, room cards, scope)

This commit is contained in:
Matysh
2026-07-22 12:10:13 +03:00
26 changed files with 1255 additions and 398 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
FILES_URL = "/houseplan_files/files" FILES_URL = "/houseplan_files/files"
FILES_DIR = "houseplan/files" FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only" CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.30.1" VERSION = "1.31.0"
DEFAULT_CONFIG: dict = { DEFAULT_CONFIG: dict = {
"spaces": [], "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", "issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [], "requirements": [],
"single_config_entry": true, "single_config_entry": true,
"version": "1.30.1" "version": "1.31.0"
} }
@@ -88,6 +88,10 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
vol.Optional("temp_min"): vol.Coerce(float), vol.Optional("temp_min"): vol.Coerce(float),
vol.Optional("temp_max"): vol.Coerce(float), vol.Optional("temp_max"): vol.Coerce(float),
vol.Optional("show_lqi"): bool, 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, extra=vol.ALLOW_EXTRA,
) )
+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();
+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();
+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})), 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 }; 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 = {}; const out = {};
// MERGE living(r1)+kitchen(r2) — share wall x=0.55, y∈[0.05..0.45] // MERGE living(r1)+kitchen(r2) — share wall x=0.55, y∈[0.05..0.45]
+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]; const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H];
}, [nx, ny]); }, [nx, ny]);
const out = {}; 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 // 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.3)); // pick living
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.052)); // near top wall (off grid) await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.052)); // near top wall (off grid)
File diff suppressed because one or more lines are too long
+196 -117
View File
File diff suppressed because one or more lines are too long
+36
View File
@@ -1,5 +1,41 @@
# Changelog # Changelog
## 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) ## v1.30.1 — 2026-07-22 (space gear polish)
- The gear icon next to the space name is now visible in **every mode** (not - 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 just Plan) for users who can edit, so space settings are always one click
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, 20200 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.
+6
View File
@@ -129,3 +129,9 @@
md5-verify after every deploy, restart HA via md5-verify after every deploy, restart HA via
`nohup ha core restart >/dev/null 2>&1 </dev/null &` (otherwise the SSH session hangs). `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). 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 J1J7, partial/out-of-scope lists, excess audit. Check it before
accepting or proposing any feature.
+18
View File
@@ -140,6 +140,24 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
(explicit ripple color still wins); off/white lights unchanged [auto] (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 - [ ] 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 display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static
- [ ] 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.53, 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 - [ ] 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 mode (admins only), vertically centered with the tab text; clicking it
opens space settings without switching the tab; "+" tab stays Plan-only [auto] opens space settings without switching the tab; "+" tab stays Plan-only [auto]
+9 -4
View File
@@ -12,11 +12,16 @@ A segmented control in the card header with three tabs; the active one is visual
highlighted, and edit modes add a colored frame around the stage so the mode is highlighted, and edit modes add a colored frame around the stage so the mode is
obvious at a glance: obvious at a glance:
**[ 👁 View ] [ 📐 Plan ] [ 🔧 Devices ]** **[ 📐 Plan editor ] [ 🔧 Device editor ]** — View has NO tab (since v1.30.2).
- **View** is the default and the only mode after every load (edit modes are never - **View** is the implicit default state: no editor tab is active. It is the only
restored across reloads). state after every load (edit modes are never restored across reloads).
- **Plan** and **Devices** are shown only to admins when `admin_only` is on. - 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 ## View — display and device interaction only
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.30.1", "version": "1.31.0",
"description": "Interactive house plan Lovelace card for Home Assistant", "description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+37
View File
@@ -343,6 +343,43 @@ export function areaLights(hass: any, devices: { area: string; entities: string[
return seen ? 'off' : 'none'; 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 temperature across the area's devices (null when nothing reports one). */
/** Average zigbee signal (LQI) across an area's non-virtual devices, or null. */ /** 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 { export function areaLqi(hass: any, devices: { area: string; virtual?: boolean; entities: string[] }[], area: string): number | null {
+170 -28
View File
@@ -22,7 +22,7 @@ import {
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay, DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
} from './logic'; } 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 { import type {
OpeningCfg, OpeningCfg,
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig, RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
@@ -32,7 +32,7 @@ import './space-card';
import { cardStyles } from './styles'; import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n'; import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.30.1'; const CARD_VERSION = '1.31.0';
const LS_KEY = 'houseplan_card_layout_v1'; 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_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1'; const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -65,7 +65,7 @@ class HouseplanCard extends LitElement {
private _config?: CardConfig; private _config?: CardConfig;
private _space = 'f1'; 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 _serverStorage = false;
private _loadOk = false; private _loadOk = false;
private _loading = false; private _loading = false;
@@ -176,6 +176,10 @@ class HouseplanCard extends LitElement {
tempMin: number; tempMin: number;
tempMax: number; tempMax: number;
showLqi: boolean; showLqi: boolean;
labelTemp: boolean;
labelHum: boolean;
labelLqi: boolean;
labelLight: boolean;
cellCm: number; // real-world cm represented by one grid cell cellCm: number; // real-world cm represented by one grid cell
busy: boolean; busy: boolean;
} | null = null; } | null = null;
@@ -197,6 +201,7 @@ class HouseplanCard extends LitElement {
}; };
private _drag: { id: string; sx: number; sy: number; ox: number; oy: number; moved: boolean } | null = null; 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 _holdTimer?: number;
private _holdFired = false; private _holdFired = false;
@@ -253,8 +258,20 @@ class HouseplanCard extends LitElement {
private _onKey(e: KeyboardEvent): void { private _onKey(e: KeyboardEvent): void {
if (e.key === 'Escape') { 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._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._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; if (!this._markup) return;
const undo = e.key === 'Escape' || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z'); const undo = e.key === 'Escape' || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z');
@@ -681,9 +698,10 @@ class HouseplanCard extends LitElement {
const gx = Math.round(x / g) * g; const gx = Math.round(x / g) * g;
const gy = Math.round(y / g) * g; const gy = Math.round(y / g) * g;
const aspect = this._serverCfg!.spaces.find((s: any) => s.id === d.space)?.aspect || 1; 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 = {
...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 { } else {
this._layout = { ...this._layout, [d.id]: { x: Math.round(x), y: Math.round(y) } }; this._layout = { ...this._layout, [d.id]: { x: Math.round(x), y: Math.round(y) } };
@@ -1934,6 +1952,8 @@ class HouseplanCard extends LitElement {
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill, roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill,
tempMin: disp.tempMin, tempMax: disp.tempMax, tempMin: disp.tempMin, tempMax: disp.tempMax,
showLqi: disp.showLqi ?? this._config?.show_signal ?? true, 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, cellCm: Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5,
busy: false, busy: false,
}; };
@@ -1945,6 +1965,7 @@ class HouseplanCard extends LitElement {
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none', roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX, tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
showLqi: this._config?.show_signal ?? true, showLqi: this._config?.show_signal ?? true,
labelTemp: false, labelHum: false, labelLqi: false, labelLight: false,
cellCm: 5, cellCm: 5,
busy: false, busy: false,
}; };
@@ -2029,6 +2050,10 @@ class HouseplanCard extends LitElement {
temp_min: Number.isFinite(d.tempMin) ? Math.min(d.tempMin, d.tempMax) : DEFAULT_TEMP_MIN, 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, temp_max: Number.isFinite(d.tempMax) ? Math.max(d.tempMin, d.tempMax) : DEFAULT_TEMP_MAX,
show_lqi: d.showLqi, 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; sp.cell_cm = Number.isFinite(d.cellCm) && d.cellCm > 0 ? d.cellCm : 5;
await this._saveConfigNow(); await this._saveConfigNow();
@@ -2121,6 +2146,7 @@ class HouseplanCard extends LitElement {
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none', roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX, tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
showLqi: this._config?.show_signal ?? true, showLqi: this._config?.show_signal ?? true,
labelTemp: false, labelHum: false, labelLqi: false, labelLight: false,
cellCm: 5, cellCm: 5,
busy: false, busy: false,
}; };
@@ -2427,11 +2453,15 @@ class HouseplanCard extends LitElement {
</div> </div>
${this._canEdit ${this._canEdit
? html`<div class="modes"> ? html`<div class="modes">
${([['view', 'mdi:eye-outline'], ['plan', 'mdi:floor-plan'], ['devices', 'mdi:tune-variant']] as const).map( ${([['plan', 'mdi:floor-plan'], ['devices', 'mdi:tune-variant']] as const).map(
([m, ic]) => html`<button class="modetab ${this._mode === m ? 'active' : ''}" ([m, ic]) => html`<button class="modetab ${this._mode === m ? 'active' : ''}"
title=${this._t(('mode.' + m) as any)} title=${this._t(('mode.' + m) as any)}
@click=${() => this._setMode(m)}> @click=${() => { if (this._mode !== m) this._setMode(m); }}>
<ha-icon icon=${ic}></ha-icon><span class="ml">${this._t(('mode.' + m) as any)}</span> <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>`, </button>`,
)} )}
</div>` </div>`
@@ -2444,29 +2474,13 @@ class HouseplanCard extends LitElement {
title=${this._t('title.zoom_reset')}><ha-icon icon="mdi:fit-to-page-outline"></ha-icon></button> 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> <button class="btn zb" @click=${() => this._stepZoom(1)} title=${this._t('title.zoom_in')}><ha-icon icon="mdi:plus"></ha-icon></button>
</div> </div>
${this._norm && this._mode === 'devices' ${this._norm && this._canEdit
? html`<button class="btn" @click=${() => this._openMarkerDialog()}
title=${this._t('title.add_device')}>
<ha-icon icon="mdi:plus-box-outline"></ha-icon>
</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>
</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}
${this._norm && this._mode === 'plan'
? html`<button class="btn" @click=${this._openSettingsDialog} title=${this._t('title.general_settings')}> ? html`<button class="btn" @click=${this._openSettingsDialog} title=${this._t('title.general_settings')}>
<ha-icon icon="mdi:cog-outline"></ha-icon> <ha-icon icon="mdi:cog-outline"></ha-icon>
</button>` </button>`
: nothing} : nothing}
</div> </div>
${this._markup ? this._renderMarkupBar() : nothing} ${this._markup ? this._renderMarkupBar() : this._mode === 'devices' ? this._renderDevicesBar() : nothing}
</div> </div>
<div class="stage ${this._markup ? 'markup' : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}" <div class="stage ${this._markup ? 'markup' : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}"
@@ -2514,7 +2528,7 @@ class HouseplanCard extends LitElement {
this._showTip(e, r.name, this._t('tip.room'), this._showTip(e, r.name, this._t('tip.room'),
showLqi ? this._roomLqi(r.area) : null, showLqi ? this._roomLqi(r.area) : null,
r.area ? areaTemp(this.hass, this._devices, 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 c = this._roomCenter(r);
const shape = r.poly const shape = r.poly
? svg`<polygon class="${cls}" style="${style}" points="${r.poly.map((p) => p.join(',')).join(' ')}" ? svg`<polygon class="${cls}" style="${style}" points="${r.poly.map((p) => p.join(',')).join(' ')}"
@@ -2532,7 +2546,7 @@ class HouseplanCard extends LitElement {
<div class="devlayer" style="--icon-size:${((iconPct * vb[2]) / view.w).toFixed(3)}cqw"> <div class="devlayer" style="--icon-size:${((iconPct * vb[2]) / view.w).toFixed(3)}cqw">
${devs.map((d) => this._renderDevice(d, view, showLqi))} ${devs.map((d) => this._renderDevice(d, view, showLqi))}
${this._renderOpeningLocks(view)} ${this._renderOpeningLocks(view)}
${disp.showNames && !this._markup ${disp.showNames || this._markup
? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp)) ? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp))
: nothing} : nothing}
</div> </div>
@@ -2689,6 +2703,57 @@ class HouseplanCard extends LitElement {
if (moved) window.setTimeout(() => (this._drag = null), 0); 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( private _renderRoomLabel(
r: RoomCfg, space: SpaceModel, view: { x: number; y: number; w: number; h: number }, disp: SpaceDisplay, r: RoomCfg, space: SpaceModel, view: { x: number; y: number; w: number; h: number }, disp: SpaceDisplay,
): TemplateResult | typeof nothing { ): TemplateResult | typeof nothing {
@@ -2697,12 +2762,52 @@ class HouseplanCard extends LitElement {
const left = ((p.x - view.x) / view.w) * 100; const left = ((p.x - view.x) / view.w) * 100;
const top = ((p.y - view.y) / view.h) * 100; const top = ((p.y - view.y) / view.h) * 100;
const op = Math.min(1, disp.opacity + 0.25); 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)} @pointerdown=${(e: PointerEvent) => this._labelDown(e, r, space.id)}
@pointermove=${(e: PointerEvent) => this._labelMove(e, r, space.id)} @pointermove=${(e: PointerEvent) => this._labelMove(e, r, space.id)}
@pointerup=${() => this._labelUp(r)} @pointerup=${() => this._labelUp(r)}
@pointercancel=${() => 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. */ /** Where the live measurement starts: the last outline point, or the first split point. */
@@ -3020,6 +3125,34 @@ class HouseplanCard extends LitElement {
: this._t('markup.hint_start')}</span> : this._t('markup.hint_start')}</span>
${this._path.length ? html`<button class="btn ghost" @click=${this._cancelPath}>${this._t('btn.reset')}</button>` : nothing}` ${this._path.length ? html`<button class="btn ghost" @click=${this._cancelPath}>${this._t('btn.reset')}</button>` : nothing}`
: 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>`; </div>`;
} }
@@ -3288,6 +3421,15 @@ class HouseplanCard extends LitElement {
@change=${(e: Event) => (this._spaceDialog = { ...d, showLqi: (e.target as HTMLInputElement).checked })} /> @change=${(e: Event) => (this._spaceDialog = { ...d, showLqi: (e.target as HTMLInputElement).checked })} />
<span>${this._t('space.show_lqi')}</span> <span>${this._t('space.show_lqi')}</span>
</label> </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> <label>${this._t('space.room_color')}</label>
<div class="colorrow"> <div class="colorrow">
<input type="color" .value=${d.roomColor} <input type="color" .value=${d.roomColor}
+16 -4
View File
@@ -240,13 +240,25 @@
"gs.saved": "General settings saved", "gs.saved": "General settings saved",
"space.show_lqi": "Show zigbee signal (LQI) next to devices", "space.show_lqi": "Show zigbee signal (LQI) next to devices",
"gs.light_none": "No light sources", "gs.light_none": "No light sources",
"mode.view": "View", "mode.plan": "Plan editor",
"mode.plan": "Plan", "mode.devices": "Device editor",
"mode.devices": "Devices",
"display.value": "Value instead of an icon", "display.value": "Value instead of an icon",
"marker.subarea": "no area, manual", "marker.subarea": "no area, manual",
"device.new": "New device — open its editor to dismiss", "device.new": "New device — open its editor to dismiss",
"opening.unlock_action": "Unlock", "opening.unlock_action": "Unlock",
"opening.lock_action": "Lock", "opening.lock_action": "Lock",
"opening.lock_pending": "Working…" "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}"
} }
+16 -4
View File
@@ -240,13 +240,25 @@
"gs.saved": "Общие настройки сохранены", "gs.saved": "Общие настройки сохранены",
"space.show_lqi": "Показывать зигби-сигнал (LQI) у устройств", "space.show_lqi": "Показывать зигби-сигнал (LQI) у устройств",
"gs.light_none": "Нет источников света", "gs.light_none": "Нет источников света",
"mode.view": "Просмотр", "mode.plan": "Редактор плана",
"mode.plan": "План", "mode.devices": "Редактор устройств",
"mode.devices": "Устройства",
"display.value": "Значение вместо иконки", "display.value": "Значение вместо иконки",
"marker.subarea": "без зоны, вручную", "marker.subarea": "без зоны, вручную",
"device.new": "Новое устройство — откройте его редактор, чтобы снять отметку", "device.new": "Новое устройство — откройте его редактор, чтобы снять отметку",
"opening.unlock_action": "Открыть замок", "opening.unlock_action": "Открыть замок",
"opening.lock_action": "Закрыть замок", "opening.lock_action": "Закрыть замок",
"opening.lock_pending": "Выполняется…" "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}"
} }
+9
View File
@@ -497,6 +497,11 @@ export interface SpaceDisplay {
tempMax: number; // comfort range upper bound, °C tempMax: number; // comfort range upper bound, °C
/** Per-space LQI badges near zigbee devices; null = follow the card option. */ /** Per-space LQI badges near zigbee devices; null = follow the card option. */
showLqi: boolean | null; 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'; export const DEFAULT_ROOM_COLOR = '#3ea6ff';
@@ -517,6 +522,10 @@ export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
tempMin: typeof s.temp_min === 'number' ? s.temp_min : DEFAULT_TEMP_MIN, tempMin: typeof s.temp_min === 'number' ? s.temp_min : DEFAULT_TEMP_MIN,
tempMax: typeof s.temp_max === 'number' ? s.temp_max : DEFAULT_TEMP_MAX, tempMax: typeof s.temp_max === 'number' ? s.temp_max : DEFAULT_TEMP_MAX,
showLqi: typeof s.show_lqi === 'boolean' ? s.show_lqi : null, 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,
}; };
} }
+54 -1
View File
@@ -364,14 +364,53 @@ export const cardStyles = css`
pointer-events: none; /* draggable only in plan mode (rule below) */ pointer-events: none; /* draggable only in plan mode (rule below) */
position: absolute; position: absolute;
transform: translate(-50%, -50%); 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; font-weight: 700;
letter-spacing: 0.04em; letter-spacing: 0.04em;
white-space: nowrap; white-space: nowrap;
cursor: grab; cursor: grab;
user-select: none; user-select: none;
z-index: 1; 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; } .stage.markup .roomlabel { pointer-events: auto; }
.roomlabel:active { cursor: grabbing; } .roomlabel:active { cursor: grabbing; }
.measurelayer { .measurelayer {
@@ -443,6 +482,20 @@ export const cardStyles = css`
font-family: inherit; font-family: inherit;
} }
.modetab ha-icon { --mdc-icon-size: 15px; } .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 { .modetab.active {
background: var(--hp-accent); background: var(--hp-accent);
color: var(--text-primary-color, #fff); color: var(--text-primary-color, #fff);
+30 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; 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'; import { compileIconRules } from '../test-build/rules.js';
/** Minimal fake hass around the pieces buildDevices reads. */ /** 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.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) 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);
});
+7
View File
@@ -537,3 +537,10 @@ test('diffNewDevices: first run seeds the baseline silently; later additions are
const removed = diffNewDevices(['a'], ['a', 'b']); const removed = diffNewDevices(['a'], ['a', 'b']);
assert.deepEqual(removed.fresh, []); 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]);
});