feat v1.14.0: per-space display settings, hand-drawn spaces, manual-testing checklist

- space dialog 'Display' section: room borders/names toggles, color+opacity,
  fill by zigbee signal or lights (tri-state: on/off/no-lights)
- draggable room name labels persisted as layout rl_<roomId>
- 'no image, outline by hand' space source with canvas orientation; image
  optional now; switching an existing space to draw detaches its plan
- demo/ synthetic-home harness lives in the repo (serve.mjs + smokes + icons gen)
- docs/TESTING.md checklist (same-commit update policy); self-run found and
  fixed: plan_url not detached on image->draw, _stateClass crash on states
  without entity_id; perf measured (162 devices ~14ms build)
- backend: SPACE_DISPLAY_SCHEMA validation (+test)
This commit is contained in:
Matysh
2026-07-07 13:41:51 +03:00
parent 6c8b509da2
commit 4f8e98cdc7
29 changed files with 2762 additions and 249 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.13.3"
VERSION = "1.14.0"
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.13.3"
"version": "1.14.0"
}
+12
View File
@@ -78,10 +78,22 @@ ROOM_SCHEMA = vol.All(
),
_require_geometry,
)
SPACE_DISPLAY_SCHEMA = vol.Schema(
{
vol.Optional("show_borders"): bool,
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"]),
},
extra=vol.ALLOW_EXTRA,
)
SPACE_SCHEMA = vol.Schema(
{
vol.Required("id"): str,
vol.Required("title"): str,
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
vol.Optional("plan_url"): vol.Any(str, None),
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
vol.Required("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
+17
View File
@@ -0,0 +1,17 @@
# Synthetic demo home
A fully fictional house (plans, devices, states) used for README screenshots,
the demo GIF and headless smoke tests — so no real home data ever appears in
public materials.
- `srv/demo.html` — self-contained host page: `<ha-icon>`/`<ha-card>` stubs and a
fake `hass` (registries, states, `callWS`, `callService`, floors).
- `srv/assets/` — generated plan SVGs and `icons.js` (`node demo/gen_icons.mjs`,
needs the repo's devDependencies). The card bundle is copied from `dist/`:
`cp dist/houseplan-card.js demo/srv/assets/`.
- `serve.mjs` — playwright launcher (route interception, no web server).
- `smoke_*.mjs` — feature smoke tests; run with a Chromium installed via
`PLAYWRIGHT_BROWSERS_PATH=<dir> npx playwright install chromium-headless-shell`.
Note for sandboxed sessions: `/tmp` does not survive; this directory is the
persistent home of the harness (docs/DEVELOPMENT.md has the LD_LIBRARY_PATH recipe).
+22
View File
@@ -0,0 +1,22 @@
// Generate demo/srv/assets/icons.js: an { "mdi:name": "<svg path>" } map for every
// mdi: icon referenced in src/ and demo/ (the demo host stubs <ha-icon> with it).
import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
import * as mdi from '@mdi/js';
const names = new Set();
const scan = (dir) => {
for (const f of readdirSync(dir, { withFileTypes: true })) {
if (f.isDirectory()) { scan(`${dir}/${f.name}`); continue; }
if (!/\.(ts|json|html|mjs)$/.test(f.name) || f.name === 'icons.js') continue;
const txt = readFileSync(`${dir}/${f.name}`, 'utf8');
for (const m of txt.matchAll(/mdi:([a-z0-9-]+)/g)) names.add(m[1]);
}
};
scan('src'); scan('demo');
const map = {};
for (const n of [...names].sort()) {
const camel = 'mdi' + n.replace(/(^|-)(\w)/g, (_, __, c) => c.toUpperCase());
if (mdi[camel]) map['mdi:' + n] = mdi[camel];
}
writeFileSync('demo/srv/assets/icons.js', 'window.__ICONS=' + JSON.stringify(map) + ';\n');
console.log('icons:', Object.keys(map).length, 'of', names.size, 'referenced');
+28
View File
@@ -0,0 +1,28 @@
// Shared launcher for demo captures: starts headless Chromium serving demo/srv/
// via request interception (no HTTP server needed). Usage: const {page,browser}=await launch();
import { chromium } from 'playwright';
import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const ROOT = dirname(fileURLToPath(import.meta.url)) + '/srv';
const CT = { '.html': 'text/html', '.js': 'text/javascript', '.svg': 'image/svg+xml' };
export async function launch(viewport = { width: 820, height: 760 }, scale = 1) {
const browser = await chromium.launch({ args: ['--no-sandbox'] });
const page = await (await browser.newContext({ viewport, deviceScaleFactor: scale })).newPage();
page.on('pageerror', (e) => console.log('EXC', e.message));
await page.route('**/*', (r) => {
const u = new URL(r.request().url());
let p = decodeURIComponent(u.pathname);
if (p === '/') p = '/demo.html';
const f = ROOT + p;
existsSync(f)
? r.fulfill({ status: 200, headers: { 'content-type': CT[p.slice(p.lastIndexOf('.'))] || 'application/octet-stream' }, body: readFileSync(f) })
: r.fulfill({ status: 404, body: 'nf' });
});
await page.goto('http://demo.local/demo.html', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__card?._model?.length > 0, { timeout: 9000 });
// hass flows continuously in production; the stub sets it once — nudge a rebuild
await page.evaluate(() => { const c = window.__card; c.hass = { ...c.hass }; });
await page.waitForFunction(() => window.__card._devices.length > 0, { timeout: 9000 });
return { page, browser };
}
+74
View File
@@ -0,0 +1,74 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const mk = () => document.createElement('houseplan-card');
const sr = (c) => c.shadowRoot || c.renderRoot;
// 1) пустая инсталляция: ноль устройств/зон, конфиг с пространством без комнат
const c1 = mk();
c1.setConfig({ type: 'custom:houseplan-card' });
document.body.appendChild(c1);
c1.hass = { language:'en', locale:{language:'en'}, devices:{}, entities:{}, areas:{}, states:{},
callWS: async (m) => m.type==='houseplan/config/get'
? { config:{ spaces:[{ id:'s1', title:'Empty', plan_url:null, aspect:1.4, view_box:[0,0,1,1], rooms:[], segments:[] }], markers:[], settings:{} }, rev:1 }
: { layout:{} },
connection:{ subscribeEvents: async()=>()=>{} } };
await new Promise(r=>setTimeout(r,150));
c1.hass = { ...c1.hass };
await c1.updateComplete; await new Promise(r=>setTimeout(r,50)); await c1.updateComplete;
out.emptyDevices = c1._devices.length;
out.emptyRenders = !!sr(c1).querySelector('.stage');
out.emptyCount = sr(c1).querySelector('.count')?.textContent.trim();
c1.remove();
// 2) XSS в именах комнат и устройств
const c2 = mk();
c2.setConfig({ type: 'custom:houseplan-card' });
document.body.appendChild(c2);
const evil = '<img src=x onerror=window.__pwned=1><b>bold</b>';
c2.hass = { language:'en', locale:{language:'en'},
devices:{ d1:{ id:'d1', name: evil, model:'M<script>1</script>', area_id:'a1', identifiers:[['x','1']] } },
entities:{}, areas:{ a1:{ area_id:'a1', name:'A1' } }, states:{},
callWS: async (m) => m.type==='houseplan/config/get'
? { config:{ spaces:[{ id:'s1', title:'S', plan_url:null, aspect:1, view_box:[0,0,1,1],
rooms:[{ id:'r1', name: evil, area:'a1', poly:[[0.1,0.1],[0.9,0.1],[0.9,0.9],[0.1,0.9]] }], segments:[] }], markers:[], settings:{} }, rev:1 }
: { layout:{} },
connection:{ subscribeEvents: async()=>()=>{} } };
await new Promise(r=>setTimeout(r,150));
c2.hass = { ...c2.hass };
await c2.updateComplete; await new Promise(r=>setTimeout(r,50)); await c2.updateComplete;
out.xssPwned = !!window.__pwned;
const lbl = sr(c2).querySelector('.roomlabel');
out.xssLabelIsText = lbl ? lbl.innerHTML.includes('&lt;img') || lbl.textContent.includes('<img') : 'no label';
out.xssDeviceRendered = sr(c2).querySelectorAll('.dev').length;
c2.remove();
// 3) легаси-записи layout v1 {x,y} без ключа s — игнорируются в norm-режиме
const c = window.__card;
c._layout = { ...c._layout, d_light1_legacy_test: { x: 500, y: 300 } };
const fake = { id: 'd_light1_legacy_test', space: 'f1' };
const p = c._pos(fake);
out.legacyIgnored = p.y !== 300; // centre y=400 if ignored; raw 300 would mean it leaked through
delete c._layout.d_light1_legacy_test;
// 4) перф: 150 устройств
const t0 = performance.now();
const devices = {}; const entities = {}; const states = {};
for (let i = 0; i < 150; i++) {
devices['p'+i] = { id:'p'+i, name:'Plug '+i, model:'Smart Plug', area_id:'living_room', identifiers:[['demo','p'+i]] };
entities['switch.p'+i] = { entity_id:'switch.p'+i, device_id:'p'+i, platform:'demo' };
states['switch.p'+i] = { state: i%2?'on':'off', attributes:{ linkquality: 50+i } };
}
const bigHass = { ...window.__mkHass(), devices:{...window.__mkHass().devices, ...devices},
entities:{...window.__mkHass().entities, ...entities}, states:{...window.__mkHass().states, ...states} };
c.hass = bigHass; c._regSignature=''; c._maybeRebuildDevices();
out.bigBuildMs = Math.round(performance.now() - t0);
out.bigCount = c._devices.length;
await c.updateComplete;
const t1 = performance.now(); c.requestUpdate(); await c.updateComplete;
out.bigRenderMs = Math.round(performance.now() - t1);
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+50
View File
@@ -0,0 +1,50 @@
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;
// 1) дефолт (план есть): границ и лейблов нет
out.defaultStyled = sr().querySelectorAll('.room.styled').length;
out.defaultLabels = sr().querySelectorAll('.roomlabel').length;
// 2) включаем границы+имена+заливку по свету
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : {
...s, settings: { show_borders: true, show_names: true, room_color: '#ff8800', room_opacity: 0.8, fill_mode: 'light' },
})};
c.requestUpdate(); await c.updateComplete;
out.styled = sr().querySelectorAll('.room.styled').length;
out.labels = [...sr().querySelectorAll('.roomlabel')].map((l) => l.textContent.trim());
const liv = [...sr().querySelectorAll('.room.styled')][0];
out.livingStyle = liv.getAttribute('style');
// living: ceiling on → жёлтая; kitchen: нет light-сущностей → без заливки; bedroom light off → серая
const styles = [...sr().querySelectorAll('.room.styled')].map((r) => r.getAttribute('style'));
out.hasYellow = styles.some((s) => s.includes('#ffd45c'));
out.hasGrey = styles.some((s) => s.includes('#9aa0a6'));
out.kitchenNoFill = styles.some((s) => s.includes('--room-fill:transparent'));
// 3) lqi-заливка
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : {
...s, settings: { ...s.settings, fill_mode: 'lqi' },
})};
c.requestUpdate(); await c.updateComplete;
out.lqiFills = [...sr().querySelectorAll('.room.styled')].filter((r) => (r.getAttribute('style') || '').includes('hsl(')).length;
// 4) drag лейбла → layout rl_
const lbl = sr().querySelector('.roomlabel');
c._labelDown({ preventDefault(){}, stopPropagation(){}, clientX: 100, clientY: 100, target: { setPointerCapture(){} }, pointerId: 5 },
c._spaceModel().rooms[0], 'f1');
c._labelMove({ clientX: 160, clientY: 140 }, c._spaceModel().rooms[0], 'f1');
c._labelUp(c._spaceModel().rooms[0]);
out.labelSaved = !!c._layout['rl_r1'];
// 5) диалог: create + draw
c._openSpaceDialog('create'); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'Attic', source: 'draw', orientation: 'square' };
await c.updateComplete;
out.saveEnabled = !sr().querySelector('.dialog .btn.on[disabled]');
await c._saveSpaceDialog(); await c.updateComplete;
const attic = c._serverCfg.spaces.find((s) => s.title === 'Attic');
out.atticAspect = attic?.aspect;
out.atticSettings = attic?.settings;
out.atticNoPlan = attic ? attic.plan_url === null : null;
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 800">
<rect width="1000" height="800" fill="#f4f1ea"/>
<g opacity="0.35" stroke="#d9d2c4" stroke-width="1"><line x1="0" y1="0" x2="0" y2="800"/><line x1="50" y1="0" x2="50" y2="800"/><line x1="100" y1="0" x2="100" y2="800"/><line x1="150" y1="0" x2="150" y2="800"/><line x1="200" y1="0" x2="200" y2="800"/><line x1="250" y1="0" x2="250" y2="800"/><line x1="300" y1="0" x2="300" y2="800"/><line x1="350" y1="0" x2="350" y2="800"/><line x1="400" y1="0" x2="400" y2="800"/><line x1="450" y1="0" x2="450" y2="800"/><line x1="500" y1="0" x2="500" y2="800"/><line x1="550" y1="0" x2="550" y2="800"/><line x1="600" y1="0" x2="600" y2="800"/><line x1="650" y1="0" x2="650" y2="800"/><line x1="700" y1="0" x2="700" y2="800"/><line x1="750" y1="0" x2="750" y2="800"/><line x1="800" y1="0" x2="800" y2="800"/><line x1="850" y1="0" x2="850" y2="800"/><line x1="900" y1="0" x2="900" y2="800"/><line x1="950" y1="0" x2="950" y2="800"/><line x1="0" y1="0" x2="1000" y2="0"/><line x1="0" y1="50" x2="1000" y2="50"/><line x1="0" y1="100" x2="1000" y2="100"/><line x1="0" y1="150" x2="1000" y2="150"/><line x1="0" y1="200" x2="1000" y2="200"/><line x1="0" y1="250" x2="1000" y2="250"/><line x1="0" y1="300" x2="1000" y2="300"/><line x1="0" y1="350" x2="1000" y2="350"/><line x1="0" y1="400" x2="1000" y2="400"/><line x1="0" y1="450" x2="1000" y2="450"/><line x1="0" y1="500" x2="1000" y2="500"/><line x1="0" y1="550" x2="1000" y2="550"/><line x1="0" y1="600" x2="1000" y2="600"/><line x1="0" y1="650" x2="1000" y2="650"/><line x1="0" y1="700" x2="1000" y2="700"/><line x1="0" y1="750" x2="1000" y2="750"/></g>
<rect x="40" y="40" width="920" height="720" fill="none" stroke="#3b4753" stroke-width="14"/>
<line x1="550" y1="40" x2="550" y2="300" stroke="#3b4753" stroke-width="10"/>
<line x1="550" y1="380" x2="550" y2="620" stroke="#3b4753" stroke-width="10"/>
<line x1="550" y1="700" x2="550" y2="760" stroke="#3b4753" stroke-width="10"/>
<line x1="550" y1="360" x2="820" y2="360" stroke="#3b4753" stroke-width="10"/>
<line x1="900" y1="360" x2="960" y2="360" stroke="#3b4753" stroke-width="10"/>
<line x1="40" y1="480" x2="300" y2="480" stroke="#3b4753" stroke-width="10"/>
<line x1="380" y1="480" x2="550" y2="480" stroke="#3b4753" stroke-width="10"/>
<rect x="120" y="46" width="180" height="10" fill="#a8c6e8"/>
<rect x="640" y="46" width="140" height="10" fill="#a8c6e8"/>
<rect x="700" y="744" width="140" height="10" fill="#a8c6e8"/>
<text x="295" y="270" font-family="Arial" font-size="30" letter-spacing="4" fill="#8b95a1" text-anchor="middle" opacity="0.55">LIVING ROOM</text>
<text x="775" y="200" font-family="Arial" font-size="30" letter-spacing="4" fill="#8b95a1" text-anchor="middle" opacity="0.55">KITCHEN</text>
<text x="775" y="560" font-family="Arial" font-size="30" letter-spacing="4" fill="#8b95a1" text-anchor="middle" opacity="0.55">BEDROOM</text>
<text x="295" y="640" font-family="Arial" font-size="30" letter-spacing="4" fill="#8b95a1" text-anchor="middle" opacity="0.55">HALLWAY</text>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 700">
<rect width="1000" height="700" fill="#eef3e6"/>
<rect x="30" y="30" width="940" height="640" fill="none" stroke="#7c9c6b" stroke-width="10" stroke-dasharray="24 14"/>
<circle cx="260" cy="240" r="90" fill="#dbe8cd" stroke="#9dba8a" stroke-width="4"/>
<circle cx="700" cy="450" r="130" fill="#d3e4f2" stroke="#8fb6d8" stroke-width="4"/>
<rect x="600" y="80" width="260" height="150" fill="#e7ddc8" stroke="#b8a888" stroke-width="5"/>
<text x="730" y="165" font-family="Arial" font-size="26" letter-spacing="3" fill="#8b95a1" text-anchor="middle" opacity="0.6">GARAGE</text>
<text x="260" y="248" font-family="Arial" font-size="22" letter-spacing="3" fill="#7d9169" text-anchor="middle" opacity="0.7">LAWN</text>
<text x="700" y="458" font-family="Arial" font-size="22" letter-spacing="3" fill="#6f93ad" text-anchor="middle" opacity="0.7">POOL</text>
</svg>

