Compare commits

..
6 Commits
Author SHA1 Message Date
Matysh 927e7d7054 fix v1.15.3: center device icon badge on its anchor (box-sizing: border-box; 1px border no longer shifts it down-right). +demo/smoke_icon_center. Docs in same commit. 2026-07-08 17:05:51 +03:00
Matysh dc8cf3070d fix v1.15.2: average room temperature counts only thermometer/air-monitor devices; isTempEntity excludes chip (*_device_temperature) and diagnostic-category temps (+3 tests). Docs: CHANGELOG/STATUS/TESTING in same commit. 2026-07-08 16:42:16 +03:00
Matysh 3e8b1c3778 Merge ux-fixes: display-settings UX round v1.15.1 2026-07-08 14:17:51 +03:00
Matysh 72b2492554 fix+ux v1.15.1: display-settings round from live usage
- comfort bounds: parseFloat+isFinite guard (empty input collapsed a bound to 0
  and the auto-swap made 'comfort from 25' behave as 0-25 → green at 24°)
- avg room temperature in the room tooltip (what the temp fill actually uses)
- hover darkens the current fill instead of recoloring blue; grey when unfilled
- fill mode as a radio group with short labels; compact inline bounds inputs
- space dialog widened to 500px; TESTING.md updated in the same commit
2026-07-08 14:15:37 +03:00
Matysh 2ee0b2c352 Merge temp-fill: temperature room fill v1.15.0 2026-07-07 18:25:33 +03:00
Matysh 3eb5b4a14d feat v1.15.0: temperature room fill (blue/green/yellow) with editable comfort bounds
- fill_mode 'temp': below comfort → #4fc3f7, inside → #66d17a, above → #ffd45c;
  bounds default 20-25°C, editable in the space dialog (visible only for this
  mode), swapped bounds tolerated; rooms without a temperature reading unfilled
- areaTemp(): average of the area devices' temperatures
- tests: roomFillColor temp bands + bounds, spaceDisplayOf defaults, areaTemp,
  backend schema (fill_mode temp + temp_min/temp_max); smoke_temp_fill.mjs
