mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
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:
@@ -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.13.3"
|
VERSION = "1.14.0"
|
||||||
|
|
||||||
DEFAULT_CONFIG: dict = {
|
DEFAULT_CONFIG: dict = {
|
||||||
"spaces": [],
|
"spaces": [],
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
|||||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"single_config_entry": true,
|
"single_config_entry": true,
|
||||||
"version": "1.13.3"
|
"version": "1.14.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,10 +78,22 @@ ROOM_SCHEMA = vol.All(
|
|||||||
),
|
),
|
||||||
_require_geometry,
|
_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(
|
SPACE_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required("id"): str,
|
vol.Required("id"): str,
|
||||||
vol.Required("title"): str,
|
vol.Required("title"): str,
|
||||||
|
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
|
||||||
vol.Optional("plan_url"): vol.Any(str, None),
|
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("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)),
|
vol.Required("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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');
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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('<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();
|
||||||
@@ -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();
|
||||||
@@ -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 |
@@ -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 |
Executable
+1285
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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>
|
||||||
Vendored
+212
-106
File diff suppressed because one or more lines are too long
@@ -1,5 +1,28 @@
|
|||||||
# Changelog
|
# 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)
|
## 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
|
- 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
|
bundled-data era). Nothing in the build referenced it; instance data lives in
|
||||||
|
|||||||
+5
-1
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
| Item | State |
|
| 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 |
|
| 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 |
|
||||||
@@ -43,6 +43,10 @@
|
|||||||
- **v1.13.2** — audit round 3: buildDevices unit-test suite, multi-placeholder t(),
|
- **v1.13.2** — audit round 3: buildDevices unit-test suite, multi-placeholder t(),
|
||||||
conflict resync in _saveConfigNow, pointercancel long-press fix, repairs re-check
|
conflict resync in _saveConfigNow, pointercancel long-press fix, repairs re-check
|
||||||
on config save (repairs.py).
|
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
|
## Where things live
|
||||||
|
|
||||||
|
|||||||
+161
@@ -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.
|
||||||
Generated
+58
-2
@@ -1,21 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.12.0",
|
"version": "1.13.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.12.0",
|
"version": "1.13.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lit": "^3.1.3"
|
"lit": "^3.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@mdi/js": "^7.4.47",
|
||||||
"@rollup/plugin-json": "^6.1.0",
|
"@rollup/plugin-json": "^6.1.0",
|
||||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||||
"@rollup/plugin-terser": "^0.4.4",
|
"@rollup/plugin-terser": "^0.4.4",
|
||||||
"@rollup/plugin-typescript": "^11.1.6",
|
"@rollup/plugin-typescript": "^11.1.6",
|
||||||
|
"playwright": "^1.61.1",
|
||||||
"rollup": "^4.18.0",
|
"rollup": "^4.18.0",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.6.2",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
@@ -86,6 +88,13 @@
|
|||||||
"@lit-labs/ssr-dom-shim": "^1.5.0"
|
"@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": {
|
"node_modules/@rollup/plugin-json": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz",
|
||||||
@@ -741,6 +750,53 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/randombytes": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.13.3",
|
"version": "1.14.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",
|
||||||
@@ -11,10 +11,12 @@
|
|||||||
"test": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/*.test.mjs"
|
"test": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/*.test.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@mdi/js": "^7.4.47",
|
||||||
"@rollup/plugin-json": "^6.1.0",
|
"@rollup/plugin-json": "^6.1.0",
|
||||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||||
"@rollup/plugin-terser": "^0.4.4",
|
"@rollup/plugin-terser": "^0.4.4",
|
||||||
"@rollup/plugin-typescript": "^11.1.6",
|
"@rollup/plugin-typescript": "^11.1.6",
|
||||||
|
"playwright": "^1.61.1",
|
||||||
"rollup": "^4.18.0",
|
"rollup": "^4.18.0",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.6.2",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
|
|||||||
@@ -292,3 +292,20 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
|||||||
}
|
}
|
||||||
return rest;
|
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';
|
||||||
|
}
|
||||||
|
|||||||
+207
-27
@@ -14,8 +14,9 @@ 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,
|
||||||
} from './logic';
|
} from './logic';
|
||||||
import { buildDevices, lqiFor, tempFor } from './devices';
|
import { buildDevices, lqiFor, tempFor, areaLights } from './devices';
|
||||||
import type {
|
import type {
|
||||||
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
|
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
|
||||||
} from './types';
|
} from './types';
|
||||||
@@ -23,7 +24,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.13.3';
|
const CARD_VERSION = '1.14.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';
|
||||||
@@ -120,6 +121,13 @@ class HouseplanCard extends LitElement {
|
|||||||
title: string;
|
title: string;
|
||||||
planUrl: string | null;
|
planUrl: string | null;
|
||||||
planFile: { ext: string; b64: string; aspect: number; name: 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;
|
busy: boolean;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
private _keyHandler = (e: KeyboardEvent) => this._onKey(e);
|
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;
|
const p = d.primary ? this.hass.states[d.primary] : undefined;
|
||||||
if (!p) return '';
|
if (!p) return '';
|
||||||
if (p.state === 'unavailable') return 'unavail';
|
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 (['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 === 'cover' || dom === 'valve') return ['open', 'opening'].includes(p.state) ? 'open' : '';
|
||||||
if (dom === 'lock') return ['unlocked', 'open'].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') {
|
if (mode === 'edit') {
|
||||||
const sp = this._serverCfg!.spaces.find((x: any) => x.id === spaceId);
|
const sp = this._serverCfg!.spaces.find((x: any) => x.id === spaceId);
|
||||||
if (!sp) return;
|
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 {
|
} 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> {
|
private async _saveSpaceDialog(): Promise<void> {
|
||||||
const d = this._spaceDialog;
|
const d = this._spaceDialog;
|
||||||
if (!d || d.busy || !d.title.trim()) return;
|
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'));
|
this._showToast(this._t('toast.plan_required'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1488,12 +1512,13 @@ class HouseplanCard extends LitElement {
|
|||||||
try {
|
try {
|
||||||
const cfg = this._serverCfg!;
|
const cfg = this._serverCfg!;
|
||||||
let sp: any;
|
let sp: any;
|
||||||
|
const drawAspect = d.orientation === 'portrait' ? 0.707 : d.orientation === 'square' ? 1 : 1.414;
|
||||||
if (d.mode === 'create') {
|
if (d.mode === 'create') {
|
||||||
sp = {
|
sp = {
|
||||||
id: 's' + Date.now().toString(36),
|
id: 's' + Date.now().toString(36),
|
||||||
title: d.title.trim(),
|
title: d.title.trim(),
|
||||||
plan_url: null,
|
plan_url: null,
|
||||||
aspect: 1.414,
|
aspect: d.source === 'draw' ? drawAspect : 1.414,
|
||||||
view_box: [0, 0, 1, 1],
|
view_box: [0, 0, 1, 1],
|
||||||
rooms: [],
|
rooms: [],
|
||||||
segments: [],
|
segments: [],
|
||||||
@@ -1503,13 +1528,26 @@ class HouseplanCard extends LitElement {
|
|||||||
sp = cfg.spaces.find((x: any) => x.id === d.spaceId);
|
sp = cfg.spaces.find((x: any) => x.id === d.spaceId);
|
||||||
sp.title = d.title.trim();
|
sp.title = d.title.trim();
|
||||||
}
|
}
|
||||||
if (d.planFile) {
|
if (d.source === 'file' && d.planFile) {
|
||||||
const resp = await this.hass.callWS({
|
const resp = await this.hass.callWS({
|
||||||
type: 'houseplan/plan/set', space_id: sp.id, ext: d.planFile.ext, data: d.planFile.b64,
|
type: 'houseplan/plan/set', space_id: sp.id, ext: d.planFile.ext, data: d.planFile.b64,
|
||||||
});
|
});
|
||||||
sp.plan_url = resp.url;
|
sp.plan_url = resp.url;
|
||||||
sp.aspect = d.planFile.aspect;
|
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();
|
await this._saveConfigNow();
|
||||||
this._spaceDialog = null;
|
this._spaceDialog = null;
|
||||||
if (d.mode === 'create') this._space = sp.id;
|
if (d.mode === 'create') this._space = sp.id;
|
||||||
@@ -1592,7 +1630,13 @@ class HouseplanCard extends LitElement {
|
|||||||
private _openNextImport(): void {
|
private _openNextImport(): void {
|
||||||
const title = this._importQueue.shift();
|
const title = this._importQueue.shift();
|
||||||
if (title === undefined) return;
|
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. */
|
/** Skip the current floor of the wizard without creating a space. */
|
||||||
@@ -1773,6 +1817,7 @@ class HouseplanCard extends LitElement {
|
|||||||
const space = this._spaceModel();
|
const space = this._spaceModel();
|
||||||
const vb = space.vb;
|
const vb = space.vb;
|
||||||
const devs = this._devices.filter((d) => d.space === space.id);
|
const devs = this._devices.filter((d) => d.space === space.id);
|
||||||
|
const disp = spaceDisplayOf(this._curSpaceCfg);
|
||||||
const cfgSize = this._config.icon_size ?? 2.5;
|
const cfgSize = this._config.icon_size ?? 2.5;
|
||||||
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
|
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
|
||||||
const view = this._viewOr(vb);
|
const view = this._viewOr(vb);
|
||||||
@@ -1860,18 +1905,38 @@ class HouseplanCard extends LitElement {
|
|||||||
${space.bg
|
${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" />`
|
? 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}
|
: nothing}
|
||||||
${space.rooms.filter((r) => r.area || this._markup).map((r) => {
|
${space.rooms.filter((r) => r.area || this._markup || disp.showBorders).map((r) => {
|
||||||
const cls = 'room ' + (space.bg ? 'overlay' : 'yard') + (this._markup ? ' outlined' : '');
|
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) =>
|
const tip = (e: MouseEvent) =>
|
||||||
this._showTip(e, r.name, this._t('tip.room'),
|
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);
|
||||||
const label = !space.bg || this._markup;
|
const label = (!space.bg && !disp.showNames) || this._markup;
|
||||||
const c = this._roomCenter(r);
|
const c = this._roomCenter(r);
|
||||||
const shape = r.poly
|
const shape = r.poly
|
||||||
? svg`<polygon class="${cls}" 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}
|
@click=${() => this._clickRoom(r)} @mousemove=${tip}
|
||||||
@mouseleave=${() => (this._tip = null)}></polygon>`
|
@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}"
|
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}
|
@click=${() => this._clickRoom(r)} @mousemove=${tip}
|
||||||
@mouseleave=${() => (this._tip = null)}></rect>`;
|
@mouseleave=${() => (this._tip = null)}></rect>`;
|
||||||
@@ -1881,6 +1946,9 @@ class HouseplanCard extends LitElement {
|
|||||||
</svg>
|
</svg>
|
||||||
<div class="devlayer" style="--icon-size:${((iconPct * vb[2]) / view.w).toFixed(3)}cqw">
|
<div class="devlayer" style="--icon-size:${((iconPct * vb[2]) / view.w).toFixed(3)}cqw">
|
||||||
${devs.map((d) => this._renderDevice(d, view))}
|
${devs.map((d) => this._renderDevice(d, view))}
|
||||||
|
${disp.showNames && !this._markup
|
||||||
|
? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp))
|
||||||
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${this._zoom > 1
|
${this._zoom > 1
|
||||||
@@ -1934,6 +2002,69 @@ class HouseplanCard extends LitElement {
|
|||||||
</div>`;
|
</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[] {
|
private _roomCenter(r: RoomCfg): number[] {
|
||||||
if (r.poly) {
|
if (r.poly) {
|
||||||
const n = r.poly.length;
|
const n = r.poly.length;
|
||||||
@@ -2174,18 +2305,67 @@ class HouseplanCard extends LitElement {
|
|||||||
.value=${d.title}
|
.value=${d.title}
|
||||||
@input=${(e: Event) => (this._spaceDialog = { ...d, title: (e.target as HTMLInputElement).value })} />
|
@input=${(e: Event) => (this._spaceDialog = { ...d, title: (e.target as HTMLInputElement).value })} />
|
||||||
<label>${this._t('space.plan_label')}</label>
|
<label>${this._t('space.plan_label')}</label>
|
||||||
<div class="planrow">
|
<label class="srcrow">
|
||||||
${d.planFile
|
<input type="radio" name="plansrc" .checked=${d.source === 'file'}
|
||||||
? html`<span class="planname">${d.planFile.name}</span>`
|
@change=${() => (this._spaceDialog = { ...d, source: 'file' })} />
|
||||||
: d.planUrl
|
<span>${this._t('space.source_file')}</span>
|
||||||
? html`<img class="planprev" src=${d.planUrl} alt=${this._t('space.plan_alt')} />`
|
</label>
|
||||||
: html`<span class="planname muted">${this._t('space.no_plan')}</span>`}
|
${d.source === 'file'
|
||||||
<label class="btn filebtn">
|
? html`<div class="planrow">
|
||||||
<ha-icon icon="mdi:upload"></ha-icon>${d.planUrl || d.planFile ? this._t('btn.replace') : this._t('btn.upload')}
|
${d.planFile
|
||||||
<input type="file" hidden accept=".svg,.png,.jpg,.jpeg,.webp,image/svg+xml,image/png,image/jpeg,image/webp"
|
? html`<span class="planname">${d.planFile.name}</span>`
|
||||||
@change=${(e: Event) => this._pickPlanFile(e)} />
|
: d.planUrl
|
||||||
</label>
|
? html`<img class="planprev" src=${d.planUrl} alt=${this._t('space.plan_alt')} />`
|
||||||
|
: html`<span class="planname muted">${this._t('space.no_plan')}</span>`}
|
||||||
|
<label class="btn filebtn">
|
||||||
|
<ha-icon icon="mdi:upload"></ha-icon>${d.planUrl || d.planFile ? this._t('btn.replace') : this._t('btn.upload')}
|
||||||
|
<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>
|
</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>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
${d.mode === 'edit'
|
${d.mode === 'edit'
|
||||||
@@ -2199,8 +2379,8 @@ class HouseplanCard extends LitElement {
|
|||||||
: nothing}
|
: nothing}
|
||||||
<button class="btn ghost" @click=${() => { this._spaceDialog = null; this._importQueue = []; this._importTotal = 0; }}>${this._t('btn.cancel')}</button>
|
<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}
|
<button class="btn on" @click=${this._saveSpaceDialog}
|
||||||
?disabled=${!d.title.trim() || !(d.planFile || d.planUrl) || d.busy}
|
?disabled=${!d.title.trim() || (d.source === 'file' && !(d.planFile || d.planUrl)) || d.busy}
|
||||||
title=${!(d.planFile || d.planUrl) ? this._t('title.need_plan') : ''}>
|
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')}
|
<ha-icon icon="mdi:check"></ha-icon>${d.busy ? '…' : this._t('btn.save')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+16
-1
@@ -151,5 +151,20 @@
|
|||||||
"import.manual": "Start from scratch",
|
"import.manual": "Start from scratch",
|
||||||
"import.progress": "Floor {i} of {n}",
|
"import.progress": "Floor {i} of {n}",
|
||||||
"import.done": "Spaces created. Outline the rooms: click grid dots and close the contour.",
|
"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
@@ -151,5 +151,20 @@
|
|||||||
"import.manual": "Начать с нуля",
|
"import.manual": "Начать с нуля",
|
||||||
"import.progress": "Этаж {i} из {n}",
|
"import.progress": "Этаж {i} из {n}",
|
||||||
"import.done": "Пространства созданы. Обведите комнаты: кликайте по точкам сетки и замкните контур.",
|
"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": "Квадрат"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v));
|
||||||
return out;
|
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: red→green 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -204,6 +204,33 @@ export const cardStyles = css`
|
|||||||
fill: rgba(75, 140, 90, 0.24);
|
fill: rgba(75, 140, 90, 0.24);
|
||||||
stroke: #6fbf86;
|
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 {
|
.rlabel {
|
||||||
fill: var(--hp-muted);
|
fill: var(--hp-muted);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -383,6 +410,38 @@ export const cardStyles = css`
|
|||||||
.tab.tabadd ha-icon {
|
.tab.tabadd ha-icon {
|
||||||
--mdc-icon-size: 15px;
|
--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 {
|
.planrow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+13
-1
@@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor } from '../test-build/devices.js';
|
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, areaLights } 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. */
|
||||||
@@ -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(lqiFor(h, ['sensor.x_linkquality', 'sensor.temp']), 119); // avg(120,118)
|
||||||
assert.equal(tempFor(h, ['sensor.temp']), 22.5);
|
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
@@ -2,7 +2,7 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
|
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';
|
} from '../test-build/logic.js';
|
||||||
import {
|
import {
|
||||||
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
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('no vars'), 'no vars');
|
||||||
assert.equal(subst('keep {unknown}', { n: 1 }), 'keep {unknown}');
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -97,3 +97,22 @@ def test_layout_schema():
|
|||||||
v.LAYOUT_SCHEMA({"dev1": {"x": 0.1, "y": 0.2, "s": "f1"}})
|
v.LAYOUT_SCHEMA({"dev1": {"x": 0.1, "y": 0.2, "s": "f1"}})
|
||||||
with pytest.raises(vol.Invalid):
|
with pytest.raises(vol.Invalid):
|
||||||
v.LAYOUT_SCHEMA({"dev1": {"x": 0.1}})
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user