mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
Merge dev: v1.30.2..v1.31.0 batch (editor tabs redesign, Esc, room cards, scope)
This commit is contained in:
@@ -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.30.1"
|
||||
VERSION = "1.31.0"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.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_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,
|
||||
)
|
||||
|
||||
@@ -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();
|
||||
@@ -0,0 +1,35 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
|
||||
// общие настройки
|
||||
c._openSettingsDialog(); await c.updateComplete;
|
||||
await esc(); out.settings = !c._settingsDialog;
|
||||
// правила иконок
|
||||
c._openRulesDialog(); await c.updateComplete;
|
||||
await esc(); out.rules = !c._rulesDialog;
|
||||
// диалог устройства
|
||||
c._setMode('devices'); c._openMarkerDialog(); await c.updateComplete;
|
||||
await esc(); out.marker = !c._markerDialog;
|
||||
// диалог пространства (+ очистка очереди импорта)
|
||||
c._openSpaceDialog('edit', c._space); c._importQueue = ['x']; c._importTotal = 1; await c.updateComplete;
|
||||
await esc(); out.space = !c._spaceDialog && c._importQueue.length === 0;
|
||||
// инфо-карточка устройства
|
||||
c._setMode('view'); c._infoCard = c._devices[0]; await c.updateComplete;
|
||||
await esc(); out.info = !c._infoCard;
|
||||
// инфо двери/замка
|
||||
c._openingInfo = { id: 'op1', type: 'door', rx: 550, ry: 200, len_cm: 90, lock: 'lock.front_door' }; await c.updateComplete;
|
||||
await esc(); out.openingInfo = !c._openingInfo;
|
||||
// приоритет: инфо поверх настроек — Esc закрывает только инфо
|
||||
c._openSettingsDialog(); c._infoCard = c._devices[0]; await c.updateComplete;
|
||||
await esc(); out.stacked = !c._infoCard && !!c._settingsDialog;
|
||||
await esc(); out.stacked2 = !c._settingsDialog;
|
||||
// Esc в разметке по-прежнему откатывает точку, а не только диалоги
|
||||
c._setMode('plan'); c._tool = 'draw'; c._path = [[1, 2], [3, 4]]; await c.updateComplete;
|
||||
await esc(); out.undoPointStillWorks = c._path.length === 1;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,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();
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// включить имена и все метрики у текущего пространства
|
||||
const spId = c._space;
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||||
...s, settings: { ...(s.settings || {}), show_names: true,
|
||||
label_temp: true, label_hum: true, label_lqi: true, label_light: true } })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const labels = [...sr().querySelectorAll('.roomlabel')];
|
||||
out.labels = labels.length;
|
||||
const cards = labels.filter((l) => l.classList.contains('card'));
|
||||
out.cardsWithMetrics = cards.length;
|
||||
out.hasName = !!labels[0]?.querySelector('.rlname');
|
||||
const metrics = cards[0] ? [...cards[0].querySelectorAll('.rlm')].map((m) => m.textContent.trim()) : [];
|
||||
out.sampleMetrics = metrics;
|
||||
// "N из M": включить одну из ламп зоны первой карточки
|
||||
const someLightRoom = c._spaceModel().rooms.find((r) => r.area && c._devices.some((d) => d.area === r.area && d.entities.some((e) => e.startsWith('light.'))));
|
||||
out.partialTested = false;
|
||||
if (someLightRoom) {
|
||||
const lights = [...new Set(c._devices.filter((d) => d.area === someLightRoom.area).flatMap((d) => d.entities).filter((e) => e.startsWith('light.')))];
|
||||
if (lights.length >= 2) {
|
||||
const st = { ...c.hass.states };
|
||||
lights.forEach((e, i) => { st[e] = { ...st[e], state: i === 0 ? 'on' : 'off' }; });
|
||||
c.hass = { ...c.hass, states: st }; await c.updateComplete;
|
||||
const lbl = [...sr().querySelectorAll('.roomlabel.card')].find((l) => l.textContent.includes(someLightRoom.name));
|
||||
out.partialText = lbl?.querySelector('.rlm.lit')?.textContent.trim();
|
||||
out.partialTested = /1\D+' + lights.length + '/.test(out.partialText || '') || (out.partialText || '').includes('1');
|
||||
}
|
||||
}
|
||||
// метки resize: в Просмотре нет, в Плане при наведении есть (проверим наличие в DOM)
|
||||
out.noHandlesInView = sr().querySelectorAll('.rlhandle').length === 0;
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
const planLbl = sr().querySelector('.roomlabel');
|
||||
out.handlesInPlan = planLbl?.querySelectorAll('.rlhandle').length === 4;
|
||||
// метрики скрыты в редакторе плана (карточка = имя, чтобы не мешать разметке)
|
||||
out.plainInPlan = sr().querySelectorAll('.roomlabel.card').length === 0;
|
||||
// масштаб: сымитируем resize через прямой вызов
|
||||
const room = c._spaceModel().rooms.find((r) => r.name);
|
||||
c._rlResize = { id: 'rl_' + room.id, space: spId, k0: 1, cx: 100, cy: 100, d0: 50 };
|
||||
c._rlResizeMove({ clientX: 200, clientY: 100, stopPropagation() {} });
|
||||
c._rlResizeUp();
|
||||
await c.updateComplete;
|
||||
out.scaleSaved = Math.abs((c._layout['rl_' + room.id]?.k || 0) - 2) < 0.01;
|
||||
const lblEl = sr().querySelector('.roomlabel');
|
||||
out.scaleApplied = getComputedStyle(lblEl).getPropertyValue('--rl-scale').trim() === '2';
|
||||
// клампы
|
||||
c._rlResize = { id: 'rl_' + room.id, space: spId, k0: 2, cx: 100, cy: 100, d0: 50 };
|
||||
c._rlResizeMove({ clientX: 1000, clientY: 100, stopPropagation() {} });
|
||||
c._rlResizeUp();
|
||||
out.clamped = c._layout['rl_' + room.id].k <= 3;
|
||||
// drag сохраняет масштаб
|
||||
c._savePos({ id: 'rl_' + room.id, space: spId }, 300, 300);
|
||||
out.dragKeepsScale = c._layout['rl_' + room.id].k <= 3 && c._layout['rl_' + room.id].k >= 2.9;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -5,7 +5,7 @@ const R = (nx, ny) => page.evaluate(([nx, ny]) => {
|
||||
const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H];
|
||||
}, [nx, ny]);
|
||||
const out = {};
|
||||
await page.evaluate(()=>{const c=window.__card; if(!c._markup)c._toggleMarkup(); c._tool='split';});
|
||||
await page.evaluate(()=>{const c=window.__card; if(!c._markup)c._setMode('plan'); c._tool='split';});
|
||||
// living room (r1) has walls at y=0.05 which are NOT grid nodes; click near the wall
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // pick living
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.052)); // near top wall (off grid)
|
||||
|
||||
+196
-117
File diff suppressed because one or more lines are too long
Vendored
+196
-117
File diff suppressed because one or more lines are too long
@@ -1,5 +1,41 @@
|
||||
# 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)
|
||||
- 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
|
||||
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
# Product scope — what House Plan is and is not
|
||||
|
||||
*Fixed with the owner on 2026-07-22. This document is a guard rail: features are
|
||||
built, improved and accepted **only** if they serve a job listed here. When a new
|
||||
idea appears, first find its row in this file; if there is none — it belongs to
|
||||
HA core, to another card, or nowhere. Companion documents: PRODUCT.md (market),
|
||||
ROADMAP.md (order of work), UX-MODES.md (interaction model).*
|
||||
|
||||
## Mission
|
||||
|
||||
House Plan is the **spatial "at a glance + quick act" layer** for a Home
|
||||
Assistant home. Upload or draw a floor plan, outline rooms bound to HA areas,
|
||||
and the home's devices appear on a live, tappable map: states, climate, alerts,
|
||||
guarded quick actions. Setup is GUI-only — no Inkscape, no YAML, no external
|
||||
editors. Everything that is not "look at the home spatially and act on the
|
||||
obvious" is somebody else's job.
|
||||
|
||||
## Target audience
|
||||
|
||||
| Persona | Role | Surface |
|
||||
|---|---|---|
|
||||
| **Home admin** (primary) | HA enthusiast, house/large flat, 20–200 devices, several floors; sets up and maintains the plan | Desktop browser (both editors live here) |
|
||||
| **Household members** | Non-technical; consume the plan daily, never edit | Wall tablet (kiosk), phone (companion app) |
|
||||
| **Guests / kiosk** | View-only glance at the home | Wall tablet |
|
||||
|
||||
Design consequence: **View mode is the product** for two of the three personas.
|
||||
Editors are admin-only tools and must never leak interactions into View
|
||||
(established by UX-MODES; lock guard, inert openings, no drag in View).
|
||||
|
||||
## Core user jobs — the component must close these
|
||||
|
||||
| # | Job (user's words) | Status |
|
||||
|---|---|---|
|
||||
| J1 | "Show the whole home and what's happening right now" — live spatial overview: device states, room fills (light/temp/LQI), values, multi-floor tabs | **Closed** |
|
||||
| J2 | "Something is wrong — show me *where*" — leak/smoke/gas pulse, open doors/windows, unlocked locks, red dot on devices HA added silently | **Closed** |
|
||||
| J3 | "Let me act on the obvious right from the plan" — tap-to-toggle for safe domains, info cards, guarded lock action (explicit button only, never a plan tap) | **Closed** |
|
||||
| J4 | "From zero to a working plan in one evening, no Inkscape/YAML" — image/PDF/draw, floors-import wizard, room polygons bound to areas, curated auto-placement, editable icon rules | **Closed**; onboarding polish is *partial* (no registry-driven room suggestions) |
|
||||
| J5 | "Room climate at a glance" — per-room temperature/humidity, comfort-range fills, room-card metrics | **Closed** |
|
||||
| J6 | "Keep the plan true as the home evolves" — new-device flag, two editors, drag/resize, merge/split, multi-client live sync, optimistic locking | **Closed** |
|
||||
| J7 | "Is my Zigbee mesh healthy *here*?" — LQI badges, per-room average, LQI fill | **Closed** (kept deliberately: cheap, spatial by nature, no in-plan competitor) |
|
||||
|
||||
## Partially covered — improvement backlog stays inside these
|
||||
|
||||
- **Touch ergonomics of the editors**: corner handles and grid clicks are small on
|
||||
tablets; editors are desktop-first today. Improve, don't redesign.
|
||||
- **Value display**: single current value per device; units/precision follow HA
|
||||
formatting only.
|
||||
- **Accessibility**: `prefers-reduced-motion` only; no keyboard navigation in
|
||||
editors, no ARIA labelling of the plan.
|
||||
- **Docs/screenshots**: README predates the two-editor redesign.
|
||||
|
||||
## Known gaps that fit the mission (build only on owner's request)
|
||||
|
||||
- Person/presence shown in rooms (classic floorplan ask; pure J1).
|
||||
- Plan-level "security glance": one badge for "all locked / N open" (J2).
|
||||
- Threshold colouring for room-card metrics (J5).
|
||||
|
||||
## Out of scope — never build, point users to the right tool
|
||||
|
||||
- Automations, scenes, scripts, notifications → HA core.
|
||||
- Device/entity administration (rename, reassign area, disable) → HA registry
|
||||
UIs. We *read* the registry, we never manage it.
|
||||
- History, graphs, statistics → recorder/history cards. We show now, not then.
|
||||
- Camera streams, media controls → their own cards; our more-info opens HA's.
|
||||
- Energy monitoring/analytics → HA Energy.
|
||||
- 3D / isometric / photorealistic rendering, furniture drawing → niche tools
|
||||
(easy-floorplan et al.); we are a schematic live map, not an interior editor.
|
||||
- A general dashboard framework (menus, popups, theming engine) → Bubble Card,
|
||||
Dwains and friends.
|
||||
|
||||
## Excess-functionality audit (2026-07-22)
|
||||
|
||||
- **Device metadata: links & PDF manuals** — not spatial, but tiny, server-side,
|
||||
and used in real installs. Verdict: **keep, frozen** (no growth).
|
||||
- **Virtual devices** — placeholders for not-yet-installed hardware; serves J6.
|
||||
Verdict: **keep, frozen**.
|
||||
- **LQI diagnostics** — promoted to J7 (differentiator), not excess.
|
||||
- Nothing found that warrants removal; the mode redesign already moved every
|
||||
interaction to where it belongs.
|
||||
|
||||
## The component in one breath (README/HACS copy)
|
||||
|
||||
> **House Plan** turns your Home Assistant into a live map of your home. Upload
|
||||
> a plan image (or draw one), outline rooms and bind them to HA areas — your
|
||||
> devices appear in place, automatically, with live states. Glance at the wall
|
||||
> tablet: what's on, what's open, what's too cold, what's leaking, what's new.
|
||||
> Tap to act — safely: locks never toggle by accident. Two built-in editors
|
||||
> (plan and devices) mean no Inkscape, no YAML, no external tools — ever.
|
||||
|
||||
**Tasks it closes:** whole-home live overview · spatial alerts (leak/smoke/open/
|
||||
unlocked/new device) · safe quick actions · per-room climate · Zigbee mesh
|
||||
health · zero-to-plan GUI onboarding · keeping the plan true over years.
|
||||
|
||||
**Pains it removes:** hand-crafted SVG + YAML floorplans · entity-list
|
||||
dashboards that hide *where* things happen · silent device sprawl · accidental
|
||||
toggles of security devices · per-device dashboards that non-technical family
|
||||
members can't read.
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
- [ ] 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
|
||||
- [ ] 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]
|
||||
|
||||
+9
-4
@@ -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
|
||||
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
|
||||
restored across reloads).
|
||||
- **Plan** and **Devices** are shown only to admins when `admin_only` is on.
|
||||
- **View** is the implicit default state: no editor tab is active. It is the only
|
||||
state after every load (edit modes are never restored across reloads).
|
||||
- Activating an editor tab highlights it and opens that editor's bottom toolbar
|
||||
(both editors have one since v1.30.2). The toolbar and the active tab each
|
||||
carry an **X** that closes the editor back to View; re-clicking the active tab
|
||||
does nothing; Plan↔Devices switches directly.
|
||||
- **Plan editor** and **Device editor** are shown only to admins when
|
||||
`admin_only` is on.
|
||||
|
||||
## View — display and device interaction only
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.30.1",
|
||||
"version": "1.31.0",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -343,6 +343,43 @@ export function areaLights(hass: any, devices: { area: string; entities: string[
|
||||
return seen ? 'off' : 'none';
|
||||
}
|
||||
|
||||
/** Average humidity across the area's climate-ish devices (integer %, or null). */
|
||||
export function areaHum(
|
||||
hass: any,
|
||||
devices: { area: string; icon?: string; entities: string[] }[],
|
||||
area: string,
|
||||
): number | null {
|
||||
const vals: number[] = [];
|
||||
for (const dv of devices) {
|
||||
if (dv.area !== area) continue;
|
||||
// same curation idea as areaTemp: climate sensors only, not fridges/plugs
|
||||
if (dv.icon !== 'mdi:thermometer' && dv.icon !== 'mdi:air-filter' && dv.icon !== 'mdi:water-percent') continue;
|
||||
const h = humFor(hass, dv.entities);
|
||||
if (h != null) vals.push(h);
|
||||
}
|
||||
if (!vals.length) return null;
|
||||
return Math.round(vals.reduce((a, b) => a + b, 0) / vals.length);
|
||||
}
|
||||
|
||||
/** How many of the area's lights are on: {on, total}, or null without lights. */
|
||||
export function areaLightStats(
|
||||
hass: any,
|
||||
devices: { area: string; entities: string[] }[],
|
||||
area: string,
|
||||
): { on: number; total: number } | null {
|
||||
const seen = new Set<string>();
|
||||
let on = 0;
|
||||
for (const dv of devices) {
|
||||
if (dv.area !== area) continue;
|
||||
for (const eid of dv.entities) {
|
||||
if (!eid.startsWith('light.') || seen.has(eid)) continue;
|
||||
seen.add(eid);
|
||||
if (hass.states[eid]?.state === 'on') on++;
|
||||
}
|
||||
}
|
||||
return seen.size ? { on, total: seen.size } : null;
|
||||
}
|
||||
|
||||
/** Average temperature across the area's devices (null when nothing reports one). */
|
||||
/** Average zigbee signal (LQI) across an area's non-virtual devices, or null. */
|
||||
export function areaLqi(hass: any, devices: { area: string; virtual?: boolean; entities: string[] }[], area: string): number | null {
|
||||
|
||||
+170
-28
@@ -22,7 +22,7 @@ import {
|
||||
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,
|
||||
@@ -32,7 +32,7 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
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_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||
@@ -65,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;
|
||||
@@ -176,6 +176,10 @@ class HouseplanCard extends LitElement {
|
||||
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;
|
||||
@@ -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 _rlResize: { id: string; space: string; k0: number; cx: number; cy: number; d0: number } | null = null;
|
||||
private _holdTimer?: number;
|
||||
private _holdFired = false;
|
||||
|
||||
@@ -253,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');
|
||||
@@ -681,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) } };
|
||||
@@ -1934,6 +1952,8 @@ class HouseplanCard extends LitElement {
|
||||
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,
|
||||
};
|
||||
@@ -1945,6 +1965,7 @@ class HouseplanCard extends LitElement {
|
||||
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,
|
||||
};
|
||||
@@ -2029,6 +2050,10 @@ class HouseplanCard extends LitElement {
|
||||
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();
|
||||
@@ -2121,6 +2146,7 @@ class HouseplanCard extends LitElement {
|
||||
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,
|
||||
};
|
||||
@@ -2427,11 +2453,15 @@ class HouseplanCard extends LitElement {
|
||||
</div>
|
||||
${this._canEdit
|
||||
? 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' : ''}"
|
||||
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>
|
||||
${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>`
|
||||
@@ -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>
|
||||
<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 && this._mode === 'devices'
|
||||
? 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'
|
||||
${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}
|
||||
</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'} mode-${this._mode}"
|
||||
@@ -2514,7 +2528,7 @@ class HouseplanCard extends LitElement {
|
||||
this._showTip(e, r.name, this._t('tip.room'),
|
||||
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(' ')}"
|
||||
@@ -2532,7 +2546,7 @@ class HouseplanCard extends LitElement {
|
||||
<div class="devlayer" style="--icon-size:${((iconPct * vb[2]) / view.w).toFixed(3)}cqw">
|
||||
${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>
|
||||
@@ -2689,6 +2703,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 {
|
||||
@@ -2697,12 +2762,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. */
|
||||
@@ -3020,6 +3125,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>`;
|
||||
}
|
||||
|
||||
@@ -3288,6 +3421,15 @@ class HouseplanCard extends LitElement {
|
||||
@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}
|
||||
|
||||
+16
-4
@@ -240,13 +240,25 @@
|
||||
"gs.saved": "General settings saved",
|
||||
"space.show_lqi": "Show zigbee signal (LQI) next to devices",
|
||||
"gs.light_none": "No light sources",
|
||||
"mode.view": "View",
|
||||
"mode.plan": "Plan",
|
||||
"mode.devices": "Devices",
|
||||
"mode.plan": "Plan editor",
|
||||
"mode.devices": "Device editor",
|
||||
"display.value": "Value instead of an icon",
|
||||
"marker.subarea": "no area, manual",
|
||||
"device.new": "New device — open its editor to dismiss",
|
||||
"opening.unlock_action": "Unlock",
|
||||
"opening.lock_action": "Lock",
|
||||
"opening.lock_pending": "Working…"
|
||||
"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
@@ -240,13 +240,25 @@
|
||||
"gs.saved": "Общие настройки сохранены",
|
||||
"space.show_lqi": "Показывать зигби-сигнал (LQI) у устройств",
|
||||
"gs.light_none": "Нет источников света",
|
||||
"mode.view": "Просмотр",
|
||||
"mode.plan": "План",
|
||||
"mode.devices": "Устройства",
|
||||
"mode.plan": "Редактор плана",
|
||||
"mode.devices": "Редактор устройств",
|
||||
"display.value": "Значение вместо иконки",
|
||||
"marker.subarea": "без зоны, вручную",
|
||||
"device.new": "Новое устройство — откройте его редактор, чтобы снять отметку",
|
||||
"opening.unlock_action": "Открыть замок",
|
||||
"opening.lock_action": "Закрыть замок",
|
||||
"opening.lock_pending": "Выполняется…"
|
||||
"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}"
|
||||
}
|
||||
|
||||
@@ -497,6 +497,11 @@ export interface SpaceDisplay {
|
||||
tempMax: number; // comfort range upper bound, °C
|
||||
/** Per-space LQI badges near zigbee devices; null = follow the card option. */
|
||||
showLqi: boolean | null;
|
||||
/** Room-card metrics under the room name (all default off). */
|
||||
labelTemp: boolean;
|
||||
labelHum: boolean;
|
||||
labelLqi: boolean;
|
||||
labelLight: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_ROOM_COLOR = '#3ea6ff';
|
||||
@@ -517,6 +522,10 @@ export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
|
||||
tempMin: typeof s.temp_min === 'number' ? s.temp_min : DEFAULT_TEMP_MIN,
|
||||
tempMax: typeof s.temp_max === 'number' ? s.temp_max : DEFAULT_TEMP_MAX,
|
||||
showLqi: typeof s.show_lqi === 'boolean' ? s.show_lqi : null,
|
||||
labelTemp: s.label_temp === true,
|
||||
labelHum: s.label_hum === true,
|
||||
labelLqi: s.label_lqi === true,
|
||||
labelLight: s.label_light === true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+54
-1
@@ -364,14 +364,53 @@ export const cardStyles = css`
|
||||
pointer-events: none; /* draggable only in plan mode (rule below) */
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: calc(var(--icon-size, 2.5cqw) * 0.5);
|
||||
font-size: calc(var(--icon-size, 2.5cqw) * 0.5 * var(--rl-scale, 1));
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.15em;
|
||||
text-align: center;
|
||||
}
|
||||
.roomlabel .rlmetrics {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55em;
|
||||
font-size: 0.62em;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.roomlabel .rlm {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.12em;
|
||||
}
|
||||
.roomlabel .rlm ha-icon {
|
||||
--mdc-icon-size: 1.05em;
|
||||
display: inline-flex;
|
||||
}
|
||||
.roomlabel .rlm.lit { opacity: 1; }
|
||||
.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 {
|
||||
@@ -443,6 +482,20 @@ export const cardStyles = css`
|
||||
font-family: inherit;
|
||||
}
|
||||
.modetab ha-icon { --mdc-icon-size: 15px; }
|
||||
.modetab .closex {
|
||||
--mdc-icon-size: 13px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 2px;
|
||||
opacity: 0.75;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.modetab .closex:hover { opacity: 1; }
|
||||
.editbar .barclose {
|
||||
padding: 4px 6px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.modetab.active {
|
||||
background: var(--hp-accent);
|
||||
color: var(--text-primary-color, #fff);
|
||||
|
||||
+30
-1
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp } from '../test-build/devices.js';
|
||||
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats } from '../test-build/devices.js';
|
||||
import { compileIconRules } from '../test-build/rules.js';
|
||||
|
||||
/** Minimal fake hass around the pieces buildDevices reads. */
|
||||
@@ -280,3 +280,32 @@ test('buildDevices: humidity badge is gated on device_class, not the icon (name
|
||||
assert.notEqual(d.icon, 'mdi:water-percent'); // name rule won the icon
|
||||
assert.equal(d.hum, 45); // …but the humidity value is still shown (gated on device_class)
|
||||
});
|
||||
|
||||
test('areaLightStats: counts unique lights that are on', () => {
|
||||
const hass = { states: {
|
||||
'light.a': { state: 'on' }, 'light.b': { state: 'off' }, 'light.c': { state: 'on' },
|
||||
} };
|
||||
const devs = [
|
||||
{ area: 'living', entities: ['light.a', 'sensor.x'] },
|
||||
{ area: 'living', entities: ['light.b', 'light.a'] }, // light.a duplicated on purpose
|
||||
{ area: 'other', entities: ['light.c'] },
|
||||
];
|
||||
assert.deepEqual(areaLightStats(hass, devs, 'living'), { on: 1, total: 2 });
|
||||
assert.deepEqual(areaLightStats(hass, devs, 'other'), { on: 1, total: 1 });
|
||||
assert.equal(areaLightStats(hass, devs, 'empty'), null);
|
||||
});
|
||||
|
||||
test('areaHum: averages climate sensors only, integer %', () => {
|
||||
const hass = { states: {
|
||||
'sensor.t1_humidity': { state: '41', attributes: { device_class: 'humidity', unit_of_measurement: '%' } },
|
||||
'sensor.t2_humidity': { state: '46', attributes: { device_class: 'humidity', unit_of_measurement: '%' } },
|
||||
'sensor.fridge_humidity': { state: '90', attributes: { device_class: 'humidity', unit_of_measurement: '%' } },
|
||||
} };
|
||||
const devs = [
|
||||
{ area: 'living', icon: 'mdi:thermometer', entities: ['sensor.t1_humidity'] },
|
||||
{ area: 'living', icon: 'mdi:air-filter', entities: ['sensor.t2_humidity'] },
|
||||
{ area: 'living', icon: 'mdi:fridge', entities: ['sensor.fridge_humidity'] }, // curated out
|
||||
];
|
||||
assert.equal(areaHum(hass, devs, 'living'), 44);
|
||||
assert.equal(areaHum(hass, devs, 'nothing'), null);
|
||||
});
|
||||
|
||||
@@ -537,3 +537,10 @@ test('diffNewDevices: first run seeds the baseline silently; later additions are
|
||||
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]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user