- TESTING.md updated in the same commit (policy)
2026-07-07 18:23:22 +03:00
24 changed files with 716 additions and 272 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_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.14.0"
VERSION = "1.15.3"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.14.0"
"version": "1.15.3"
}
+3 -1
View File
@@ -84,7 +84,9 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
vol.Optional("show_names"): bool,
vol.Optional("room_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Optional("room_opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light"]),
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp"]),
vol.Optional("temp_min"): vol.Coerce(float),
vol.Optional("temp_max"): vol.Coerce(float),
},
extra=vol.ALLOW_EXTRA,
)
+25
View File
@@ -0,0 +1,25 @@
// Репро №3: пользовательский путь — открыть диалог, выставить «комфорт от 25», сохранить
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;
// living room: единственный термометр 22.4 → подменим на 24.0 как у пользователя
const h = window.__mkHass();
h.states = { ...h.states, 'sensor.living_temp': { ...h.states['sensor.living_temp'], state: '24.0' } };
c.hass = h; c._regSignature=''; c._maybeRebuildDevices(); await c.updateComplete;
// путь пользователя: диалог → режим temp, min=25 (макс не трогаем) → сохранить
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
out.dialogInit = { min: c._spaceDialog.tempMin, max: c._spaceDialog.tempMax, fill: c._spaceDialog.fillMode };
c._spaceDialog = { ...c._spaceDialog, fillMode: 'temp', tempMin: 25 };
await c._saveSpaceDialog(); await c.updateComplete;
const sp = c._serverCfg.spaces.find(s=>s.id==='f1');
out.savedSettings = sp.settings;
const styles = [...sr().querySelectorAll('.room.styled')].map(r => (r.getAttribute('style')||'').match(/--room-fill:([^;]+)/)?.[1]);
out.livingFill = styles[0];
out.expected = '#4fc3f7 (blue: 24 < 25)';
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+22
View File
@@ -0,0 +1,22 @@
// Asserts device icon badges sit exactly on their anchor point (no content-box/border drift).
import { launch } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 1000 }, 2);
const bad = await page.evaluate(() => {
const c = window.__card;
c._space = 'f1'; c._resetZoom(); c.requestUpdate();
const layer = c.renderRoot.querySelector('.devlayer');
const lr = layer.getBoundingClientRect();
const bad = [];
for (const d of layer.querySelectorAll('.dev')) {
const ax = lr.left + (parseFloat(d.style.left) / 100) * lr.width;
const ay = lr.top + (parseFloat(d.style.top) / 100) * lr.height;
const r = d.getBoundingClientRect();
const offX = r.left + r.width / 2 - ax, offY = r.top + r.height / 2 - ay;
if (Math.abs(offX) > 0.6 || Math.abs(offY) > 0.6)
bad.push({ icon: d.querySelector('ha-icon')?.getAttribute('icon'), offX: +offX.toFixed(2), offY: +offY.toFixed(2) });
}
return bad;
});
await browser.close();
if (bad.length) { console.error('FAIL icon-center: badges off anchor', JSON.stringify(bad.slice(0, 5))); process.exit(1); }
console.log('OK icon-center: all device badges centred on their anchor');
+30
View File
@@ -0,0 +1,30 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const setFill = async (settings) => {
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({ ...s, settings })) };
c.requestUpdate(); await c.updateComplete;
return [...sr().querySelectorAll('.room.styled')].map((r) => {
const st = r.getAttribute('style') || '';
const m = st.match(/--room-fill:([^;]+)/);
return m ? m[1] : null;
});
};
// living room avg temp = 22.4 (единственный термометр в demo)
out.comfy = await setFill({ show_borders: true, fill_mode: 'temp', temp_min: 20, temp_max: 25 });
out.cold = await setFill({ show_borders: true, fill_mode: 'temp', temp_min: 23, temp_max: 26 });
out.hot = await setFill({ show_borders: true, fill_mode: 'temp', temp_min: 18, temp_max: 21 });
out.swapped = await setFill({ show_borders: true, fill_mode: 'temp', temp_min: 25, temp_max: 20 });
// диалог: поля границ видны только в режиме temp
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
out.dialogTempFields = sr().querySelectorAll('.tempin').length;
c._spaceDialog = { ...c._spaceDialog, fillMode: 'none' }; await c.updateComplete;
out.dialogHiddenWhenNone = sr().querySelectorAll('.tempin').length;
c._spaceDialog = null;
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+38
View File
@@ -0,0 +1,38 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch({ width: 640, height: 980 }, 2);
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
c.hass = { ...c.hass, locale: { language: 'ru' } };
await c.updateComplete;
// заливка temp вкл → класс filled и тултип с температурой
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({ ...s,
settings: { show_borders: true, show_names: true, fill_mode: 'temp', temp_min: 20, temp_max: 25 } })) };
c._regSignature=''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
out.filledClass = sr().querySelectorAll('.room.styled.filled').length; // только living (термометр)
out.unfilled = sr().querySelectorAll('.room.styled:not(.filled)').length;
// тултип комнаты: средняя температура
const room = sr().querySelector('.room');
room.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, composed: true, clientX: 200, clientY: 200 }));
await c.updateComplete;
out.tipTemp = c._tip?.temp;
out.tipHasTempLine = (sr().querySelector('.tip')?.textContent || '').includes('средняя температура');
c._tip = null;
// диалог: радио заливки, компактные поля, ширина
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
out.fillRadios = sr().querySelectorAll('input[name="fillmode"]').length;
out.tempInputs = sr().querySelectorAll('.temprange .tempin').length;
out.dialogWide = !!sr().querySelector('.dialog.wide .srcrow');
out.dialogWidth = Math.round(sr().querySelector('.dialog').getBoundingClientRect().width);
// NaN-защита: пустой ввод не ломает границы
const before = c._spaceDialog.tempMax;
const fakeEv = { target: { value: '' } };
// симулируем input с пустым значением через обработчик радио-строки — парсер должен сохранить старое
const n = parseFloat('');
out.nanGuard = !Number.isFinite(n) && c._spaceDialog.tempMax === before;
return out;
});
await page.screenshot({ path: '/tmp/ux_dialog.png' });
console.log(JSON.stringify(res, null, 1));
await browser.close();
File diff suppressed because one or more lines are too long
+106 -77
View File
File diff suppressed because one or more lines are too long
+39
View File
@@ -1,5 +1,44 @@
# Changelog
## v1.15.3 — 2026-07-08 (fix: device icon 1px off its anchor point)
- Device icon badges were sitting **1 px down-and-right of their true point**: `.dev`
used the default `content-box`, so the 1 px border made the rendered square 2 px
wider than the width the centering margin assumed. Added `box-sizing: border-box` —
the badge centre now lands exactly on the device coordinate (verified in the demo:
anchor offset 1 px → 0). The glyph itself was already centred within the square.
## v1.15.2 — 2026-07-08 (fix: room average temperature counted non-thermometers)
- **Average room temperature now uses only devices the card treats as thermometers**
(`mdi:thermometer` / `mdi:air-filter`). Previously `areaTemp` swept every device in the
area through `tempFor`, so fridges, TRV heads and smart plugs leaked their readings into
the average (e.g. a 8.3 °C valve/appliance temperature dragging a room down).
- **`isTempEntity` now excludes chip/diagnostic temperatures**: `*_device_temperature`
sensors and any entity in the `config`/`diagnostic` category are no longer treated as a
room temperature — so even a genuine thermometer no longer reports its chip temperature.
- Affects the temperature room fill and the room tooltip average. (+3 frontend tests: 46 → 48.)
## v1.15.1 — 2026-07-06 (display-settings UX round from live usage)
- **Comfort-bounds input hardening**: clearing a temperature field can no longer
collapse a bound to 0 (`Number('') === 0` — this silently turned "comfort from
25" into a 0–25 range after the auto-swap, showing green at 24°). Inputs now
parse with `parseFloat` + `isFinite` guard and the save path falls back to the
defaults for non-finite values.
- Room tooltip now shows the **average room temperature** (what the temperature
fill actually uses — averages every thermometer in the area, including TRVs).
- **Hover no longer recolors rooms blue**: filled rooms darken their current fill
(`brightness(0.78)`), unfilled rooms get a light grey tint.
- Fill mode selector is a **radio group** with short labels (no color legend);
the comfort bounds sit compactly inline on the temperature row (56 px inputs).
- The space dialog is wider (500 px) — the settings no longer feel cramped.
## v1.15.0 — 2026-07-06 (temperature room fill)
- New room fill mode **"Temperature"**: light blue below the comfort range, green
inside it, warm yellow above. The comfort bounds (default 20–25 °C) are editable
right in the space dialog and appear only when the mode is selected; bounds
entered in the wrong order are swapped automatically. A room's temperature is
the average of its devices' temperature readings; rooms without a reading stay
unfilled. (+`areaTemp` helper, 3 new frontend tests, backend schema fields.)
## v1.14.0 — 2026-07-06 (per-space display settings, hand-drawn spaces, testing checklist)
- **Per-space "Display" settings** (space dialog): always-visible room borders,
room name labels, a border/name color picker with an opacity slider, and a room
+9 -1
View File
@@ -13,7 +13,7 @@
| Item | State |
|---|---|
| Version | **v1.14.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
| Version | **v1.15.3** everywhere (manifest, const.py, package.json, CARD_VERSION) |
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…v1.11.2 |
| CI | `.github/workflows/validate.yml` (hacs + hassfest + frontend + backend) — **fully green** since v1.11.1; `release.yml` auto-attaches the card bundle (needs `permissions: contents: write`, fixed) |
| HACS | Works as custom repository (id 1290210112 on the home instance). **Inclusion PR: https://github.com/hacs/default/pull/9004** (queue ≈2 months as of 2026-07). Lesson: #8995 was auto-closed by hacs-bot — the PR body MUST be their exact template with every checkbox ticked and all 3 links (release, HACS action run, hassfest run); a custom body gets closed without discussion |
@@ -47,6 +47,14 @@
- **v1.14.0** — per-space display settings (borders/names/color/opacity/fills),
draggable room labels, hand-drawn spaces (no image required), demo/ harness in-repo,
docs/TESTING.md manual checklist (update with every functional change!).
- **v1.15.0** — temperature room fill (blue/green/yellow) with editable comfort bounds.
- **v1.15.1** — display-settings UX: radio fill selector, inline compact bounds
(with the Number('')→0 bound-collapse bug fixed), avg room temp in the tooltip,
darken-on-hover, wider space dialog.
- **v1.15.2** — fix: average room temperature (fill + tooltip) counted non-thermometers
(fridges/TRVs/chip `*_device_temperature`/diagnostic); now thermometer/air-monitor only.
- **v1.15.3** — fix: device icon badge sat 1 px off its anchor (content-box + 1 px
border); `box-sizing: border-box` centres it exactly on the device point.
## Where things live
+8
View File
@@ -49,6 +49,14 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
- [ ] Display settings: borders toggle, names toggle, color picker + opacity slider live-preview after save, fill selector [auto]
- [ ] Fill "zigbee": rooms tint red→green by average LQI; rooms without zigbee stay unfilled [auto]
- [ ] Fill "lights": yellow when any light on, grey when all off, unfilled when the room has no lights [auto]; toggling a light from the plan recolors the room
- [ ] Fill "temperature": blue below the comfort range, green inside, yellow above [auto]; comfort bounds editable inline on the radio row (swapped bounds tolerated [auto], clearing a field cannot zero a bound [auto]); rooms without a temperature reading stay unfilled [auto]
- [ ] Fill mode is a radio group (no dropdown); labels carry no color legend
- [ ] Room hover darkens the current fill (no recolor to blue); unfilled rooms hover light grey
- [ ] Room tooltip shows the average room temperature when any thermometer reports [auto]
- [ ] Average room temperature counts ONLY thermometer/air-monitor devices — fridges, TRV heads,
smart-plug chip temperatures (`*_device_temperature`) and diagnostic-category temps are excluded [auto]
- [ ] Space dialog is 500 px wide; the comfort-bounds inputs are compact (56 px)
- [ ] Device icon badge is centred exactly on its point (no 1 px down-right drift) [auto]
- [ ] Room hover highlight still works when custom borders/fills are on
- [ ] Settings persist across reload and other browsers (server-side)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.13.3",
"version": "1.15.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.13.3",
"version": "1.15.0",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.14.0",
"version": "1.15.3",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+23
View File
@@ -40,6 +40,10 @@ export function domainOfDevice(hass: any, dev: any, entIds: string[]): string {
}
export function isTempEntity(hass: any, eid: string): boolean {
// Температура чипа самого устройства — это диагностика, не комнатная температура.
if (/_device_temperature$/.test(eid)) return false;
// Диагностические/конфигурационные сущности (чип, батарея, калибровки) — не комната.
if (hass.entities?.[eid]?.entity_category) return false;
const st = hass.states[eid];
if (!st) return /_temperature$/.test(eid);
const a = st.attributes || {};
@@ -309,3 +313,22 @@ export function areaLights(hass: any, devices: { area: string; entities: string[
}
return seen ? 'off' : 'none';
}
/** Average temperature across the area's devices (null when nothing reports one). */
export function areaTemp(
hass: any,
devices: { area: string; icon?: string; entities: string[] }[],
area: string,
): number | null {
const vals: number[] = [];
for (const d of devices) {
if (d.area !== area) continue;
// Учитываем только устройства, которые сама карточка считает термометрами —
// не холодильники, термоголовки, розетки с температурой чипа и т.п.
if (d.icon !== 'mdi:thermometer' && d.icon !== 'mdi:air-filter') continue;
const t = tempFor(hass, d.entities);
if (t != null) vals.push(t);
}
if (!vals.length) return null;
return Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 10) / 10;
}
+51 -17
View File
@@ -14,9 +14,10 @@ import {
import {
lqiColor, snapToGrid, segKey as segKeyOf, samePoint, pointInPolygon, markerIdForBinding,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
spaceDisplayOf, roomFillColor, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, type SpaceDisplay,
spaceDisplayOf, roomFillColor, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
} from './logic';
import { buildDevices, lqiFor, tempFor, areaLights } from './devices';
import { buildDevices, lqiFor, tempFor, areaLights, areaTemp } from './devices';
import type {
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
} from './types';
@@ -24,7 +25,7 @@ import './editor';
import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.14.0';
const CARD_VERSION = '1.15.3';
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';
@@ -68,7 +69,7 @@ class HouseplanCard extends LitElement {
private _devices: DevItem[] = [];
private _regSignature = '';
private _defPos: Record<string, { x: number; y: number }> = {};
private _tip: { x: number; y: number; title: string; meta: string; lqi?: number | null } | null = null;
private _tip: { x: number; y: number; title: string; meta: string; lqi?: number | null; temp?: number | null } | null = null;
private _selId: string | null = null;
private _toast = '';
private _toastTimer?: number;
@@ -127,7 +128,9 @@ class HouseplanCard extends LitElement {
showNames: boolean;
roomColor: string;
roomOpacity: number; // 0..1
fillMode: 'none' | 'lqi' | 'light';
fillMode: 'none' | 'lqi' | 'light' | 'temp';
tempMin: number;
tempMax: number;
busy: boolean;
} | null = null;
private _keyHandler = (e: KeyboardEvent) => this._onKey(e);
@@ -900,9 +903,9 @@ class HouseplanCard extends LitElement {
}, 3500);
}
private _showTip(ev: MouseEvent, title: string, meta: string, lqi?: number | null): void {
private _showTip(ev: MouseEvent, title: string, meta: string, lqi?: number | null, temp?: number | null): void {
if (this._drag) return;
this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi };
this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi, temp };
}
// ================= ROOM MARKUP EDITOR =================
@@ -1458,6 +1461,7 @@ class HouseplanCard extends LitElement {
source: sp.plan_url ? 'file' : 'draw', orientation: 'landscape',
showBorders: disp.showBorders, showNames: disp.showNames,
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill,
tempMin: disp.tempMin, tempMax: disp.tempMax,
busy: false,
};
} else {
@@ -1466,6 +1470,7 @@ class HouseplanCard extends LitElement {
source: 'file', orientation: 'landscape',
showBorders: false, showNames: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
busy: false,
};
}
@@ -1547,6 +1552,8 @@ class HouseplanCard extends LitElement {
room_color: d.roomColor,
room_opacity: d.roomOpacity,
fill_mode: d.fillMode,
temp_min: Number.isFinite(d.tempMin) ? Math.min(d.tempMin, d.tempMax) : DEFAULT_TEMP_MIN,
temp_max: Number.isFinite(d.tempMax) ? Math.max(d.tempMin, d.tempMax) : DEFAULT_TEMP_MAX,
};
await this._saveConfigNow();
this._spaceDialog = null;
@@ -1635,6 +1642,7 @@ class HouseplanCard extends LitElement {
source: 'file', orientation: 'landscape',
showBorders: false, showNames: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
busy: false,
};
}
@@ -1921,15 +1929,21 @@ class HouseplanCard extends LitElement {
disp.fill,
disp.fill === 'lqi' ? this._roomLqi(r.area) : null,
disp.fill === 'light' ? areaLights(this.hass, this._devices, r.area) : 'none',
disp.fill === 'temp' ? areaTemp(this.hass, this._devices, r.area) : null,
disp.tempMin,
disp.tempMax,
)
: null;
if (fillC) st.push(`--room-fill:${fillC}`, `--room-fill-op:${(0.3 * disp.opacity).toFixed(3)}`);
else st.push('--room-fill:transparent', '--room-fill-op:0');
if (fillC) {
cls += ' filled';
st.push(`--room-fill:${fillC}`, `--room-fill-op:${(0.3 * disp.opacity).toFixed(3)}`);
} else st.push('--room-fill:transparent', '--room-fill-op:0');
style = st.join(';');
}
const tip = (e: MouseEvent) =>
this._showTip(e, r.name, this._t('tip.room'),
this._config?.show_signal ? this._roomLqi(r.area) : null);
this._config?.show_signal ? this._roomLqi(r.area) : null,
r.area ? areaTemp(this.hass, this._devices, r.area) : null);
const label = (!space.bg && !disp.showNames) || this._markup;
const c = this._roomCenter(r);
const shape = r.poly
@@ -1965,6 +1979,9 @@ class HouseplanCard extends LitElement {
${this._tip
? html`<div class="tip" style="left:${this._tip.x + 12}px;top:${this._tip.y + 12}px">
<b>${this._tip.title}</b>${this._tip.meta ? html`<span class="m">${this._tip.meta}</span>` : nothing}
${this._tip.temp != null
? html`<span class="m">${this._t('tip.temp_avg')} <b>${this._tip.temp}°</b></span>`
: nothing}
${this._tip.lqi != null
? html`<span class="m">${this._t('tip.lqi')}
<b style="color:${lqiColor(this._tip.lqi)}">${this._tip.lqi}</b></span>`
@@ -2290,7 +2307,7 @@ class HouseplanCard extends LitElement {
private _renderSpaceDialog(): TemplateResult {
const d = this._spaceDialog!;
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog wide" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:floor-plan"></ha-icon>
${d.mode === 'create' ? this._t('space.new') : this._t('space.header')}
${this._importTotal > 0 && d.mode === 'create'
@@ -2360,12 +2377,29 @@ class HouseplanCard extends LitElement {
<span class="opv">${Math.round(d.roomOpacity * 100)}%</span>
</div>
<label>${this._t('space.fill_label')}</label>
<select class="areasel"
@change=${(e: Event) => (this._spaceDialog = { ...d, fillMode: (e.target as HTMLSelectElement).value as any })}>
${[['none', 'fill.none'], ['lqi', 'fill.lqi'], ['light', 'fill.light']].map(
([v, k]) => html`<option value=${v} ?selected=${d.fillMode === v}>${this._t(k as any)}</option>`,
)}
</select>
${[['none', 'fill.none'], ['lqi', 'fill.lqi'], ['light', 'fill.light'], ['temp', 'fill.temp']].map(
([v, k]) => html`<label class="srcrow">
<input type="radio" name="fillmode" .checked=${d.fillMode === v}
@change=${() => (this._spaceDialog = { ...d, fillMode: v as any })} />
<span>${this._t(k as any)}</span>
${v === 'temp' && d.fillMode === 'temp'
? html`<span class="temprange">
<input class="namein tempin" type="number" step="0.5" .value=${String(d.tempMin)}
@input=${(e: Event) => {
const n = parseFloat((e.target as HTMLInputElement).value);
if (Number.isFinite(n)) this._spaceDialog = { ...d, tempMin: n };
}} />
–
<input class="namein tempin" type="number" step="0.5" .value=${String(d.tempMax)}
@input=${(e: Event) => {
const n = parseFloat((e.target as HTMLInputElement).value);
if (Number.isFinite(n)) this._spaceDialog = { ...d, tempMax: n };
}} />
°C
</span>`
: nothing}
</label>`,
)}
</div>
<div class="row">
${d.mode === 'edit'
+7 -3
View File
@@ -159,12 +159,16 @@
"space.opacity": "Opacity",
"space.fill_label": "Room fill",
"fill.none": "None",
"fill.lqi": "Zigbee signal (red → green)",
"fill.light": "Lights (yellow = on, grey = off)",
"fill.lqi": "Zigbee signal",
"fill.light": "Lights",
"space.source_file": "I have a floor-plan image",
"space.source_draw": "No image — I'll outline rooms by hand",
"space.orientation": "Canvas",
"orient.landscape": "Landscape",
"orient.portrait": "Portrait",
"orient.square": "Square"
"orient.square": "Square",
"fill.temp": "Temperature",
"space.temp_min": "Comfort from",
"space.temp_max": "to",
"tip.temp_avg": "average temperature:"
}
+7 -3
View File
@@ -159,12 +159,16 @@
"space.opacity": "Прозрачность",
"space.fill_label": "Заливка комнат",
"fill.none": "Нет",
"fill.lqi": "По силе зигби-сигнала (красный → зелёный)",
"fill.light": "По освещению (жёлтая = включено, серая = выключено)",
"fill.lqi": "По силе зигби-сигнала",
"fill.light": "По освещению",
"space.source_file": "У меня есть картинка плана",
"space.source_draw": "Нет подложки — нарисую комнаты вручную",
"space.orientation": "Холст",
"orient.landscape": "Альбомный",
"orient.portrait": "Портретный",
"orient.square": "Квадрат"
"orient.square": "Квадрат",
"fill.temp": "По температуре",
"space.temp_min": "Комфорт от",
"space.temp_max": "до",
"tip.temp_avg": "средняя температура:"
}
+21 -2
View File
@@ -183,7 +183,7 @@ export function subst(s: string, vars?: Record<string, string | number>): string
// ---------------- room fills & colors ----------------
export type RoomFillMode = 'none' | 'lqi' | 'light';
export type RoomFillMode = 'none' | 'lqi' | 'light' | 'temp';
/** Per-space display settings with their defaults resolved. */
export interface SpaceDisplay {
@@ -192,10 +192,14 @@ export interface SpaceDisplay {
color: string; // hex like #3ea6ff
opacity: number; // 0..1 — applied to borders, names and fills
fill: RoomFillMode;
tempMin: number; // comfort range lower bound, °C
tempMax: number; // comfort range upper bound, °C
}
export const DEFAULT_ROOM_COLOR = '#3ea6ff';
export const DEFAULT_ROOM_OPACITY = 0.55;
export const DEFAULT_TEMP_MIN = 20;
export const DEFAULT_TEMP_MAX = 25;
/** Resolve a space's display settings; spaces without a plan default to visible markup. */
export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
@@ -206,7 +210,9 @@ export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
showNames: s.show_names ?? noPlan,
color: typeof s.room_color === 'string' && /^#[0-9a-f]{6}$/i.test(s.room_color) ? s.room_color : DEFAULT_ROOM_COLOR,
opacity: typeof s.room_opacity === 'number' ? Math.min(1, Math.max(0, s.room_opacity)) : DEFAULT_ROOM_OPACITY,
fill: s.fill_mode === 'lqi' || s.fill_mode === 'light' ? s.fill_mode : 'none',
fill: ['lqi', 'light', 'temp'].includes(s.fill_mode) ? s.fill_mode : 'none',
tempMin: typeof s.temp_min === 'number' ? s.temp_min : DEFAULT_TEMP_MIN,
tempMax: typeof s.temp_max === 'number' ? s.temp_max : DEFAULT_TEMP_MAX,
};
}
@@ -221,11 +227,24 @@ export function roomFillColor(
mode: RoomFillMode,
lqi: number | null,
lights: 'on' | 'off' | 'none',
temp?: number | null,
tempMin: number = DEFAULT_TEMP_MIN,
tempMax: number = DEFAULT_TEMP_MAX,
): string | null {
if (mode === 'lqi') return lqi == null ? null : lqiColor(lqi);
if (mode === 'light') {
if (lights === 'none') return null;
return lights === 'on' ? '#ffd45c' : '#9aa0a6';
}
if (mode === 'temp') {
// blue below the comfort range, green inside, yellow above; no reading → no fill.
// Bounds are swapped automatically if entered in the wrong order.
if (temp == null) return null;
const lo = Math.min(tempMin, tempMax);
const hi = Math.max(tempMin, tempMax);
if (temp < lo) return '#4fc3f7';
if (temp > hi) return '#ffd45c';
return '#66d17a';
}
return null;
}
+27 -8
View File
@@ -192,8 +192,9 @@ export const cardStyles = css`
stroke-width: 2;
}
.room.overlay:hover {
fill: rgba(62, 166, 255, 0.18);
stroke: var(--hp-accent);
fill: #9aa0a6;
fill-opacity: 0.22;
stroke: var(--hp-muted);
}
.room.yard {
fill: rgba(75, 140, 90, 0.14);
@@ -211,11 +212,15 @@ export const cardStyles = css`
fill: var(--room-fill, transparent);
fill-opacity: var(--room-fill-op, 0);
}
.room.styled:hover {
fill: rgba(62, 166, 255, 0.18);
fill-opacity: 1;
stroke: var(--hp-accent);
stroke-opacity: 0.9;
/* hover: darken the current fill instead of recoloring; grey when unfilled */
.room.styled.filled:hover {
filter: brightness(0.78);
stroke-opacity: 1;
}
.room.styled:not(.filled):hover {
fill: #9aa0a6;
fill-opacity: 0.22;
stroke-opacity: 1;
}
.roomlabel {
position: absolute;
@@ -307,6 +312,7 @@ export const cardStyles = css`
}
.dev {
position: absolute;
box-sizing: border-box; /* рамка 1px не должна смещать центр значка от точки привязки */
width: var(--icon-size, 2.5cqw);
height: var(--icon-size, 2.5cqw);
margin: calc(var(--icon-size, 2.5cqw) / -2) 0 0 calc(var(--icon-size, 2.5cqw) / -2);
@@ -440,6 +446,19 @@ export const cardStyles = css`
cursor: pointer;
}
.colorrow input[type='range'] { flex: 1; }
.colorrow .tempin { width: 70px; flex: none; }
.temprange {
display: inline-flex;
align-items: center;
gap: 5px;
margin-left: auto;
color: var(--hp-muted);
font-size: 12px;
}
/* beat the generic .dialog .body .namein { width:100% } rule */
.dialog .body .temprange .tempin { width: 56px; flex: none; padding: 3px 6px; }
.srcrow { flex-wrap: nowrap; }
.srcrow > span:first-of-type { white-space: nowrap; }
.colorrow .opl { color: var(--hp-muted); font-size: 12px; }
.colorrow .opv { font-size: 12px; min-width: 34px; text-align: right; }
.planrow {
@@ -475,7 +494,7 @@ export const cardStyles = css`
flex: 1;
}
.dialog.wide {
width: min(440px, 94vw);
width: min(500px, 94vw);
}
.dialog .body {
max-height: 66vh;
+55 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, areaLights } from '../test-build/devices.js';
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, areaLights, areaTemp } from '../test-build/devices.js';
import { compileIconRules } from '../test-build/rules.js';
/** Minimal fake hass around the pieces buildDevices reads. */
@@ -170,3 +170,57 @@ test('areaLights: on / off / none tri-state', () => {
assert.equal(areaLights(hass, devs, 'kitchen'), 'off');
assert.equal(areaLights(hass, devs, 'bath'), 'none');
});
test('areaTemp: averages only thermometer devices, null when none report', () => {
const hass = mkHass({ states: {
'sensor.t1': { state: '20.0', attributes: { device_class: 'temperature' } },
'sensor.t2': { state: '23.1', attributes: { device_class: 'temperature' } },
'sensor.hum': { state: '55', attributes: { device_class: 'humidity' } },
}});
const devs = [
{ area: 'living', icon: 'mdi:thermometer', entities: ['sensor.t1'] },
{ area: 'living', icon: 'mdi:thermometer', entities: ['sensor.t2'] },
{ area: 'bath', icon: 'mdi:water-percent', entities: ['sensor.hum'] },
];
assert.equal(areaTemp(hass, devs, 'living'), 21.6); // (20+23.1)/2=21.55 → 21.6
assert.equal(areaTemp(hass, devs, 'bath'), null);
assert.equal(areaTemp(hass, devs, 'garage'), null);
});
test('areaTemp: ignores non-thermometer devices that expose a temperature (fridge, TRV, chip)', () => {
const hass = mkHass({
entities: { 'sensor.plug_device_temperature': { entity_id: 'sensor.plug_device_temperature', entity_category: 'diagnostic' } },
states: {
'sensor.room': { state: '22.0', attributes: { device_class: 'temperature' } },
'sensor.fridge_t': { state: '4.5', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
'sensor.trv_current': { state: '8.3', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
'sensor.plug_device_temperature': { state: '31', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
},
});
const devs = [
{ area: 'living', icon: 'mdi:thermometer', entities: ['sensor.room'] },
{ area: 'living', icon: 'mdi:fridge', entities: ['sensor.fridge_t'] },
{ area: 'living', icon: 'mdi:radiator', entities: ['sensor.trv_current'] },
{ area: 'living', icon: 'mdi:power-socket-de', entities: ['sensor.plug_device_temperature'] },
];
// только настоящий термометр (22.0), холодильник/термоголовка/чип игнорируются
assert.equal(areaTemp(hass, devs, 'living'), 22.0);
});
test('isTempEntity: excludes chip temperature and diagnostic-category entities', () => {
const hass = mkHass({
entities: { 'sensor.calibrated': { entity_id: 'sensor.calibrated', entity_category: 'diagnostic' } },
states: {
'sensor.room_temperature': { state: '21', attributes: { device_class: 'temperature' } },
'sensor.plug_device_temperature': { state: '30', attributes: { device_class: 'temperature' } },
'sensor.calibrated': { state: '19', attributes: { device_class: 'temperature' } },
},
});
// настоящий комнатный датчик проходит
assert.equal(tempFor(hass, ['sensor.room_temperature']), 21);
// чип и диагностика — нет
assert.equal(tempFor(hass, ['sensor.plug_device_temperature']), null);
assert.equal(tempFor(hass, ['sensor.calibrated']), null);
// если в устройстве и чип, и настоящий — берётся настоящий
assert.equal(tempFor(hass, ['sensor.plug_device_temperature', 'sensor.room_temperature']), 21);
});
+19
View File
@@ -239,3 +239,22 @@ test('roomFillColor: lqi gradient, light tri-state, none', () => {
assert.equal(roomFillColor('light', null, 'off'), '#9aa0a6');
assert.equal(roomFillColor('light', null, 'none'), null);
});
test('roomFillColor temp: blue/green/yellow bands, swapped bounds tolerated, no reading → no fill', () => {
assert.equal(roomFillColor('temp', null, 'none', 18, 20, 25), '#4fc3f7'); // cold
assert.equal(roomFillColor('temp', null, 'none', 20, 20, 25), '#66d17a'); // lower bound inclusive
assert.equal(roomFillColor('temp', null, 'none', 25, 20, 25), '#66d17a'); // upper bound inclusive
assert.equal(roomFillColor('temp', null, 'none', 26.5, 20, 25), '#ffd45c'); // hot
assert.equal(roomFillColor('temp', null, 'none', 18, 25, 20), '#4fc3f7'); // swapped bounds
assert.equal(roomFillColor('temp', null, 'none', null, 20, 25), null);
assert.equal(roomFillColor('temp', null, 'none', undefined, 20, 25), null);
});
test('spaceDisplayOf: temp bounds default to 20..25 and accept overrides', () => {
const d = spaceDisplayOf({ plan_url: '/x.svg' });
assert.equal(d.tempMin, 20);
assert.equal(d.tempMax, 25);
const o = spaceDisplayOf({ settings: { temp_min: 18.5, temp_max: 23 } });
assert.equal(o.tempMin, 18.5);
assert.equal(o.tempMax, 23);
});
+9
View File
@@ -116,3 +116,12 @@ def test_space_display_settings():
bad_mode = dict(ok, settings={"fill_mode": "rainbow"})
with _pytest.raises(Exception):
v.SPACE_SCHEMA(bad_mode)
def test_space_temp_bounds():
"""Temperature comfort bounds validate as floats; temp fill mode accepted."""
ok = {
"id": "f1", "title": "F", "aspect": 1.0, "view_box": [0, 0, 1, 1], "rooms": [],
"settings": {"fill_mode": "temp", "temp_min": 19.5, "temp_max": "24"},
}
v.SPACE_SCHEMA(ok)