Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ee0b2c352 | ||
|
|
3eb5b4a14d | ||
|
|
eaa722679f | ||
|
|
4f8e98cdc7 | ||
|
|
6c8b509da2 | ||
|
|
4146e5998e | ||
|
|
34d073f484 | ||
|
|
fff7368696 | ||
|
|
37604fa17e |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 76 KiB |
@@ -7,7 +7,6 @@ from pathlib import Path
|
||||
from homeassistant.components.frontend import add_extra_js_url
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from . import websocket_api as hp_ws
|
||||
from .const import (
|
||||
@@ -19,6 +18,7 @@ from .const import (
|
||||
PLANS_URL,
|
||||
VERSION,
|
||||
)
|
||||
from .repairs import async_check_plan_files
|
||||
from .store import HouseplanConfigEntry, create_data
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -82,47 +82,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
if not await _register_lovelace_resource(hass, module_url):
|
||||
add_extra_js_url(hass, module_url)
|
||||
|
||||
await _check_plan_files(hass, entry)
|
||||
await async_check_plan_files(hass, entry)
|
||||
return True
|
||||
|
||||
|
||||
async def _check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntry) -> None:
|
||||
"""Repairs: raise an issue for every space whose plan file is missing on disk."""
|
||||
cfg_raw = await entry.runtime_data.config_store.async_load() or {}
|
||||
spaces = cfg_raw.get("config", {}).get("spaces", [])
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
|
||||
def _missing() -> list[tuple[str, str]]:
|
||||
res = []
|
||||
for sp in spaces:
|
||||
url = sp.get("plan_url") or ""
|
||||
if not url.startswith(PLANS_URL + "/"):
|
||||
continue # external/legacy URL — not ours to verify
|
||||
fname = url[len(PLANS_URL) + 1 :].split("?", 1)[0]
|
||||
if not (plans_dir / fname).is_file():
|
||||
res.append((sp.get("id", "?"), fname))
|
||||
return res
|
||||
|
||||
missing = await hass.async_add_executor_job(_missing)
|
||||
seen = set()
|
||||
for space_id, fname in missing:
|
||||
seen.add(space_id)
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"broken_plan_{space_id}",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="broken_plan",
|
||||
translation_placeholders={"space": space_id, "file": fname},
|
||||
)
|
||||
# clear stale issues for spaces that are fine again (or gone)
|
||||
for sp in spaces:
|
||||
sid = sp.get("id", "?")
|
||||
if sid not in seen:
|
||||
ir.async_delete_issue(hass, DOMAIN, f"broken_plan_{sid}")
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
|
||||
"""Unload the entry.
|
||||
|
||||
|
||||
@@ -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.1"
|
||||
VERSION = "1.15.0"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.13.1"
|
||||
"version": "1.15.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Repair issues for House Plan.
|
||||
|
||||
The check runs at entry setup AND after every config save (ws_config_set),
|
||||
so a plan file that goes missing — or gets re-uploaded — is reflected in the
|
||||
Repairs UI without waiting for a restart.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .const import DOMAIN, PLANS_DIR, PLANS_URL
|
||||
from .store import HouseplanConfigEntry
|
||||
|
||||
|
||||
async def async_check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntry) -> None:
|
||||
"""Raise an issue for every space whose plan file is missing on disk."""
|
||||
cfg_raw = await entry.runtime_data.config_store.async_load() or {}
|
||||
spaces = cfg_raw.get("config", {}).get("spaces", [])
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
|
||||
def _missing() -> list[tuple[str, str]]:
|
||||
res = []
|
||||
for sp in spaces:
|
||||
url = sp.get("plan_url") or ""
|
||||
if not url.startswith(PLANS_URL + "/"):
|
||||
continue # external/legacy URL — not ours to verify
|
||||
fname = url[len(PLANS_URL) + 1 :].split("?", 1)[0]
|
||||
if not (plans_dir / fname).is_file():
|
||||
res.append((sp.get("id", "?"), fname))
|
||||
return res
|
||||
|
||||
missing = await hass.async_add_executor_job(_missing)
|
||||
broken = set()
|
||||
for space_id, fname in missing:
|
||||
broken.add(space_id)
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"broken_plan_{space_id}",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="broken_plan",
|
||||
translation_placeholders={"space": space_id, "file": fname},
|
||||
)
|
||||
# clear stale issues for spaces that are fine again (or gone)
|
||||
for sp in spaces:
|
||||
sid = sp.get("id", "?")
|
||||
if sid not in broken:
|
||||
ir.async_delete_issue(hass, DOMAIN, f"broken_plan_{sid}")
|
||||
@@ -78,10 +78,24 @@ 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", "temp"]),
|
||||
vol.Optional("temp_min"): vol.Coerce(float),
|
||||
vol.Optional("temp_max"): vol.Coerce(float),
|
||||
},
|
||||
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)),
|
||||
|
||||
@@ -180,6 +180,12 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
new_rev = current_rev + 1
|
||||
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
|
||||
# refresh repair issues (broken plan references) without waiting for a restart
|
||||
entry = get_entry(hass)
|
||||
if entry is not None:
|
||||
from .repairs import async_check_plan_files
|
||||
|
||||
hass.async_create_task(async_check_plan_files(hass, entry))
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
|
||||
|
||||
|
||||
@@ -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,30 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const setFill = async (settings) => {
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({ ...s, settings })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
return [...sr().querySelectorAll('.room.styled')].map((r) => {
|
||||
const st = r.getAttribute('style') || '';
|
||||
const m = st.match(/--room-fill:([^;]+)/);
|
||||
return m ? m[1] : null;
|
||||
});
|
||||
};
|
||||
// living room avg temp = 22.4 (единственный термометр в demo)
|
||||
out.comfy = await setFill({ show_borders: true, fill_mode: 'temp', temp_min: 20, temp_max: 25 });
|
||||
out.cold = await setFill({ show_borders: true, fill_mode: 'temp', temp_min: 23, temp_max: 26 });
|
||||
out.hot = await setFill({ show_borders: true, fill_mode: 'temp', temp_min: 18, temp_max: 21 });
|
||||
out.swapped = await setFill({ show_borders: true, fill_mode: 'temp', temp_min: 25, temp_max: 20 });
|
||||
// диалог: поля границ видны только в режиме temp
|
||||
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
|
||||
out.dialogTempFields = sr().querySelectorAll('.tempin').length;
|
||||
c._spaceDialog = { ...c._spaceDialog, fillMode: 'none' }; await c.updateComplete;
|
||||
out.dialogHiddenWhenNone = sr().querySelectorAll('.tempin').length;
|
||||
c._spaceDialog = null;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -21,7 +21,6 @@ houseplan-card/
|
||||
│ ├─ config_flow.py # single entry; admin_only option (editing restricted to admins)
|
||||
│ ├─ const.py # DOMAIN, STORAGE_KEY, VERSION, FRONTEND_URL
|
||||
│ └─ frontend/houseplan-card.js # copy of dist, served as /houseplan_files/houseplan-card.js
|
||||
├─ assets/ # plan sources: f1_plan.svg, f2_plan.svg (REMPLANNER), *_bg.png (old raster versions)
|
||||
├─ hacs.json # HACS manifest
|
||||
└─ docs/ # this documentation
|
||||
```
|
||||
|
||||
@@ -1,5 +1,61 @@
|
||||
# Changelog
|
||||
|
||||
## v1.15.0 — 2026-07-06 (temperature room fill)
|
||||
- New room fill mode **"Temperature"**: light blue below the comfort range, green
|
||||
inside it, warm yellow above. The comfort bounds (default 20–25 °C) are editable
|
||||
right in the space dialog and appear only when the mode is selected; bounds
|
||||
entered in the wrong order are swapped automatically. A room's temperature is
|
||||
the average of its devices' temperature readings; rooms without a reading stay
|
||||
unfilled. (+`areaTemp` helper, 3 new frontend tests, backend schema fields.)
|
||||
|
||||
## v1.14.0 — 2026-07-06 (per-space display settings, hand-drawn spaces, testing checklist)
|
||||
- **Per-space "Display" settings** (space dialog): always-visible room borders,
|
||||
room name labels, a border/name color picker with an opacity slider, and a room
|
||||
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
|
||||
server-side config. Note: the files remain in old git history and release archives.
|
||||
|
||||
## v1.13.2 — 2026-07-05 (audit round 3: fixes + buildDevices test suite)
|
||||
- **buildDevices finally has a direct unit-test suite** (12 tests on a fake hass):
|
||||
area filtering, curation incl. show-all, duplicate numbering, light-group folding
|
||||
and its `group_lights=false` inverse, marker claim/metadata/hidden/virtual/entity
|
||||
paths, custom icon rules + the deliberate lock override, device_class fallback,
|
||||
primary-entity priority, LQI/temperature extraction. Frontend tests: 28 → 41.
|
||||
- Fix: `t()` now substitutes **every** occurrence of a placeholder (extracted as the
|
||||
pure `subst()` helper with a regression test).
|
||||
- Fix: `_saveConfigNow` refreshes the local config on a rev conflict before
|
||||
rethrowing — a retry no longer hits the same conflict (the debounced path already
|
||||
did this; the immediate path did not).
|
||||
- Fix: `pointercancel` on a device icon clears the long-press timer — no phantom
|
||||
info card after an aborted touch gesture.
|
||||
- Repairs check moved to `repairs.py` and now **re-runs after every config save**,
|
||||
so a missing/restored plan file is reflected in the Repairs UI without a restart.
|
||||
- Documented the deliberate `mdi:lock` override in `devices.ts` (wins over custom
|
||||
rules — a mislabeled lock icon is safety-relevant confusion).
|
||||
- Test infra: `tsconfig.test.json` also compiles `devices.ts`/`types.ts`;
|
||||
`scripts/fix-test-build.mjs` appends `.js` to tsc's extensionless ESM imports.
|
||||
|
||||
## v1.13.1 — 2026-07-05 (distribution materials)
|
||||
- **Demo GIF** in the README — recorded on a fully synthetic demo home (no real
|
||||
floor plans): live states, tap-to-toggle, drag, zoom, info card, space tabs.
|
||||
|
||||
@@ -13,14 +13,14 @@
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.13.1** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
||||
| Version | **v1.15.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…v1.11.2 |
|
||||
| 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/8995** (queue ≈2 months as of 2026-07) |
|
||||
| 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 |
|
||||
| Brands | Ships **inside the integration**: `custom_components/houseplan/brand/{icon,icon@2x,logo,logo@2x}.png` (HA ≥2026.3 local-brands mechanism). home-assistant/brands PR #10700 was auto-closed — that repo no longer accepts custom integrations |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed v1.11.2, installed *via HACS* (custom repo) — updates flow through HACS now |
|
||||
| Localization | UI en/ru (`src/i18n.ts`), auto by `hass.locale` + `language` card option; codebase and docs are English-first (`README.ru.md` is the Russian copy) |
|
||||
| Tests | 28 frontend (node:test) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13; skipped locally — sandbox has py3.10) |
|
||||
| Tests | 41 frontend (node:test, incl. a 12-test buildDevices suite on a fake hass) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13; skipped locally — sandbox has py3.10) |
|
||||
|
||||
## Recent milestones (details in CHANGELOG.md)
|
||||
|
||||
@@ -40,6 +40,14 @@
|
||||
- **v1.13.1** — distribution: synthetic-home demo GIF in the README, issue templates,
|
||||
CONTRIBUTING.md, Discussions. Forum/Reddit drafts are in the user folder
|
||||
(`posts_drafts.md`) awaiting manual posting.
|
||||
- **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!).
|
||||
- **v1.15.0** — temperature room fill (blue/green/yellow) with editable comfort bounds.
|
||||
|
||||
## Where things live
|
||||
|
||||
@@ -52,11 +60,16 @@
|
||||
|
||||
## Open items / watchlist
|
||||
|
||||
1. **hacs/default PR #8995** — waiting for moderation (bot may draft the PR for minor fixes —
|
||||
fix and re-ready; wrongly filled template ⇒ silent close).
|
||||
1. **hacs/default PR #9004** — accepted by the bot into the review queue ('New default
|
||||
repository' label). Minor issues ⇒ the bot drafts the PR (fix and re-ready).
|
||||
2. GitHub PAT `houseplan-card-publish` (repo+workflow) expires ~2026-07-12; in sandbox
|
||||
`~/.git-credentials`. Revoke after the HACS queue clears, or re-issue when needed.
|
||||
3. Stale files on the mount that cannot be deleted from the sandbox: `src/data/` leftovers,
|
||||
3. Privacy: legacy real-house plan sources (`assets/`) removed from the tree in
|
||||
v1.13.3, but they persist in git history and old release archives; 8 README
|
||||
screenshots in docs/images are still from the real house (replacement with
|
||||
synthetic ones is a standing watchlist item). History rewrite deliberately NOT
|
||||
done — it would break existing release tags/HACS installs.
|
||||
4. Stale files on the mount that cannot be deleted from the sandbox: `src/data/` leftovers,
|
||||
`brand_preview.png`, old nested bundle copies — ignore, git is authoritative.
|
||||
4. Roadmap: phases 7–10 are DONE (v1.12.0 quality scale, v1.13.0 universality,
|
||||
v1.13.1 distribution). Next candidates: replace the remaining real-house README
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
# 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
|
||||
- [ ] Fill "temperature": blue below the comfort range, green inside, yellow above [auto]; comfort bounds editable in the dialog (swapped bounds tolerated [auto]); rooms without a temperature reading stay unfilled [auto]; bound fields appear only when the mode is selected
|
||||
- [ ] Room hover highlight still works when custom borders/fills are on
|
||||
- [ ] 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.
|
||||
|
Before Width: | Height: | Size: 323 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 290 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 1012 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 823 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 198 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 299 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 292 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 69 KiB |
@@ -1,21 +1,23 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.12.0",
|
||||
"version": "1.14.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.12.0",
|
||||
"version": "1.14.0",
|
||||
"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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.13.1",
|
||||
"version": "1.15.0",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -8,13 +8,15 @@
|
||||
"build": "tsc --noEmit && rollup -c",
|
||||
"watch": "rollup -c --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsc -p tsconfig.test.json && node --test test/*.test.mjs"
|
||||
"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"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// tsc keeps extensionless relative imports; Node ESM requires explicit ".js".
|
||||
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
for (const f of readdirSync('test-build')) {
|
||||
if (!f.endsWith('.js')) continue;
|
||||
const p = `test-build/${f}`;
|
||||
const s = readFileSync(p, 'utf8').replace(/from\s+'(\.\/[\w-]+)'/g, "from '$1.js'");
|
||||
writeFileSync(p, s);
|
||||
}
|
||||
@@ -292,3 +292,32 @@ 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';
|
||||
}
|
||||
|
||||
/** Average temperature across the area's devices (null when nothing reports one). */
|
||||
export function areaTemp(hass: any, devices: { area: string; entities: string[] }[], area: string): number | null {
|
||||
const vals: number[] = [];
|
||||
for (const d of devices) {
|
||||
if (d.area !== area) continue;
|
||||
const t = tempFor(hass, d.entities);
|
||||
if (t != null) vals.push(t);
|
||||
}
|
||||
if (!vals.length) return null;
|
||||
return Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 10) / 10;
|
||||
}
|
||||
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
import {
|
||||
lqiColor, snapToGrid, segKey as segKeyOf, samePoint, pointInPolygon, markerIdForBinding,
|
||||
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
||||
spaceDisplayOf, roomFillColor, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
|
||||
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
|
||||
} from './logic';
|
||||
import { buildDevices, lqiFor, tempFor } from './devices';
|
||||
import { buildDevices, lqiFor, tempFor, areaLights, areaTemp } from './devices';
|
||||
import type {
|
||||
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
|
||||
} from './types';
|
||||
@@ -23,7 +25,7 @@ import './editor';
|
||||
import { cardStyles } from './styles';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.13.1';
|
||||
const CARD_VERSION = '1.15.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 +122,15 @@ 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' | 'temp';
|
||||
tempMin: number;
|
||||
tempMax: number;
|
||||
busy: boolean;
|
||||
} | null = null;
|
||||
private _keyHandler = (e: KeyboardEvent) => this._onKey(e);
|
||||
@@ -583,7 +594,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 +1455,24 @@ 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,
|
||||
tempMin: disp.tempMin, tempMax: disp.tempMax,
|
||||
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',
|
||||
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
|
||||
busy: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1479,7 +1508,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 +1517,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 +1533,28 @@ 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,
|
||||
temp_min: Math.min(d.tempMin, d.tempMax),
|
||||
temp_max: Math.max(d.tempMin, d.tempMax),
|
||||
};
|
||||
await this._saveConfigNow();
|
||||
this._spaceDialog = null;
|
||||
if (d.mode === 'create') this._space = sp.id;
|
||||
@@ -1554,12 +1599,21 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** Immediate config save with a revision bump (no debounce). */
|
||||
/** Immediate config save with a revision bump (no debounce).
|
||||
|
||||
On a rev conflict the local copy is refreshed before rethrowing, so the
|
||||
user's retry starts from the fresh config instead of hitting the same
|
||||
conflict again. */
|
||||
private async _saveConfigNow(): Promise<void> {
|
||||
const r = await this.hass.callWS({
|
||||
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
|
||||
});
|
||||
this._cfgRev = r?.rev ?? this._cfgRev + 1;
|
||||
try {
|
||||
const r = await this.hass.callWS({
|
||||
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
|
||||
});
|
||||
this._cfgRev = r?.rev ?? this._cfgRev + 1;
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'conflict') await this._reloadConfigOnly();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1583,7 +1637,14 @@ 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',
|
||||
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
|
||||
busy: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Skip the current floor of the wizard without creating a space. */
|
||||
@@ -1764,6 +1825,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);
|
||||
@@ -1851,18 +1913,41 @@ 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',
|
||||
disp.fill === 'temp' ? areaTemp(this.hass, this._devices, r.area) : null,
|
||||
disp.tempMin,
|
||||
disp.tempMax,
|
||||
)
|
||||
: null;
|
||||
if (fillC) st.push(`--room-fill:${fillC}`, `--room-fill-op:${(0.3 * disp.opacity).toFixed(3)}`);
|
||||
else st.push('--room-fill:transparent', '--room-fill-op:0');
|
||||
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>`;
|
||||
@@ -1872,6 +1957,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
|
||||
@@ -1917,6 +2005,7 @@ class HouseplanCard extends LitElement {
|
||||
@pointerdown=${(e: PointerEvent) => this._pointerDown(e, d)}
|
||||
@pointermove=${(e: PointerEvent) => this._pointerMove(e, d)}
|
||||
@pointerup=${(e: PointerEvent) => this._pointerUp(e, d)}
|
||||
@pointercancel=${(e: PointerEvent) => this._pointerUp(e, d)}
|
||||
>
|
||||
<ha-icon icon="${d.icon}"></ha-icon>
|
||||
${temp != null ? html`<span class="tval">${temp}°</span>` : nothing}
|
||||
@@ -1924,6 +2013,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;
|
||||
@@ -2164,18 +2316,78 @@ 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">
|
||||
${d.planFile
|
||||
? html`<span class="planname">${d.planFile.name}</span>`
|
||||
: d.planUrl
|
||||
? 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>
|
||||
<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
|
||||
? 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>
|
||||
<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'], ['temp', 'fill.temp']].map(
|
||||
([v, k]) => html`<option value=${v} ?selected=${d.fillMode === v}>${this._t(k as any)}</option>`,
|
||||
)}
|
||||
</select>
|
||||
${d.fillMode === 'temp'
|
||||
? html`<div class="colorrow">
|
||||
<span class="opl">${this._t('space.temp_min')}</span>
|
||||
<input class="namein tempin" type="number" step="0.5" .value=${String(d.tempMin)}
|
||||
@input=${(e: Event) => (this._spaceDialog = { ...d, tempMin: Number((e.target as HTMLInputElement).value) })} />
|
||||
<span class="opl">${this._t('space.temp_max')}</span>
|
||||
<input class="namein tempin" type="number" step="0.5" .value=${String(d.tempMax)}
|
||||
@input=${(e: Event) => (this._spaceDialog = { ...d, tempMax: Number((e.target as HTMLInputElement).value) })} />
|
||||
<span class="opv">°C</span>
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="row">
|
||||
${d.mode === 'edit'
|
||||
@@ -2189,8 +2401,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>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* HA user profile (hass.locale.language); anything that is not a known
|
||||
* language falls back to English.
|
||||
*/
|
||||
import { subst } from './logic';
|
||||
import en from './i18n/en.json';
|
||||
import ru from './i18n/ru.json';
|
||||
|
||||
@@ -22,9 +23,7 @@ export function langOf(hass: any, configLang?: string | null): Lang {
|
||||
|
||||
/** Translate a key with optional {placeholder} substitution. */
|
||||
export function t(lang: Lang, key: Key, vars?: Record<string, string | number>): string {
|
||||
let s = DICTS[lang][key] ?? en[key] ?? key;
|
||||
if (vars) for (const [k, v] of Object.entries(vars)) s = s.replace('{' + k + '}', String(v));
|
||||
return s;
|
||||
return subst(DICTS[lang][key] ?? en[key] ?? key, vars);
|
||||
}
|
||||
|
||||
export type { Key as I18nKey };
|
||||
|
||||
@@ -151,5 +151,23 @@
|
||||
"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",
|
||||
"fill.temp": "Temperature (blue = cold, green = comfy, yellow = hot)",
|
||||
"space.temp_min": "Comfort from",
|
||||
"space.temp_max": "to"
|
||||
}
|
||||
|
||||
@@ -151,5 +151,23 @@
|
||||
"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": "Квадрат",
|
||||
"fill.temp": "По температуре (голубая = холодно, зелёная = комфорт, жёлтая = жарко)",
|
||||
"space.temp_min": "Комфорт от",
|
||||
"space.temp_max": "до"
|
||||
}
|
||||
|
||||
@@ -172,3 +172,79 @@ export function floorsOf(hass: any): FloorInfo[] {
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
/** Substitute every occurrence of {name} placeholders in a template string. */
|
||||
export function subst(s: string, vars?: Record<string, string | number>): string {
|
||||
if (!vars) return s;
|
||||
let out = s;
|
||||
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' | 'temp';
|
||||
|
||||
/** 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;
|
||||
tempMin: number; // comfort range lower bound, °C
|
||||
tempMax: number; // comfort range upper bound, °C
|
||||
}
|
||||
|
||||
export const DEFAULT_ROOM_COLOR = '#3ea6ff';
|
||||
export const DEFAULT_ROOM_OPACITY = 0.55;
|
||||
export const DEFAULT_TEMP_MIN = 20;
|
||||
export const DEFAULT_TEMP_MAX = 25;
|
||||
|
||||
/** Resolve a space's display settings; spaces without a plan default to visible markup. */
|
||||
export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
|
||||
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: ['lqi', 'light', 'temp'].includes(s.fill_mode) ? s.fill_mode : 'none',
|
||||
tempMin: typeof s.temp_min === 'number' ? s.temp_min : DEFAULT_TEMP_MIN,
|
||||
tempMax: typeof s.temp_max === 'number' ? s.temp_max : DEFAULT_TEMP_MAX,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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',
|
||||
temp?: number | null,
|
||||
tempMin: number = DEFAULT_TEMP_MIN,
|
||||
tempMax: number = DEFAULT_TEMP_MAX,
|
||||
): string | null {
|
||||
if (mode === 'lqi') return lqi == null ? null : lqiColor(lqi);
|
||||
if (mode === 'light') {
|
||||
if (lights === 'none') return null;
|
||||
return lights === 'on' ? '#ffd45c' : '#9aa0a6';
|
||||
}
|
||||
if (mode === 'temp') {
|
||||
// blue below the comfort range, green inside, yellow above; no reading → no fill.
|
||||
// Bounds are swapped automatically if entered in the wrong order.
|
||||
if (temp == null) return null;
|
||||
const lo = Math.min(tempMin, tempMax);
|
||||
const hi = Math.max(tempMin, tempMax);
|
||||
if (temp < lo) return '#4fc3f7';
|
||||
if (temp > hi) return '#ffd45c';
|
||||
return '#66d17a';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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,39 @@ 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 .tempin { width: 70px; flex: none; }
|
||||
.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;
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, areaLights, areaTemp } from '../test-build/devices.js';
|
||||
import { compileIconRules } from '../test-build/rules.js';
|
||||
|
||||
/** Minimal fake hass around the pieces buildDevices reads. */
|
||||
function mkHass({ devices = {}, entities = {}, states = {}, areas = {} } = {}) {
|
||||
return { devices, entities, states, areas };
|
||||
}
|
||||
const loc = (k) =>
|
||||
({ 'device.unnamed': 'unnamed', 'device.light_group': 'light group',
|
||||
'device.fallback': 'device', 'device.virtual': 'virtual device' }[k]);
|
||||
const baseCtx = (hass, over = {}) => ({
|
||||
hass, areaToSpace: { living: 'f1', kitchen: 'f1' }, markers: [], settings: {},
|
||||
excluded: new Set(['hacs']), showAll: false, firstSpaceId: 'f1', loc, ...over,
|
||||
});
|
||||
const dev = (id, name, model, area, extra = {}) =>
|
||||
({ id, name, model, area_id: area, identifiers: [['demo', id]], entry_type: null, via_device_id: null, ...extra });
|
||||
|
||||
test('buildDevices: devices outside bound areas are dropped', () => {
|
||||
const h = mkHass({ devices: {
|
||||
a: dev('a', 'Lamp', 'Bulb', 'living'),
|
||||
b: dev('b', 'Lamp', 'Bulb', 'garage'), // area not bound to a room
|
||||
c: dev('c', 'Lamp', 'Bulb', null), // no area at all
|
||||
}});
|
||||
const res = buildDevices(baseCtx(h));
|
||||
assert.deepEqual(res.map((d) => d.id), ['a']);
|
||||
assert.equal(res[0].space, 'f1');
|
||||
});
|
||||
|
||||
test('buildDevices: curation hides excluded/service/group/scene/bridge, show_all reveals', () => {
|
||||
const h = mkHass({ devices: {
|
||||
ok: dev('ok', 'Plug', 'Smart Plug', 'living'),
|
||||
ex: dev('ex', 'HACS', 'x', 'living', { identifiers: [['hacs', '1']] }),
|
||||
sv: dev('sv', 'Service', 'x', 'living', { entry_type: 'service' }),
|
||||
gr: dev('gr', 'Group dev', 'Group', 'living'),
|
||||
sc: dev('sc', 'Scenes', 'wireless scene switch', 'living'),
|
||||
br: dev('br', 'Zigbee Bridge', 'Bridge V2', 'living'),
|
||||
}});
|
||||
assert.deepEqual(buildDevices(baseCtx(h)).map((d) => d.id), ['ok']);
|
||||
// show_all reveals everything except service entries (those are never devices on the plan)
|
||||
const all = buildDevices(baseCtx(h, { showAll: true })).map((d) => d.id);
|
||||
assert.deepEqual(all.sort(), ['br', 'ex', 'gr', 'ok', 'sc']);
|
||||
});
|
||||
|
||||
test('buildDevices: duplicate name|area gets numbered, different areas do not', () => {
|
||||
const h = mkHass({ devices: {
|
||||
a: dev('a', 'Lamp', 'Bulb', 'living'),
|
||||
b: dev('b', 'Lamp', 'Bulb', 'living'),
|
||||
c: dev('c', 'Lamp', 'Bulb', 'kitchen'),
|
||||
}});
|
||||
const names = buildDevices(baseCtx(h)).map((d) => d.name);
|
||||
assert.deepEqual(names, ['Lamp', 'Lamp 2', 'Lamp']);
|
||||
});
|
||||
|
||||
test('buildDevices: light groups appear, single lamps in grouped areas are suppressed', () => {
|
||||
const h = mkHass({
|
||||
devices: { lamp: dev('lamp', 'Ceiling lamp', 'Bulb E27', 'living') },
|
||||
entities: {
|
||||
'light.group_living': { entity_id: 'light.group_living', platform: 'group', area_id: 'living' },
|
||||
'light.single': { entity_id: 'light.single', device_id: 'lamp', platform: 'demo' },
|
||||
},
|
||||
states: { 'light.group_living': { state: 'on', attributes: { friendly_name: 'Living lights' } } },
|
||||
});
|
||||
const res = buildDevices(baseCtx(h));
|
||||
const ids = res.map((d) => d.id);
|
||||
assert.ok(ids.includes('lg_light.group_living'));
|
||||
assert.ok(!ids.includes('lamp'), 'single lamp must fold into the group');
|
||||
// group_lights=false: no groups, the lamp comes back
|
||||
const res2 = buildDevices(baseCtx(h, { settings: { group_lights: false } }));
|
||||
assert.deepEqual(res2.map((d) => d.id), ['lamp']);
|
||||
});
|
||||
|
||||
test('buildDevices: claimed device is replaced by its marker (metadata applied)', () => {
|
||||
const h = mkHass({ devices: { a: dev('a', 'Boiler', 'BAXI', 'living') } });
|
||||
const markers = [{ id: 'a', binding: 'device:a', name: 'Main boiler', icon: 'mdi:water-boiler',
|
||||
link: 'https://x', tap_action: 'more-info' }];
|
||||
const res = buildDevices(baseCtx(h, { markers }));
|
||||
assert.equal(res.length, 1);
|
||||
const it = res[0];
|
||||
assert.equal(it.name, 'Main boiler');
|
||||
assert.equal(it.icon, 'mdi:water-boiler');
|
||||
assert.equal(it.link, 'https://x');
|
||||
assert.equal(it.tapAction, 'more-info');
|
||||
assert.equal(it.bindingKind, 'device');
|
||||
});
|
||||
|
||||
test('buildDevices: hidden marker removes the device entirely', () => {
|
||||
const h = mkHass({ devices: { a: dev('a', 'Noisy', 'x', 'living') } });
|
||||
const res = buildDevices(baseCtx(h, { markers: [{ id: 'a', binding: 'device:a', hidden: true }] }));
|
||||
assert.equal(res.length, 0);
|
||||
});
|
||||
|
||||
test('buildDevices: virtual marker lands in its room; entity marker resolves name from state', () => {
|
||||
const h = mkHass({
|
||||
entities: { 'sensor.avg': { entity_id: 'sensor.avg', platform: 'min_max', area_id: 'kitchen' } },
|
||||
states: { 'sensor.avg': { state: '21', attributes: { friendly_name: 'Average temp' } } },
|
||||
});
|
||||
const markers = [
|
||||
{ id: 'v1', binding: 'virtual', name: 'Septic tank', icon: 'mdi:pipe', space: 'f1', area: 'living' },
|
||||
{ id: 'lg_sensor.avg', binding: 'entity:sensor.avg' },
|
||||
];
|
||||
const res = buildDevices(baseCtx(h, { markers }));
|
||||
const v = res.find((d) => d.id === 'v1');
|
||||
assert.ok(v.virtual);
|
||||
assert.equal(v.icon, 'mdi:pipe');
|
||||
const e = res.find((d) => d.id === 'lg_sensor.avg');
|
||||
assert.equal(e.name, 'Average temp');
|
||||
assert.equal(e.primary, 'sensor.avg');
|
||||
assert.equal(e.space, 'f1'); // via the entity's area
|
||||
});
|
||||
|
||||
test('buildDevices: custom icon rules apply; lock entity still forces mdi:lock', () => {
|
||||
const h = mkHass({
|
||||
devices: {
|
||||
p: dev('p', 'Steckdose Küche', 'Plug S26', 'living'),
|
||||
l: dev('l', 'Home', 'S2', 'living'),
|
||||
},
|
||||
entities: { 'lock.front': { entity_id: 'lock.front', device_id: 'l', platform: 'demo' } },
|
||||
});
|
||||
const iconRules = compileIconRules([{ pattern: 'steckdose', icon: 'mdi:power-socket-eu' }]);
|
||||
const res = buildDevices(baseCtx(h, { iconRules }));
|
||||
assert.equal(res.find((d) => d.id === 'p').icon, 'mdi:power-socket-eu');
|
||||
assert.equal(res.find((d) => d.id === 'l').icon, 'mdi:lock');
|
||||
});
|
||||
|
||||
test('buildDevices: device_class fallback when no rule matches', () => {
|
||||
const h = mkHass({
|
||||
devices: { t: dev('t', 'XYZZY-42', 'unknown', 'living') },
|
||||
entities: { 'sensor.x': { entity_id: 'sensor.x', device_id: 't', platform: 'demo' } },
|
||||
states: { 'sensor.x': { state: '21.5', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } } },
|
||||
});
|
||||
const it = buildDevices(baseCtx(h))[0];
|
||||
assert.equal(it.icon, 'mdi:thermometer');
|
||||
assert.equal(it.temp, 21.5);
|
||||
assert.equal(it.primary, 'sensor.x');
|
||||
});
|
||||
|
||||
test('primaryEntity: domain priority and diagnostic-category demotion', () => {
|
||||
const h = mkHass({
|
||||
entities: {
|
||||
'sensor.batt': { entity_id: 'sensor.batt', entity_category: 'diagnostic' },
|
||||
'light.a': { entity_id: 'light.a' },
|
||||
'sensor.temp': { entity_id: 'sensor.temp' },
|
||||
},
|
||||
states: {},
|
||||
});
|
||||
assert.equal(primaryEntity(h, ['sensor.batt', 'sensor.temp', 'light.a'], 'mdi:lightbulb'), 'light.a');
|
||||
// only a diagnostic entity → still usable as a last resort
|
||||
assert.equal(primaryEntity(h, ['sensor.batt'], 'mdi:chip'), 'sensor.batt');
|
||||
});
|
||||
|
||||
test('lqiFor: dedicated sensor wins over attribute duplication; tempFor rounds', () => {
|
||||
const h = mkHass({ states: {
|
||||
'sensor.x_linkquality': { state: '120', attributes: { unit_of_measurement: 'lqi' } },
|
||||
'sensor.temp': { state: '22.46', attributes: { device_class: 'temperature', linkquality: 118 } },
|
||||
}});
|
||||
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');
|
||||
});
|
||||
|
||||
test('areaTemp: averages device temperatures, null when none report', () => {
|
||||
const hass = mkHass({ states: {
|
||||
'sensor.t1': { state: '20.0', attributes: { device_class: 'temperature' } },
|
||||
'sensor.t2': { state: '23.1', attributes: { device_class: 'temperature' } },
|
||||
'sensor.hum': { state: '55', attributes: { device_class: 'humidity' } },
|
||||
}});
|
||||
const devs = [
|
||||
{ area: 'living', entities: ['sensor.t1'] },
|
||||
{ area: 'living', entities: ['sensor.t2'] },
|
||||
{ area: 'bath', entities: ['sensor.hum'] },
|
||||
];
|
||||
assert.equal(areaTemp(hass, devs, 'living'), 21.6); // (20+23.1)/2=21.55 → 21.6
|
||||
assert.equal(areaTemp(hass, devs, 'bath'), null);
|
||||
assert.equal(areaTemp(hass, devs, 'garage'), null);
|
||||
});
|
||||
@@ -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,
|
||||
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
|
||||
} from '../test-build/logic.js';
|
||||
import {
|
||||
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
||||
@@ -206,3 +206,55 @@ test('floorsOf: sorts by level, tolerates missing registry and odd entries', ()
|
||||
const res = floorsOf(hass);
|
||||
assert.deepEqual(res.map((f) => f.id), ['ground', 'attic', 'x']);
|
||||
});
|
||||
|
||||
test('subst: replaces every occurrence of a placeholder, ignores unknown', () => {
|
||||
assert.equal(subst('{n} of {n} ({x})', { n: 2, x: 'y' }), '2 of 2 (y)');
|
||||
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);
|
||||
});
|
||||
|
||||
test('roomFillColor temp: blue/green/yellow bands, swapped bounds tolerated, no reading → no fill', () => {
|
||||
assert.equal(roomFillColor('temp', null, 'none', 18, 20, 25), '#4fc3f7'); // cold
|
||||
assert.equal(roomFillColor('temp', null, 'none', 20, 20, 25), '#66d17a'); // lower bound inclusive
|
||||
assert.equal(roomFillColor('temp', null, 'none', 25, 20, 25), '#66d17a'); // upper bound inclusive
|
||||
assert.equal(roomFillColor('temp', null, 'none', 26.5, 20, 25), '#ffd45c'); // hot
|
||||
assert.equal(roomFillColor('temp', null, 'none', 18, 25, 20), '#4fc3f7'); // swapped bounds
|
||||
assert.equal(roomFillColor('temp', null, 'none', null, 20, 25), null);
|
||||
assert.equal(roomFillColor('temp', null, 'none', undefined, 20, 25), null);
|
||||
});
|
||||
|
||||
test('spaceDisplayOf: temp bounds default to 20..25 and accept overrides', () => {
|
||||
const d = spaceDisplayOf({ plan_url: '/x.svg' });
|
||||
assert.equal(d.tempMin, 20);
|
||||
assert.equal(d.tempMax, 25);
|
||||
const o = spaceDisplayOf({ settings: { temp_min: 18.5, temp_max: 23 } });
|
||||
assert.equal(o.tempMin, 18.5);
|
||||
assert.equal(o.tempMax, 23);
|
||||
});
|
||||
|
||||
@@ -97,3 +97,31 @@ 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)
|
||||
|
||||
|
||||
def test_space_temp_bounds():
|
||||
"""Temperature comfort bounds validate as floats; temp fill mode accepted."""
|
||||
ok = {
|
||||
"id": "f1", "title": "F", "aspect": 1.0, "view_box": [0, 0, 1, 1], "rooms": [],
|
||||
"settings": {"fill_mode": "temp", "temp_min": 19.5, "temp_max": "24"},
|
||||
}
|
||||
v.SPACE_SCHEMA(ok)
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
},
|
||||
"include": [
|
||||
"src/logic.ts",
|
||||
"src/rules.ts"
|
||||
"src/rules.ts",
|
||||
"src/devices.ts",
|
||||
"src/types.ts"
|
||||
]
|
||||
}
|
||||
|
||||