feat v1.31.0: room cards — metrics line + proportional resize

- room label becomes a card: name on top, optional metrics below
  (temperature / humidity / avg zigbee / lights), four checkboxes in
  space settings, all off by default; lights render On/Off or '1 of 3'
- areaHum + areaLightStats pure helpers (+3 unit tests, 90 total)
- resize via corner handles in the Plan editor (hover), uniform 0.5-3x,
  scale stored as layout k next to the position; drag preserves it
- fix latent v1.25 regression: draggable HTML labels were never rendered
  in the Plan editor (static SVG only) — real name-only cards now render
  there, draggable and resizable
- repair merge/split smokes still calling removed _toggleMarkup
- validation.py: 4 label_* bools; smoke_room_cards.mjs; docs same-commit
This commit is contained in:
Matysh
2026-07-22 12:01:14 +03:00
parent 22992b256f
commit 821bdfbd9a
20 changed files with 824 additions and 326 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.4" 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.4" "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,
) )
+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
+156 -103
View File
File diff suppressed because one or more lines are too long
+16
View File
@@ -1,5 +1,21 @@
# 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 ## v1.30.4 — 2026-07-22
- **Escape now closes every dialog** (general settings, icon rules, device - **Escape now closes every dialog** (general settings, icon rules, device
editor, space dialog, opening editor, info cards), topmost first when editor, space dialog, opening editor, info cards), topmost first when
+7
View File
@@ -140,6 +140,13 @@ 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 - [ ] Esc closes dialogs (v1.30.4): Escape closes the topmost dialog (opening
info, device info, icon rules, general settings, device editor, opening info, device info, icon rules, general settings, device editor, opening
editor, space dialog incl. abandoning an import queue); stacked dialogs editor, space dialog incl. abandoning an import queue); stacked dialogs
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.30.4", "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 {
+122 -8
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.4'; 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;
@@ -693,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) } };
@@ -1946,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,
}; };
@@ -1957,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,
}; };
@@ -2041,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();
@@ -2133,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,
}; };
@@ -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. */
@@ -3316,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}
+9 -1
View File
@@ -252,5 +252,13 @@
"devbar.add": "Add", "devbar.add": "Add",
"devbar.show_all": "Show all", "devbar.show_all": "Show all",
"devbar.reset": "Reset", "devbar.reset": "Reset",
"devbar.rules": "Icon rules" "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}"
} }
+9 -1
View File
@@ -252,5 +252,13 @@
"devbar.add": "Добавить", "devbar.add": "Добавить",
"devbar.show_all": "Показать все", "devbar.show_all": "Показать все",
"devbar.reset": "Сброс", "devbar.reset": "Сброс",
"devbar.rules": "Правила иконок" "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,
}; };
} }
+40 -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 {
+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]);
});