mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
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:
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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];
|
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)
|
||||||
|
|||||||
+156
-103
File diff suppressed because one or more lines are too long
Vendored
+156
-103
File diff suppressed because one or more lines are too long
@@ -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
|
||||||
|
|||||||
@@ -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.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
|
- [ ] 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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user