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)
This commit is contained in:
Matysh
2026-07-07 18:23:22 +03:00
parent eaa722679f
commit 3eb5b4a14d
21 changed files with 217 additions and 41 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.14.0" VERSION = "1.15.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.14.0" "version": "1.15.0"
} }
+3 -1
View File
@@ -84,7 +84,9 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
vol.Optional("show_names"): bool, vol.Optional("show_names"): bool,
vol.Optional("room_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"), 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("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, extra=vol.ALLOW_EXTRA,
) )
+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();
File diff suppressed because one or more lines are too long
+18 -8
View File
File diff suppressed because one or more lines are too long
+8
View File
@@ -1,5 +1,13 @@
# Changelog # Changelog
## 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 2025 °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) ## 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, - **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 room name labels, a border/name color picker with an opacity slider, and a room
+2 -1
View File
@@ -13,7 +13,7 @@
| Item | State | | Item | State |
|---|---| |---|---|
| Version | **v1.14.0** everywhere (manifest, const.py, package.json, CARD_VERSION) | | Version | **v1.15.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…v1.11.2 | | 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) | | 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 | | 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,7 @@
- **v1.14.0** — per-space display settings (borders/names/color/opacity/fills), - **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, draggable room labels, hand-drawn spaces (no image required), demo/ harness in-repo,
docs/TESTING.md manual checklist (update with every functional change!). docs/TESTING.md manual checklist (update with every functional change!).
- **v1.15.0** — temperature room fill (blue/green/yellow) with editable comfort bounds.
## Where things live ## Where things live
+1
View File
@@ -49,6 +49,7 @@ 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] - [ ] 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 "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 "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 in the dialog (swapped bounds tolerated [auto]); rooms without a temperature reading stay unfilled [auto]; bound fields appear only when the mode is selected
- [ ] Room hover highlight still works when custom borders/fills are on - [ ] Room hover highlight still works when custom borders/fills are on
- [ ] Settings persist across reload and other browsers (server-side) - [ ] Settings persist across reload and other browsers (server-side)
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.13.3", "version": "1.14.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.13.3", "version": "1.14.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lit": "^3.1.3" "lit": "^3.1.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.14.0", "version": "1.15.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",
+12
View File
@@ -309,3 +309,15 @@ export function areaLights(hass: any, devices: { area: string; entities: string[
} }
return seen ? 'off' : 'none'; return seen ? 'off' : 'none';
} }
/** Average temperature across the area's devices (null when nothing reports one). */
export function areaTemp(hass: any, devices: { area: string; entities: string[] }[], area: string): number | null {
const vals: number[] = [];
for (const d of devices) {
if (d.area !== area) 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;
}
+27 -5
View File
@@ -14,9 +14,10 @@ import {
import { import {
lqiColor, snapToGrid, segKey as segKeyOf, samePoint, pointInPolygon, markerIdForBinding, lqiColor, snapToGrid, segKey as segKeyOf, samePoint, pointInPolygon, markerIdForBinding,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo, 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'; } from './logic';
import { buildDevices, lqiFor, tempFor, areaLights } from './devices'; import { buildDevices, lqiFor, tempFor, areaLights, areaTemp } from './devices';
import type { import type {
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig, RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
} from './types'; } from './types';
@@ -24,7 +25,7 @@ import './editor';
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.14.0'; const CARD_VERSION = '1.15.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';
@@ -127,7 +128,9 @@ class HouseplanCard extends LitElement {
showNames: boolean; showNames: boolean;
roomColor: string; roomColor: string;
roomOpacity: number; // 0..1 roomOpacity: number; // 0..1
fillMode: 'none' | 'lqi' | 'light'; fillMode: 'none' | 'lqi' | 'light' | 'temp';
tempMin: number;
tempMax: number;
busy: boolean; busy: boolean;
} | null = null; } | null = null;
private _keyHandler = (e: KeyboardEvent) => this._onKey(e); private _keyHandler = (e: KeyboardEvent) => this._onKey(e);
@@ -1458,6 +1461,7 @@ class HouseplanCard extends LitElement {
source: sp.plan_url ? 'file' : 'draw', orientation: 'landscape', source: sp.plan_url ? 'file' : 'draw', orientation: 'landscape',
showBorders: disp.showBorders, showNames: disp.showNames, showBorders: disp.showBorders, showNames: disp.showNames,
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill, roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill,
tempMin: disp.tempMin, tempMax: disp.tempMax,
busy: false, busy: false,
}; };
} else { } else {
@@ -1466,6 +1470,7 @@ class HouseplanCard extends LitElement {
source: 'file', orientation: 'landscape', source: 'file', orientation: 'landscape',
showBorders: false, showNames: false, showBorders: false, showNames: false,
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,
busy: false, busy: false,
}; };
} }
@@ -1547,6 +1552,8 @@ class HouseplanCard extends LitElement {
room_color: d.roomColor, room_color: d.roomColor,
room_opacity: d.roomOpacity, room_opacity: d.roomOpacity,
fill_mode: d.fillMode, fill_mode: d.fillMode,
temp_min: Math.min(d.tempMin, d.tempMax),
temp_max: Math.max(d.tempMin, d.tempMax),
}; };
await this._saveConfigNow(); await this._saveConfigNow();
this._spaceDialog = null; this._spaceDialog = null;
@@ -1635,6 +1642,7 @@ class HouseplanCard extends LitElement {
source: 'file', orientation: 'landscape', source: 'file', orientation: 'landscape',
showBorders: false, showNames: false, showBorders: false, showNames: false,
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,
busy: false, busy: false,
}; };
} }
@@ -1921,6 +1929,9 @@ class HouseplanCard extends LitElement {
disp.fill, disp.fill,
disp.fill === 'lqi' ? this._roomLqi(r.area) : null, disp.fill === 'lqi' ? this._roomLqi(r.area) : null,
disp.fill === 'light' ? areaLights(this.hass, this._devices, r.area) : 'none', 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; : null;
if (fillC) st.push(`--room-fill:${fillC}`, `--room-fill-op:${(0.3 * disp.opacity).toFixed(3)}`); if (fillC) st.push(`--room-fill:${fillC}`, `--room-fill-op:${(0.3 * disp.opacity).toFixed(3)}`);
@@ -2362,10 +2373,21 @@ class HouseplanCard extends LitElement {
<label>${this._t('space.fill_label')}</label> <label>${this._t('space.fill_label')}</label>
<select class="areasel" <select class="areasel"
@change=${(e: Event) => (this._spaceDialog = { ...d, fillMode: (e.target as HTMLSelectElement).value as any })}> @change=${(e: Event) => (this._spaceDialog = { ...d, fillMode: (e.target as HTMLSelectElement).value as any })}>
${[['none', 'fill.none'], ['lqi', 'fill.lqi'], ['light', 'fill.light']].map( ${[['none', 'fill.none'], ['lqi', 'fill.lqi'], ['light', 'fill.light'], ['temp', 'fill.temp']].map(
([v, k]) => html`<option value=${v} ?selected=${d.fillMode === v}>${this._t(k as any)}</option>`, ([v, k]) => html`<option value=${v} ?selected=${d.fillMode === v}>${this._t(k as any)}</option>`,
)} )}
</select> </select>
${d.fillMode === 'temp'
? html`<div class="colorrow">
<span class="opl">${this._t('space.temp_min')}</span>
<input class="namein tempin" type="number" step="0.5" .value=${String(d.tempMin)}
@input=${(e: Event) => (this._spaceDialog = { ...d, tempMin: Number((e.target as HTMLInputElement).value) })} />
<span class="opl">${this._t('space.temp_max')}</span>
<input class="namein tempin" type="number" step="0.5" .value=${String(d.tempMax)}
@input=${(e: Event) => (this._spaceDialog = { ...d, tempMax: Number((e.target as HTMLInputElement).value) })} />
<span class="opv">°C</span>
</div>`
: nothing}
</div> </div>
<div class="row"> <div class="row">
${d.mode === 'edit' ${d.mode === 'edit'
+4 -1
View File
@@ -166,5 +166,8 @@
"space.orientation": "Canvas", "space.orientation": "Canvas",
"orient.landscape": "Landscape", "orient.landscape": "Landscape",
"orient.portrait": "Portrait", "orient.portrait": "Portrait",
"orient.square": "Square" "orient.square": "Square",
"fill.temp": "Temperature (blue = cold, green = comfy, yellow = hot)",
"space.temp_min": "Comfort from",
"space.temp_max": "to"
} }
+4 -1
View File
@@ -166,5 +166,8 @@
"space.orientation": "Холст", "space.orientation": "Холст",
"orient.landscape": "Альбомный", "orient.landscape": "Альбомный",
"orient.portrait": "Портретный", "orient.portrait": "Портретный",
"orient.square": "Квадрат" "orient.square": "Квадрат",
"fill.temp": "По температуре (голубая = холодно, зелёная = комфорт, жёлтая = жарко)",
"space.temp_min": "Комфорт от",
"space.temp_max": "до"
} }
+21 -2
View File
@@ -183,7 +183,7 @@ export function subst(s: string, vars?: Record<string, string | number>): string
// ---------------- room fills & colors ---------------- // ---------------- room fills & colors ----------------
export type RoomFillMode = 'none' | 'lqi' | 'light'; export type RoomFillMode = 'none' | 'lqi' | 'light' | 'temp';
/** Per-space display settings with their defaults resolved. */ /** Per-space display settings with their defaults resolved. */
export interface SpaceDisplay { export interface SpaceDisplay {
@@ -192,10 +192,14 @@ export interface SpaceDisplay {
color: string; // hex like #3ea6ff color: string; // hex like #3ea6ff
opacity: number; // 0..1 — applied to borders, names and fills opacity: number; // 0..1 — applied to borders, names and fills
fill: RoomFillMode; 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_COLOR = '#3ea6ff';
export const DEFAULT_ROOM_OPACITY = 0.55; 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. */ /** Resolve a space's display settings; spaces without a plan default to visible markup. */
export function spaceDisplayOf(spaceCfg: any): SpaceDisplay { export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
@@ -206,7 +210,9 @@ export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
showNames: s.show_names ?? noPlan, 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, 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, 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, mode: RoomFillMode,
lqi: number | null, lqi: number | null,
lights: 'on' | 'off' | 'none', lights: 'on' | 'off' | 'none',
temp?: number | null,
tempMin: number = DEFAULT_TEMP_MIN,
tempMax: number = DEFAULT_TEMP_MAX,
): string | null { ): string | null {
if (mode === 'lqi') return lqi == null ? null : lqiColor(lqi); if (mode === 'lqi') return lqi == null ? null : lqiColor(lqi);
if (mode === 'light') { if (mode === 'light') {
if (lights === 'none') return null; if (lights === 'none') return null;
return lights === 'on' ? '#ffd45c' : '#9aa0a6'; 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; return null;
} }
+1
View File
@@ -440,6 +440,7 @@ export const cardStyles = css`
cursor: pointer; cursor: pointer;
} }
.colorrow input[type='range'] { flex: 1; } .colorrow input[type='range'] { flex: 1; }
.colorrow .tempin { width: 70px; flex: none; }
.colorrow .opl { color: var(--hp-muted); font-size: 12px; } .colorrow .opl { color: var(--hp-muted); font-size: 12px; }
.colorrow .opv { font-size: 12px; min-width: 34px; text-align: right; } .colorrow .opv { font-size: 12px; min-width: 34px; text-align: right; }
.planrow { .planrow {
+17 -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, 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'; import { compileIconRules } from '../test-build/rules.js';
/** Minimal fake hass around the pieces buildDevices reads. */ /** Minimal fake hass around the pieces buildDevices reads. */
@@ -170,3 +170,19 @@ test('areaLights: on / off / none tri-state', () => {
assert.equal(areaLights(hass, devs, 'kitchen'), 'off'); assert.equal(areaLights(hass, devs, 'kitchen'), 'off');
assert.equal(areaLights(hass, devs, 'bath'), 'none'); assert.equal(areaLights(hass, devs, 'bath'), 'none');
}); });
test('areaTemp: averages device temperatures, 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', entities: ['sensor.t1'] },
{ area: 'living', entities: ['sensor.t2'] },
{ area: 'bath', 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);
});
+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, 'off'), '#9aa0a6');
assert.equal(roomFillColor('light', null, 'none'), null); 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"}) bad_mode = dict(ok, settings={"fill_mode": "rainbow"})
with _pytest.raises(Exception): with _pytest.raises(Exception):
v.SPACE_SCHEMA(bad_mode) 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)