After

Width:  |  Height:  |  Size: 920 B

+1285
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+144
View File
@@ -0,0 +1,144 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>House Plan demo</title>
<style>
:root{
--primary-color:#3ea6ff; --primary-text-color:#e1e1e1; --secondary-text-color:#9aa4ad;
--card-background-color:#1c2530; --ha-card-background:#1c2530;
--divider-color:#33404d; --text-primary-color:#fff; --mdc-icon-size:24px;
--header-height:0px;
}
html,body{margin:0;background:#0e1621;color:#e1e1e1;font-family:'Segoe UI',Roboto,Arial,sans-serif;}
#host{width:780px;margin:0 auto;padding:8px;}
</style>
</head>
<body>
<div id="host"></div>
<script src="/assets/icons.js"></script>
<script>
class HaIcon extends HTMLElement{
static get observedAttributes(){return ['icon'];}
connectedCallback(){this._r();}
attributeChangedCallback(){this._r();}
set icon(v){this.setAttribute('icon',v);}
get icon(){return this.getAttribute('icon');}
_r(){
const p=(window.__ICONS||{})[this.getAttribute('icon')]||'';
if(!this.shadowRoot) this.attachShadow({mode:'open'});
this.shadowRoot.innerHTML='<style>:host{display:inline-flex;align-items:center;justify-content:center;'+
'width:var(--mdc-icon-size,24px);height:var(--mdc-icon-size,24px);vertical-align:middle}'+
'svg{width:100%;height:100%;fill:currentColor;display:block}</style>'+
'<svg viewBox="0 0 24 24"><path d="'+p+'"></path></svg>';
}
}
customElements.define('ha-icon',HaIcon);
class HaCard extends HTMLElement{
connectedCallback(){
if(this.shadowRoot)return;
this.attachShadow({mode:'open'});
this.shadowRoot.innerHTML='<style>:host{display:block;background:var(--ha-card-background,#1c2530);'+
'border-radius:12px;box-shadow:0 2px 8px rgba(0,0,0,.4);overflow:visible;color:var(--primary-text-color)}</style><slot></slot>';
}
}
customElements.define('ha-card',HaCard);
</script>
<script type="module">
const CFG = {
spaces: [
{ id:'f1', title:'Ground floor', plan_url:'/assets/f1.svg', aspect:1.25,
view_box:[0,0,1,1],
rooms:[
{id:'r1', name:'Living room', area:'living_room', poly:[[0.04,0.05],[0.55,0.05],[0.55,0.6],[0.04,0.6]]},
{id:'r2', name:'Kitchen', area:'kitchen', poly:[[0.55,0.05],[0.96,0.05],[0.96,0.45],[0.55,0.45]]},
{id:'r3', name:'Bedroom', area:'bedroom', poly:[[0.55,0.45],[0.96,0.45],[0.96,0.95],[0.55,0.95]]},
{id:'r4', name:'Hallway', area:'hallway', poly:[[0.04,0.6],[0.55,0.6],[0.55,0.95],[0.04,0.95]]}
], segments:[] },
{ id:'garden', title:'Garden', plan_url:'/assets/garden.svg', aspect:1.4286,
view_box:[0,0,1,1],
rooms:[ {id:'g1', name:'Garden', area:'garden', poly:[[0.03,0.04],[0.97,0.04],[0.97,0.96],[0.03,0.96]]} ], segments:[] }
],
markers: [],
settings: {}
};
const LAYOUT = {
d_light1:{s:'f1',x:0.22,y:0.22}, d_lamp:{s:'f1',x:0.42,y:0.5}, d_tv:{s:'f1',x:0.13,y:0.5},
d_temp:{s:'f1',x:0.33,y:0.35}, d_kettle:{s:'f1',x:0.72,y:0.15}, d_leak:{s:'f1',x:0.88,y:0.34},
d_bedlight:{s:'f1',x:0.75,y:0.6}, d_window:{s:'f1',x:0.9,y:0.85}, d_lock:{s:'f1',x:0.1,y:0.88},
d_motion:{s:'f1',x:0.4,y:0.75}, d_mower:{s:'garden',x:0.25,y:0.35}, d_gate:{s:'garden',x:0.73,y:0.22}
};
const AREAS={living_room:{area_id:'living_room',name:'Living room'},kitchen:{area_id:'kitchen',name:'Kitchen'},
bedroom:{area_id:'bedroom',name:'Bedroom'},hallway:{area_id:'hallway',name:'Hallway'},garden:{area_id:'garden',name:'Garden'}};
function dev(id,name,model,area){return [id,{id,name,model,area_id:area,identifiers:[['demo',id]],entry_type:null,via_device_id:null}];}
const DEVICES=Object.fromEntries([
dev('d_light1','Ceiling light','Smart Bulb E27','living_room'),
dev('d_lamp','Floor lamp','Smart Bulb E14','living_room'),
dev('d_tv','TV','55U8HQ','living_room'),
dev('d_temp','Temperature sensor','TH01','living_room'),
dev('d_kettle','Kettle plug','Smart Plug','kitchen'),
dev('d_leak','Sink leak sensor','SJCGQ11LM','kitchen'),
dev('d_bedlight','Bedroom light','Smart Bulb E27','bedroom'),
dev('d_window','Window sensor','MCCGQ11LM','bedroom'),
dev('d_lock','Front door lock','Smart Lock S2','hallway'),
dev('d_motion','Motion sensor','RTCGQ11LM','hallway'),
dev('d_mower','Robot mower','Automower 305','garden'),
dev('d_gate','Gate','GDO-4','garden'),
]);
const ENTITIES={
'light.ceiling':{entity_id:'light.ceiling',device_id:'d_light1',platform:'demo'},
'light.floor_lamp':{entity_id:'light.floor_lamp',device_id:'d_lamp',platform:'demo'},
'media_player.tv':{entity_id:'media_player.tv',device_id:'d_tv',platform:'demo'},
'sensor.living_temp':{entity_id:'sensor.living_temp',device_id:'d_temp',platform:'demo'},
'switch.kettle':{entity_id:'switch.kettle',device_id:'d_kettle',platform:'demo'},
'binary_sensor.sink_leak':{entity_id:'binary_sensor.sink_leak',device_id:'d_leak',platform:'demo'},
'light.bedroom':{entity_id:'light.bedroom',device_id:'d_bedlight',platform:'demo'},
'binary_sensor.window':{entity_id:'binary_sensor.window',device_id:'d_window',platform:'demo'},
'lock.front_door':{entity_id:'lock.front_door',device_id:'d_lock',platform:'demo'},
'binary_sensor.hall_motion':{entity_id:'binary_sensor.hall_motion',device_id:'d_motion',platform:'demo'},
'vacuum.mower':{entity_id:'vacuum.mower',device_id:'d_mower',platform:'demo'},
'cover.gate':{entity_id:'cover.gate',device_id:'d_gate',platform:'demo'},
};
let STATES={
'light.ceiling':{entity_id:'light.ceiling',state:'on',attributes:{friendly_name:'Ceiling light'}},
'light.floor_lamp':{entity_id:'light.floor_lamp',state:'off',attributes:{friendly_name:'Floor lamp',linkquality:196}},
'media_player.tv':{entity_id:'media_player.tv',state:'playing',attributes:{friendly_name:'TV'}},
'sensor.living_temp':{entity_id:'sensor.living_temp',state:'22.4',attributes:{friendly_name:'Temperature',device_class:'temperature',unit_of_measurement:'°C',linkquality:154}},
'switch.kettle':{entity_id:'switch.kettle',state:'off',attributes:{friendly_name:'Kettle plug',linkquality:182}},
'binary_sensor.sink_leak':{entity_id:'binary_sensor.sink_leak',state:'off',attributes:{friendly_name:'Leak',device_class:'moisture',linkquality:117}},
'light.bedroom':{entity_id:'light.bedroom',state:'off',attributes:{friendly_name:'Bedroom light'}},
'binary_sensor.window':{entity_id:'binary_sensor.window',state:'on',attributes:{friendly_name:'Window',device_class:'window',linkquality:88}},
'lock.front_door':{entity_id:'lock.front_door',state:'locked',attributes:{friendly_name:'Front door',linkquality:143}},
'binary_sensor.hall_motion':{entity_id:'binary_sensor.hall_motion',state:'on',attributes:{friendly_name:'Motion',device_class:'motion',linkquality:64}},
'vacuum.mower':{entity_id:'vacuum.mower',state:'cleaning',attributes:{friendly_name:'Mower'}},
'cover.gate':{entity_id:'cover.gate',state:'closed',attributes:{friendly_name:'Gate'}},
};
function mkHass(){
return {
language:'en', locale:{language:'en'},
devices:DEVICES, entities:ENTITIES, areas:AREAS, states:STATES,
floors:{g:{floor_id:'g',name:'Ground floor',level:0},u:{floor_id:'u',name:'Upstairs',level:1}},
callWS:async (m)=>{
if(m.type==='houseplan/config/get')return{config:CFG,rev:1};
if(m.type==='houseplan/layout/get')return{layout:LAYOUT};
return {ok:true,rev:2};
},
callService:async (d,s,data)=>{
const eid=data.entity_id, st=STATES[eid];
if(st){const on=['on','playing','unlocked','open'].includes(st.state);
STATES={...STATES,[eid]:{...st,state:on?'off':'on'}};
card.hass=mkHass();}
},
connection:{subscribeEvents:async()=>()=>{}},
formatEntityState:(st)=>st.state,
};
}
await import('/assets/houseplan-card.js');
const card=document.createElement('houseplan-card');
card.setConfig({type:'custom:houseplan-card', title:'House Plan', icon_size:3.4});
document.getElementById('host').appendChild(card);
card.hass=mkHass();
window.__card=card;
window.__mkHass=mkHass;
</script>
</body></html>
+207 -101
View File
File diff suppressed because one or more lines are too long
+23
View File
@@ -1,5 +1,28 @@
# Changelog
## 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
fill mode — none / by zigbee signal (red→green) / by lights (yellow = something
is on, grey = all lights off; rooms without lights stay unfilled).
- **Room name labels are draggable** like device icons; positions persist server-side
(layout keys `rl_<roomId>`), defaults to the room centre; hidden in markup mode.
- **Hand-drawn spaces**: the space dialog got a "No image — I'll outline rooms by
hand" option with a canvas orientation choice (landscape/portrait/square). Such
spaces default to visible borders and names; switching an existing space to this
mode detaches its background image. The plan image is no longer mandatory.
- Backend: explicit validation schema for the new per-space settings (+test).
- **`demo/` harness moved into the repository** (synthetic home, host page, capture
and smoke scripts, icon-map generator) — public materials and smoke tests no
longer depend on a perishable sandbox.
- **`docs/TESTING.md`**: a comprehensive manual-testing checklist (environments
matrix, every feature, edge cases); policy — updated in the same commit as any
functional change. The first self-run found and fixed two bugs:
`plan_url` not detached on image→draw switch, and a `_stateClass` crash on
state objects without `entity_id`.
- Tests: 43 frontend + 11 pure backend; new smokes `smoke_space_settings` and
`smoke_edge_cases` (empty install, XSS names, legacy layout entries, 150-device perf).
## v1.13.3 — 2026-07-06 (privacy: drop legacy real-house plan sources)
- Removed the legacy `assets/` directory (real floor-plan sources from the pre-v1.3
bundled-data era). Nothing in the build referenced it; instance data lives in
+5 -1
View File
@@ -13,7 +13,7 @@
| Item | State |
|---|---|
| Version | **v1.13.3** everywhere (manifest, const.py, package.json, CARD_VERSION) |
| Version | **v1.14.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 |
| 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 |
@@ -43,6 +43,10 @@
- **v1.13.2** — audit round 3: buildDevices unit-test suite, multi-placeholder t(),
conflict resync in _saveConfigNow, pointercancel long-press fix, repairs re-check
on config save (repairs.py).
- **v1.13.3** — privacy: legacy real-house assets/ removed; README screenshots synthetic.
- **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!).
## Where things live
+161
View File
@@ -0,0 +1,161 @@
# Manual testing checklist
> **Policy:** this checklist is updated **in the same commit** as any functional
> change (like CHANGELOG.md). Every release: run at least the smoke column on the
> synthetic demo (`demo/`), and the full list before major releases. Items marked
> `[auto]` are covered by unit tests or the headless smokes in `demo/` — they still
> deserve an occasional eyeball. File every failure as a GitHub issue before fixing.
## Environments matrix
Run the *core flows* (marked ★ below) in each environment at least once per minor release:
- [ ] Chrome / Edge (desktop, Windows or Linux)
- [ ] Firefox (desktop) — SVG viewBox math and container queries differ historically
- [ ] Safari (macOS) — pointer events / pinch behavior
- [ ] HA Companion app, Android (cold start! the v1.7.2 race lived here)
- [ ] HA Companion app, iOS
- [ ] Tablet in kiosk/panel mode (wall-tablet scenario, landscape)
- [ ] Phone portrait, narrow ≤400 px (adaptive header ≤620 px)
- [ ] Dark theme and light theme (badges, dialogs, plan contrast)
- [ ] RU profile locale and EN profile locale (+ `language:` card option forcing each)
## Installation / upgrade / removal
- [ ] Fresh install via HACS custom repository → integration appears, card auto-registers as a Lovelace resource (`?v=` matches manifest); no manual resource setup
- [ ] `single_config_entry`: adding a second entry is impossible [auto]
- [ ] Upgrade via HACS: `?v=` bumps after HA restart, browser picks the new bundle without cache clearing
- [ ] YAML-mode Lovelace: falls back to `extra_module_url` (card loads)
- [ ] Removal: delete entry → Lovelace resource entry disappears; `.storage/houseplan.*` survives; reinstall picks the old config up
- [ ] Diagnostics download works; personal fields (name/link/description/pdfs) are `**REDACTED**` [auto]
## Onboarding ★
- [ ] Empty config, HA has floors → floors-import wizard offers them sorted by level [auto]
- [ ] Wizard: uncheck all → "Create" disabled; "Start from scratch" → classic dialog
- [ ] Wizard: N floors → space dialog per floor with prefilled name and progress "i of N"; Skip skips one; Cancel aborts the whole queue [auto]
- [ ] After the last wizard space (or first manual space) → markup mode auto-opens with a toast
- [ ] Empty config, no floors → classic "New space" dialog auto-opens once per session
- [ ] All floors skipped, nothing created → empty state with "Add space" button remains usable
## Spaces ★
- [ ] Create with an image (SVG, PNG, JPG, WebP) → correct aspect, crisp at zoom (SVG)
- [ ] Oversized plan (>8 MB) → readable error toast, dialog stays open
- [ ] Create with "No image — I'll outline rooms by hand": orientation landscape/portrait/square respected [auto]; borders+names default ON [auto]
- [ ] Draw-space renders an empty canvas (no black hole), markup works on it
- [ ] Edit: rename; replace image; **switch image→draw detaches the plan** [auto]
- [ ] Delete space with rooms/devices → tab disappears, layout of other spaces untouched
- [ ] 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
- [ ] Room hover highlight still works when custom borders/fills are on
- [ ] Settings persist across reload and other browsers (server-side)
## Room markup editor ★
- [ ] Grid appears; dots snap; segments draw pair-by-pair; shared walls reused
- [ ] Esc / Ctrl+Z removes the last dot (and its line); Reset clears the path
- [ ] Closing the contour (click the first dot, ≥4 points) opens the room dialog
- [ ] Room dialog: area list shows only unassigned areas; picking an area prefills the name
- [ ] "No area" room (decorative) requires a name; saves with `area: null`
- [ ] Cancel in the dialog reopens the contour (last point undone)
- [ ] Saving a room with an area: area devices appear with icons; positions are fixed into the layout [auto]
- [ ] Erase tool removes exactly the clicked line; Delete-room removes the polygon after confirm
- [ ] Device icons hidden during markup; visible again on exit
## Devices on the plan ★
- [ ] Auto devices appear only in rooms bound to their area [auto]
- [ ] Curation hides bridges/groups/scenes/excluded integrations; 👁 "show all" reveals [auto]
- [ ] Duplicate "name|area" numbered ("Lamp", "Lamp 2") [auto]
- [ ] Light groups fold their single lamps; `group_lights=false` unfolds [auto]
- [ ] Drag anywhere (no edit mode), snaps to grid, persists after reload, per space
- [ ] ↺ reset restores auto layout after confirm
- [ ] Temperature badge on thermometers; LQI value under zigbee icons with red→green color
- [ ] Live states: light on = yellow, open cover/lock/door = orange, unavailable = faded
- [ ] No devices at all in HA (fresh instance) → plan renders, "0 dev.", no console errors [auto]
## Device dialog (markers) ★
- [ ] Open via info card → Edit; all fields persist (name, icon, model, link, description)
- [ ] Rebind to another device/entity/helper: search filters; already-placed candidates excluded; old position cleaned up [auto backend]
- [ ] Virtual device: requires name; room required; renders dashed
- [ ] Room override moves the icon to the room center
- [ ] Tap-action override select (default/info/more-info/toggle) saves and applies
- [ ] PDF/manual upload: ok path; >25 MB → readable error; .exe → bad-ext error [auto backend]; traversal names sanitized [auto backend]
- [ ] `javascript:` in the link field is not rendered as a clickable link [auto]
- [ ] Remove: auto device → hidden marker (reappears via dialog "show all"? no — stays hidden until re-added); virtual → gone incl. its layout entry [auto backend]
## Icon rules ★
- [ ] ⬡ opens the editor with current rules (defaults if none saved)
- [ ] Test field resolves live; add/delete/reorder rows; first match wins [auto]
- [ ] Invalid regex highlights red and is skipped at runtime (other rules still work) [auto]
- [ ] Reset to defaults; saving defaults stores nothing (settings key removed) [auto]
- [ ] Custom rules re-icon existing devices immediately; per-device icon override still wins; lock devices keep mdi:lock [auto]
- [ ] Rules survive reload; second browser sees them after live-sync
## Tap actions & gestures ★
- [ ] Default: tap → info card; card option `toggle`: tap toggles lights/switches/fans/humidifiers only [auto]
- [ ] Locks/alarms never toggle, even with per-device override [auto]; covers/valves toggle only with explicit per-device override [auto]
- [ ] Long-press (600 ms) always opens the info card, also when tap=toggle [auto]
- [ ] Drag > 3 px cancels both tap and long-press; pinch/pan never triggers taps
- [ ] `pointercancel` (touch interrupted) does not leave a phantom info card [auto]
## Zoom / pan / labels
- [ ] Wheel zoom at cursor; +/ buttons; fit button resets; badge shows %
- [ ] Pinch zoom + two-finger pan on touch; one-finger pan when zoomed
- [ ] Zoom level persists per space (localStorage), restored on reload
- [ ] Window resize / sidebar collapse refits without distortion
- [ ] Room name labels: default at room center; dragging moves and persists (server layout, `rl_*`) [auto]; hidden in markup mode
- [ ] Labels legible on light plans (text shadow) at min/max zoom
## Multi-client & concurrency ★
- [ ] Two browser tabs: drag in A → position appears in B without reload (live event)
- [ ] Config edit collision: stale tab saving gets a conflict toast, auto-resyncs, retry works [auto backend]
- [ ] Point layout updates from two windows don't overwrite each other's icons [auto backend]
- [ ] `admin_only` ON: non-admin user gets readable "administrators only" errors on every write path
## Edge cases
- [ ] HA instance with zero devices/areas → onboarding works, rooms can be drawn, no crashes [auto]
- [ ] Space with zero rooms → renders; markup hint visible
- [ ] Room without area + borders ON → drawn, click does nothing, no area tooltip signal
- [ ] No zigbee devices anywhere → no LQI badges, lqi fill leaves all rooms unfilled [auto]
- [ ] 100+ devices in one space → build under ~50 ms [auto], drag stays smooth
- [ ] Very long device/room names → ellipsis/wrap, no layout explosion
- [ ] HTML/emoji in names (`<b>xss</b>`, 🚿) → rendered as text, never as markup [auto]
- [ ] Plan file deleted from disk → Repairs issue appears after config save/restart; re-upload clears it [auto backend]
- [ ] Corrupted `.storage/houseplan.config` → entry retries (ConfigEntryNotReady), no crash loop [auto backend]
- [ ] HA restart while a dialog is open → next save gets a clean error/conflict, no data loss
- [ ] Legacy layout entries (v1 {x,y} without space) are ignored gracefully
- [ ] Kiosk cold start on mobile app: card defined before dashboard render (resource registration)
## Release regression quickies
- [ ] Browser console has zero errors from houseplan-card.js on: dashboard load, markup, dialogs, zoom
- [ ] HA log has zero houseplan errors/warnings after restart
- [ ] `npm test` (frontend), `pytest tests_backend` (pure), CI HA-harness — all green
- [ ] README screenshots/GIF still match the current UI (synthetic home only)
---
## Last self-run
**v1.14.0 (2026-07-06), headless demo harness + unit suites.** All `[auto]` items pass
(43 frontend tests, 11 pure + 12 HA-harness backend tests, `smoke_space_settings`,
tap/hold/wizard/rules smokes). Bugs found during the run, fixed in the same release:
1. Edit dialog: switching an existing space from image to "draw" kept the old
background (`plan_url` not detached) — fixed.
2. `_stateClass` crashed on state objects without `entity_id` (domain is now
derived from `d.primary`, which the state was looked up by) — fixed; found by
the 150-device perf item of this checklist.
3. Perf item measured: 162 devices build in ~14 ms, re-render ~1 ms — well within budget.
4. (earlier rounds) long-press phantom after `pointercancel`; `_saveConfigNow`
conflict without resync — fixed in v1.13.2.
Unchecked boxes above (real browsers/devices, multi-tab live sync, Companion apps)
require hands on real hardware — they remain for the human pass.
+58 -2
View File
@@ -1,21 +1,23 @@
{
"name": "houseplan-card",
"version": "1.12.0",
"version": "1.13.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.12.0",
"version": "1.13.3",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3"
},
"devDependencies": {
"@mdi/js": "^7.4.47",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^11.1.6",
"playwright": "^1.61.1",
"rollup": "^4.18.0",
"tslib": "^2.6.2",
"typescript": "^5.4.5"
@@ -86,6 +88,13 @@
"@lit-labs/ssr-dom-shim": "^1.5.0"
}
},
"node_modules/@mdi/js": {
"version": "7.4.47",
"resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz",
"integrity": "sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@rollup/plugin-json": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz",
@@ -741,6 +750,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.13.3",
"version": "1.14.0",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
@@ -11,10 +11,12 @@
"test": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/*.test.mjs"
},
"devDependencies": {
"@mdi/js": "^7.4.47",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^11.1.6",
"playwright": "^1.61.1",
"rollup": "^4.18.0",
"tslib": "^2.6.2",
"typescript": "^5.4.5"
+17
View File
@@ -292,3 +292,20 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
}
return rest;
}
/**
* Light situation of an area: 'on' if any light entity of the area's devices is on,
* 'off' if lights exist but none is on, 'none' when the area has no lights at all.
*/
export function areaLights(hass: any, devices: { area: string; entities: string[] }[], area: string): 'on' | 'off' | 'none' {
let seen = false;
for (const d of devices) {
if (d.area !== area) continue;
for (const eid of d.entities) {
if (!eid.startsWith('light.')) continue;
seen = true;
if (hass.states[eid]?.state === 'on') return 'on';
}
}
return seen ? 'off' : 'none';
}
+197 -17
View File
@@ -14,8 +14,9 @@ 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,
} from './logic';
import { buildDevices, lqiFor, tempFor } from './devices';
import { buildDevices, lqiFor, tempFor, areaLights } from './devices';
import type {
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
} from './types';
@@ -23,7 +24,7 @@ import './editor';
import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.13.3';
const CARD_VERSION = '1.14.0';
const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -120,6 +121,13 @@ class HouseplanCard extends LitElement {
title: string;
planUrl: string | null;
planFile: { ext: string; b64: string; aspect: number; name: string } | null;
source: 'file' | 'draw'; // draw = no background image, hand-drawn rooms
orientation: 'landscape' | 'portrait' | 'square';
showBorders: boolean;
showNames: boolean;
roomColor: string;
roomOpacity: number; // 0..1
fillMode: 'none' | 'lqi' | 'light';
busy: boolean;
} | null = null;
private _keyHandler = (e: KeyboardEvent) => this._onKey(e);
@@ -583,7 +591,10 @@ class HouseplanCard extends LitElement {
const p = d.primary ? this.hass.states[d.primary] : undefined;
if (!p) return '';
if (p.state === 'unavailable') return 'unavail';
const dom = p.entity_id.split('.')[0];
// derive the domain from the entity id we looked the state up by — state
// objects are not guaranteed to carry entity_id (defensive; found by the
// TESTING.md edge-case run)
const dom = d.primary!.split('.')[0];
if (['light', 'switch', 'fan', 'humidifier'].includes(dom)) return p.state === 'on' ? 'on' : '';
if (dom === 'cover' || dom === 'valve') return ['open', 'opening'].includes(p.state) ? 'open' : '';
if (dom === 'lock') return ['unlocked', 'open'].includes(p.state) ? 'open' : '';
@@ -1441,9 +1452,22 @@ class HouseplanCard extends LitElement {
if (mode === 'edit') {
const sp = this._serverCfg!.spaces.find((x: any) => x.id === spaceId);
if (!sp) return;
this._spaceDialog = { mode, spaceId, title: sp.title, planUrl: sp.plan_url || null, planFile: null, busy: false };
const disp = spaceDisplayOf(sp);
this._spaceDialog = {
mode, spaceId, title: sp.title, planUrl: sp.plan_url || null, planFile: null,
source: sp.plan_url ? 'file' : 'draw', orientation: 'landscape',
showBorders: disp.showBorders, showNames: disp.showNames,
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill,
busy: false,
};
} else {
this._spaceDialog = { mode, title: '', planUrl: null, planFile: null, busy: false };
this._spaceDialog = {
mode, title: '', planUrl: null, planFile: null,
source: 'file', orientation: 'landscape',
showBorders: false, showNames: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
busy: false,
};
}
}
@@ -1479,7 +1503,7 @@ class HouseplanCard extends LitElement {
private async _saveSpaceDialog(): Promise<void> {
const d = this._spaceDialog;
if (!d || d.busy || !d.title.trim()) return;
if (!d.planFile && !d.planUrl) {
if (d.source === 'file' && !d.planFile && !d.planUrl) {
this._showToast(this._t('toast.plan_required'));
return;
}
@@ -1488,12 +1512,13 @@ class HouseplanCard extends LitElement {
try {
const cfg = this._serverCfg!;
let sp: any;
const drawAspect = d.orientation === 'portrait' ? 0.707 : d.orientation === 'square' ? 1 : 1.414;
if (d.mode === 'create') {
sp = {
id: 's' + Date.now().toString(36),
title: d.title.trim(),
plan_url: null,
aspect: 1.414,
aspect: d.source === 'draw' ? drawAspect : 1.414,
view_box: [0, 0, 1, 1],
rooms: [],
segments: [],
@@ -1503,13 +1528,26 @@ class HouseplanCard extends LitElement {
sp = cfg.spaces.find((x: any) => x.id === d.spaceId);
sp.title = d.title.trim();
}
if (d.planFile) {
if (d.source === 'file' && d.planFile) {
const resp = await this.hass.callWS({
type: 'houseplan/plan/set', space_id: sp.id, ext: d.planFile.ext, data: d.planFile.b64,
});
sp.plan_url = resp.url;
sp.aspect = d.planFile.aspect;
}
// switching an existing space to "draw" detaches its background image
// (the uploaded file stays on disk; only the reference is cleared)
if (d.source === 'draw') sp.plan_url = null;
// per-space display settings; hand-drawn spaces get borders+names on by default
const draw = d.source === 'draw';
sp.settings = {
...(sp.settings || {}),
show_borders: draw && d.mode === 'create' ? true : d.showBorders,
show_names: draw && d.mode === 'create' ? true : d.showNames,
room_color: d.roomColor,
room_opacity: d.roomOpacity,
fill_mode: d.fillMode,
};
await this._saveConfigNow();
this._spaceDialog = null;
if (d.mode === 'create') this._space = sp.id;
@@ -1592,7 +1630,13 @@ class HouseplanCard extends LitElement {
private _openNextImport(): void {
const title = this._importQueue.shift();
if (title === undefined) return;
this._spaceDialog = { mode: 'create', title, planUrl: null, planFile: null, busy: false };
this._spaceDialog = {
mode: 'create', title, planUrl: null, planFile: null,
source: 'file', orientation: 'landscape',
showBorders: false, showNames: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
busy: false,
};
}
/** Skip the current floor of the wizard without creating a space. */
@@ -1773,6 +1817,7 @@ class HouseplanCard extends LitElement {
const space = this._spaceModel();
const vb = space.vb;
const devs = this._devices.filter((d) => d.space === space.id);
const disp = spaceDisplayOf(this._curSpaceCfg);
const cfgSize = this._config.icon_size ?? 2.5;
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
const view = this._viewOr(vb);
@@ -1860,18 +1905,38 @@ class HouseplanCard extends LitElement {
${space.bg
? svg`<image href="${space.bg.href}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />`
: nothing}
${space.rooms.filter((r) => r.area || this._markup).map((r) => {
const cls = 'room ' + (space.bg ? 'overlay' : 'yard') + (this._markup ? ' outlined' : '');
${space.rooms.filter((r) => r.area || this._markup || disp.showBorders).map((r) => {
let cls = 'room ' + (space.bg ? 'overlay' : 'yard') + (this._markup ? ' outlined' : '');
let style = '';
if (!this._markup && (disp.showBorders || disp.fill !== 'none')) {
cls += ' styled';
const st: string[] = [];
if (disp.showBorders) {
st.push(`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.opacity}`);
} else {
st.push('--room-stroke:transparent', '--room-stroke-op:0');
}
const fillC = r.area
? roomFillColor(
disp.fill,
disp.fill === 'lqi' ? this._roomLqi(r.area) : null,
disp.fill === 'light' ? areaLights(this.hass, this._devices, r.area) : 'none',
)
: 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');
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);
const label = !space.bg || this._markup;
const label = (!space.bg && !disp.showNames) || this._markup;
const c = this._roomCenter(r);
const shape = r.poly
? svg`<polygon class="${cls}" points="${r.poly.map((p) => p.join(',')).join(' ')}"
? svg`<polygon class="${cls}" style="${style}" points="${r.poly.map((p) => p.join(',')).join(' ')}"
@click=${() => this._clickRoom(r)} @mousemove=${tip}
@mouseleave=${() => (this._tip = null)}></polygon>`
: svg`<rect class="${cls}"
: svg`<rect class="${cls}" style="${style}"
x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="${Math.min(r.w!, r.h!) * 0.03}"
@click=${() => this._clickRoom(r)} @mousemove=${tip}
@mouseleave=${() => (this._tip = null)}></rect>`;
@@ -1881,6 +1946,9 @@ class HouseplanCard extends LitElement {
</svg>
<div class="devlayer" style="--icon-size:${((iconPct * vb[2]) / view.w).toFixed(3)}cqw">
${devs.map((d) => this._renderDevice(d, view))}
${disp.showNames && !this._markup
? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp))
: nothing}
</div>
</div>
${this._zoom > 1
@@ -1934,6 +2002,69 @@ class HouseplanCard extends LitElement {
</div>`;
}
/** Saved label position (layout key rl_<roomId>) or the room center. */
private _labelPos(r: RoomCfg, spaceId: string): { x: number; y: number } {
const saved = this._layout['rl_' + (r.id || '')];
if (saved && saved.s === spaceId) {
const aspect = this._serverCfg!.spaces.find((x: any) => x.id === spaceId)?.aspect || 1;
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
}
const c = this._roomCenter(r);
return { x: c[0], y: c[1] };
}
/** Room-name labels are dragged exactly like device icons (same layout store). */
private _labelDown(ev: PointerEvent, r: RoomCfg, spaceId: string): void {
if (this._markup) return;
ev.preventDefault();
ev.stopPropagation();
const p = this._labelPos(r, spaceId);
this._drag = { id: 'rl_' + (r.id || ''), sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false };
(ev.target as HTMLElement).setPointerCapture(ev.pointerId);
this._tip = null;
}
private _labelMove(ev: PointerEvent, r: RoomCfg, spaceId: string): void {
const id = 'rl_' + (r.id || '');
if (!this._drag || this._drag.id !== id) return;
const stage = this._stageEl;
if (!stage) return;
const vb = this._spaceModel(spaceId).vb;
const rect = stage.getBoundingClientRect();
const v = this._viewOr(vb);
const dx = ((ev.clientX - this._drag.sx) / rect.width) * v.w;
const dy = ((ev.clientY - this._drag.sy) / rect.height) * v.h;
if (Math.abs(ev.clientX - this._drag.sx) + Math.abs(ev.clientY - this._drag.sy) > 3) this._drag.moved = true;
const m = Math.min(vb[2], vb[3]) * 0.008;
const nx = Math.max(vb[0] + m, Math.min(vb[0] + vb[2] - m, this._drag.ox + dx));
const ny = Math.max(vb[1] + m, Math.min(vb[1] + vb[3] - m, this._drag.oy + dy));
this._savePos({ id, space: spaceId } as DevItem, nx, ny);
}
private _labelUp(r: RoomCfg): void {
const id = 'rl_' + (r.id || '');
if (!this._drag || this._drag.id !== id) return;
const moved = this._drag.moved;
this._drag = moved ? this._drag : null;
if (moved) window.setTimeout(() => (this._drag = null), 0);
}
private _renderRoomLabel(
r: RoomCfg, space: SpaceModel, view: { x: number; y: number; w: number; h: number }, disp: SpaceDisplay,
): TemplateResult | typeof nothing {
if (!r.name) return nothing;
const p = this._labelPos(r, space.id);
const left = ((p.x - view.x) / view.w) * 100;
const top = ((p.y - view.y) / view.h) * 100;
const op = Math.min(1, disp.opacity + 0.25);
return html`<div class="roomlabel" style="left:${left}%;top:${top}%;color:${disp.color};opacity:${op}"
@pointerdown=${(e: PointerEvent) => this._labelDown(e, r, space.id)}
@pointermove=${(e: PointerEvent) => this._labelMove(e, r, space.id)}
@pointerup=${() => this._labelUp(r)}
@pointercancel=${() => this._labelUp(r)}
>${r.name}</div>`;
}
private _roomCenter(r: RoomCfg): number[] {
if (r.poly) {
const n = r.poly.length;
@@ -2174,7 +2305,13 @@ class HouseplanCard extends LitElement {
.value=${d.title}
@input=${(e: Event) => (this._spaceDialog = { ...d, title: (e.target as HTMLInputElement).value })} />
<label>${this._t('space.plan_label')}</label>
<div class="planrow">
<label class="srcrow">
<input type="radio" name="plansrc" .checked=${d.source === 'file'}
@change=${() => (this._spaceDialog = { ...d, source: 'file' })} />
<span>${this._t('space.source_file')}</span>
</label>
${d.source === 'file'
? html`<div class="planrow">
${d.planFile
? html`<span class="planname">${d.planFile.name}</span>`
: d.planUrl
@@ -2185,7 +2322,50 @@ class HouseplanCard extends LitElement {
<input type="file" hidden accept=".svg,.png,.jpg,.jpeg,.webp,image/svg+xml,image/png,image/jpeg,image/webp"
@change=${(e: Event) => this._pickPlanFile(e)} />
</label>
</div>`
: nothing}
<label class="srcrow">
<input type="radio" name="plansrc" .checked=${d.source === 'draw'}
@change=${() => (this._spaceDialog = { ...d, source: 'draw' })} />
<span>${this._t('space.source_draw')}</span>
</label>
${d.source === 'draw' && d.mode === 'create'
? html`<label>${this._t('space.orientation')}</label>
<select class="areasel"
@change=${(e: Event) => (this._spaceDialog = { ...d, orientation: (e.target as HTMLSelectElement).value as any })}>
${[['landscape', 'orient.landscape'], ['portrait', 'orient.portrait'], ['square', 'orient.square']].map(
([v, k]) => html`<option value=${v} ?selected=${d.orientation === v}>${this._t(k as any)}</option>`,
)}
</select>`
: nothing}
<label class="dispsection">${this._t('space.display_section')}</label>
<label class="srcrow">
<input type="checkbox" .checked=${d.showBorders}
@change=${(e: Event) => (this._spaceDialog = { ...d, showBorders: (e.target as HTMLInputElement).checked })} />
<span>${this._t('space.show_borders')}</span>
</label>
<label class="srcrow">
<input type="checkbox" .checked=${d.showNames}
@change=${(e: Event) => (this._spaceDialog = { ...d, showNames: (e.target as HTMLInputElement).checked })} />
<span>${this._t('space.show_names')}</span>
</label>
<label>${this._t('space.room_color')}</label>
<div class="colorrow">
<input type="color" .value=${d.roomColor}
@input=${(e: Event) => (this._spaceDialog = { ...d, roomColor: (e.target as HTMLInputElement).value })} />
<span class="opl">${this._t('space.opacity')}</span>
<input type="range" min="0" max="100" .value=${String(Math.round(d.roomOpacity * 100))}
@input=${(e: Event) => (this._spaceDialog = { ...d, roomOpacity: Number((e.target as HTMLInputElement).value) / 100 })} />
<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>
</div>
<div class="row">
${d.mode === 'edit'
@@ -2199,8 +2379,8 @@ class HouseplanCard extends LitElement {
: nothing}
<button class="btn ghost" @click=${() => { this._spaceDialog = null; this._importQueue = []; this._importTotal = 0; }}>${this._t('btn.cancel')}</button>
<button class="btn on" @click=${this._saveSpaceDialog}
?disabled=${!d.title.trim() || !(d.planFile || d.planUrl) || d.busy}
title=${!(d.planFile || d.planUrl) ? this._t('title.need_plan') : ''}>
?disabled=${!d.title.trim() || (d.source === 'file' && !(d.planFile || d.planUrl)) || d.busy}
title=${d.source === 'file' && !(d.planFile || d.planUrl) ? this._t('title.need_plan') : ''}>
<ha-icon icon="mdi:check"></ha-icon>${d.busy ? '' : this._t('btn.save')}
</button>
</div>
+16 -1
View File
@@ -151,5 +151,20 @@
"import.manual": "Start from scratch",
"import.progress": "Floor {i} of {n}",
"import.done": "Spaces created. Outline the rooms: click grid dots and close the contour.",
"btn.skip": "Skip"
"btn.skip": "Skip",
"space.display_section": "Display",
"space.show_borders": "Always show room borders",
"space.show_names": "Show room names (drag to move)",
"space.room_color": "Border & name color",
"space.opacity": "Opacity",
"space.fill_label": "Room fill",
"fill.none": "None",
"fill.lqi": "Zigbee signal (red → green)",
"fill.light": "Lights (yellow = on, grey = off)",
"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"
}
+16 -1
View File
@@ -151,5 +151,20 @@
"import.manual": "Начать с нуля",
"import.progress": "Этаж {i} из {n}",
"import.done": "Пространства созданы. Обведите комнаты: кликайте по точкам сетки и замкните контур.",
"btn.skip": "Пропустить"
"btn.skip": "Пропустить",
"space.display_section": "Отображение",
"space.show_borders": "Всегда отображать границы комнат",
"space.show_names": "Отображать названия комнат (перетаскиваются)",
"space.room_color": "Цвет границ и названий",
"space.opacity": "Прозрачность",
"space.fill_label": "Заливка комнат",
"fill.none": "Нет",
"fill.lqi": "По силе зигби-сигнала (красный → зелёный)",
"fill.light": "По освещению (жёлтая = включено, серая = выключено)",
"space.source_file": "У меня есть картинка плана",
"space.source_draw": "Нет подложки — нарисую комнаты вручную",
"space.orientation": "Холст",
"orient.landscape": "Альбомный",
"orient.portrait": "Портретный",
"orient.square": "Квадрат"
}
+49
View File
@@ -180,3 +180,52 @@ export function subst(s: string, vars?: Record<string, string | number>): string
for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v));
return out;
}
// ---------------- room fills & colors ----------------
export type RoomFillMode = 'none' | 'lqi' | 'light';
/** Per-space display settings with their defaults resolved. */
export interface SpaceDisplay {
showBorders: boolean;
showNames: boolean;
color: string; // hex like #3ea6ff
opacity: number; // 0..1 — applied to borders, names and fills
fill: RoomFillMode;
}
export const DEFAULT_ROOM_COLOR = '#3ea6ff';
export const DEFAULT_ROOM_OPACITY = 0.55;
/** Resolve a space's display settings; spaces without a plan default to visible markup. */
export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
const s = spaceCfg?.settings || {};
const noPlan = !spaceCfg?.plan_url;
return {
showBorders: s.show_borders ?? 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,
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',
};
}
/**
* Room fill color for the selected mode, or null for "no fill".
* - lqi: redgreen gradient by the room's average zigbee signal (null LQI no fill)
* - light: warm yellow when any light in the room is on, grey when all known
* lights are off; rooms without lights get no fill (a bathroom without smart
* bulbs should not look permanently "off").
*/
export function roomFillColor(
mode: RoomFillMode,
lqi: number | null,
lights: 'on' | 'off' | 'none',
): string | null {
if (mode === 'lqi') return lqi == null ? null : lqiColor(lqi);
if (mode === 'light') {
if (lights === 'none') return null;
return lights === 'on' ? '#ffd45c' : '#9aa0a6';
}
return null;
}
+59
View File
@@ -204,6 +204,33 @@ export const cardStyles = css`
fill: rgba(75, 140, 90, 0.24);
stroke: #6fbf86;
}
.room.styled {
stroke: var(--room-stroke, transparent);
stroke-opacity: var(--room-stroke-op, 0);
stroke-width: 2.5;
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;
}
.roomlabel {
position: absolute;
transform: translate(-50%, -50%);
font-size: calc(var(--icon-size, 2.5cqw) * 0.5);
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
cursor: grab;
pointer-events: auto;
user-select: none;
text-shadow: 0 0 4px var(--card-background-color, rgba(0, 0, 0, 0.6));
z-index: 1;
}
.roomlabel:active { cursor: grabbing; }
.rlabel {
fill: var(--hp-muted);
font-size: 15px;
@@ -383,6 +410,38 @@ export const cardStyles = css`
.tab.tabadd ha-icon {
--mdc-icon-size: 15px;
}
.srcrow {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
cursor: pointer;
padding: 2px 0;
}
.dispsection {
margin-top: 12px !important;
padding-top: 8px;
border-top: 1px solid var(--hp-line);
font-weight: 600;
color: var(--hp-txt) !important;
}
.colorrow {
display: flex;
align-items: center;
gap: 8px;
}
.colorrow input[type='color'] {
width: 42px;
height: 28px;
border: 1px solid var(--hp-line);
border-radius: 6px;
background: transparent;
padding: 1px;
cursor: pointer;
}
.colorrow input[type='range'] { flex: 1; }
.colorrow .opl { color: var(--hp-muted); font-size: 12px; }
.colorrow .opv { font-size: 12px; min-width: 34px; text-align: right; }
.planrow {
display: flex;
align-items: center;
+13 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor } from '../test-build/devices.js';
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, areaLights } from '../test-build/devices.js';
import { compileIconRules } from '../test-build/rules.js';
/** Minimal fake hass around the pieces buildDevices reads. */
@@ -158,3 +158,15 @@ test('lqiFor: dedicated sensor wins over attribute duplication; tempFor rounds',
assert.equal(lqiFor(h, ['sensor.x_linkquality', 'sensor.temp']), 119); // avg(120,118)
assert.equal(tempFor(h, ['sensor.temp']), 22.5);
});
test('areaLights: on / off / none tri-state', () => {
const hass = mkHass({ states: { 'light.a': { state: 'on' }, 'light.b': { state: 'off' } } });
const devs = [
{ area: 'living', entities: ['light.a', 'sensor.x'] },
{ area: 'kitchen', entities: ['light.b'] },
{ area: 'bath', entities: ['sensor.hum'] },
];
assert.equal(areaLights(hass, devs, 'living'), 'on');
assert.equal(areaLights(hass, devs, 'kitchen'), 'off');
assert.equal(areaLights(hass, devs, 'bath'), 'none');
});
+28 -1
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst,
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
} from '../test-build/logic.js';
import {
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
@@ -212,3 +212,30 @@ test('subst: replaces every occurrence of a placeholder, ignores unknown', () =>
assert.equal(subst('no vars'), 'no vars');
assert.equal(subst('keep {unknown}', { n: 1 }), 'keep {unknown}');
});
test('spaceDisplayOf: defaults differ for spaces with and without a plan', () => {
const withPlan = spaceDisplayOf({ plan_url: '/x.svg' });
assert.equal(withPlan.showBorders, false);
assert.equal(withPlan.showNames, false);
assert.equal(withPlan.fill, 'none');
const noPlan = spaceDisplayOf({ plan_url: null });
assert.equal(noPlan.showBorders, true);
assert.equal(noPlan.showNames, true);
const s = spaceDisplayOf({ plan_url: null, settings: { show_borders: false, room_color: '#ff0000', room_opacity: 2, fill_mode: 'lqi' } });
assert.equal(s.showBorders, false);
assert.equal(s.color, '#ff0000');
assert.equal(s.opacity, 1);
assert.equal(s.fill, 'lqi');
const g = spaceDisplayOf({ settings: { room_color: 'javascript:alert(1)', fill_mode: 'weird' } });
assert.equal(g.color, '#3ea6ff');
assert.equal(g.fill, 'none');
});
test('roomFillColor: lqi gradient, light tri-state, none', () => {
assert.equal(roomFillColor('none', 200, 'on'), null);
assert.equal(roomFillColor('lqi', null, 'on'), null);
assert.equal(roomFillColor('lqi', 180, 'none'), 'hsl(120, 85%, 55%)');
assert.equal(roomFillColor('light', null, 'on'), '#ffd45c');
assert.equal(roomFillColor('light', null, 'off'), '#9aa0a6');
assert.equal(roomFillColor('light', null, 'none'), null);
});
+19
View File
@@ -97,3 +97,22 @@ def test_layout_schema():
v.LAYOUT_SCHEMA({"dev1": {"x": 0.1, "y": 0.2, "s": "f1"}})
with pytest.raises(vol.Invalid):
v.LAYOUT_SCHEMA({"dev1": {"x": 0.1}})
def test_space_display_settings():
"""Per-space display settings validate; garbage color/mode is rejected."""
ok = {
"id": "f1", "title": "Floor 1", "aspect": 1.0, "view_box": [0, 0, 1, 1],
"rooms": [], "settings": {
"show_borders": True, "show_names": False,
"room_color": "#3ea6ff", "room_opacity": 0.5, "fill_mode": "lqi",
},
}
v.SPACE_SCHEMA(ok)
import pytest as _pytest
bad_color = dict(ok, settings={"room_color": "javascript:x"})
with _pytest.raises(Exception):
v.SPACE_SCHEMA(bad_color)
bad_mode = dict(ok, settings={"fill_mode": "rainbow"})
with _pytest.raises(Exception):
v.SPACE_SCHEMA(bad_mode)