Compare commits

...
20 Commits
Author SHA1 Message Date
Matysh fabeab81aa docs+log v1.16.2 (issue #2): document correct card resource URL /houseplan_files/houseplan-card.js and warn against the /custom_components/... on-disk path (text/plain → 'Custom element doesn't exist'); integration logs the served URL + YAML-mode manual-add hint. README en+ru troubleshooting note. 2026-07-11 14:46:28 +03:00
Matysh 246de26122 fix v1.16.1: houseplan-space-card renders room fills exactly as configured on the full card (temp/signal/lights snapshot from hass); +shared areaLqi(). Still non-interactive, no state subscription. 2026-07-08 19:46:19 +03:00
Matysh 1847c7b8ed feat v1.16.0: new read-only houseplan-space-card (static single-space schematic, pointer-events:none, deep-link button) + #space=<id> deep-link in the full card. Shared space-geometry/space-render + module-level config-store cache. +6 unit tests, smoke_space_card & smoke_deeplink. Docs in same commit. 2026-07-08 19:38:31 +03:00
Matysh fbd3887e14 feat v1.15.6: room hover also reveals the border — keep stroke colour, hide via --room-stroke-op, so hover's stroke-opacity:1 shows the outline even when borders are off 2026-07-08 18:13:23 +03:00
Matysh 5f9cfb9e10 fix v1.15.5: room hover darkens the fill instead of always grey — scope legacy overlay/yard hover with :not(.styled) so filled rooms use brightness(0.78), unfilled stay grey 2026-07-08 17:56:48 +03:00
Matysh cc27a6b2fa fix v1.15.4: device icon glyph centering — real ha-icon is block+line-height (glyph sat ~1.8px low); .dev ha-icon now zero-line-height flex. Revert v1.15.3 border-box (shrank badge); anchor centered via margin. Demo stub made faithful; smoke guards glyph-in-badge. Verified live. 2026-07-08 17:44:41 +03:00
Matysh 927e7d7054 fix v1.15.3: center device icon badge on its anchor (box-sizing: border-box; 1px border no longer shifts it down-right). +demo/smoke_icon_center. Docs in same commit. 2026-07-08 17:05:51 +03:00
Matysh dc8cf3070d fix v1.15.2: average room temperature counts only thermometer/air-monitor devices; isTempEntity excludes chip (*_device_temperature) and diagnostic-category temps (+3 tests). Docs: CHANGELOG/STATUS/TESTING in same commit. 2026-07-08 16:42:16 +03:00
Matysh 3e8b1c3778 Merge ux-fixes: display-settings UX round v1.15.1 2026-07-08 14:17:51 +03:00
Matysh 72b2492554 fix+ux v1.15.1: display-settings round from live usage
- comfort bounds: parseFloat+isFinite guard (empty input collapsed a bound to 0
  and the auto-swap made 'comfort from 25' behave as 0-25 → green at 24°)
- avg room temperature in the room tooltip (what the temp fill actually uses)
- hover darkens the current fill instead of recoloring blue; grey when unfilled
- fill mode as a radio group with short labels; compact inline bounds inputs
- space dialog widened to 500px; TESTING.md updated in the same commit
2026-07-08 14:15:37 +03:00
Matysh 2ee0b2c352 Merge temp-fill: temperature room fill v1.15.0 2026-07-07 18:25:33 +03:00
Matysh 3eb5b4a14d feat v1.15.0: temperature room fill (blue/green/yellow) with editable comfort bounds
- fill_mode 'temp': below comfort → #4fc3f7, inside → #66d17a, above → #ffd45c;
  bounds default 20-25°C, editable in the space dialog (visible only for this
  mode), swapped bounds tolerated; rooms without a temperature reading unfilled
- areaTemp(): average of the area devices' temperatures
- tests: roomFillColor temp bands + bounds, spaceDisplayOf defaults, areaTemp,
  backend schema (fill_mode temp + temp_min/temp_max); smoke_temp_fill.mjs
- TESTING.md updated in the same commit (policy)
2026-07-07 18:23:22 +03:00
Matysh eaa722679f Merge space-settings: per-space display settings, hand-drawn spaces, TESTING.md v1.14.0 2026-07-07 13:44:16 +03:00
Matysh 4f8e98cdc7 feat v1.14.0: per-space display settings, hand-drawn spaces, manual-testing checklist
- space dialog 'Display' section: room borders/names toggles, color+opacity,
  fill by zigbee signal or lights (tri-state: on/off/no-lights)
- draggable room name labels persisted as layout rl_<roomId>
- 'no image, outline by hand' space source with canvas orientation; image
  optional now; switching an existing space to draw detaches its plan
- demo/ synthetic-home harness lives in the repo (serve.mjs + smokes + icons gen)
- docs/TESTING.md checklist (same-commit update policy); self-run found and
  fixed: plan_url not detached on image->draw, _stateClass crash on states
  without entity_id; perf measured (162 devices ~14ms build)
- backend: SPACE_DISPLAY_SCHEMA validation (+test)
2026-07-07 13:41:51 +03:00
Matysh 6c8b509da2 docs: hacs/default submission is PR #9004 (bot-accepted); #8995 was closed for not using the exact PR template 2026-07-06 09:22:59 +03:00
Matysh 4146e5998e docs: replace all README screenshots with the synthetic demo home (privacy) — real-house captures removed from the tree; drop unreferenced 01-plan.png 2026-07-06 07:43:46 +03:00
Matysh 34d073f484 chore v1.13.3: remove legacy assets/ with real-house plan sources (privacy; unused since server-side config) 2026-07-06 07:39:43 +03:00
Matysh fff7368696 Merge audit-3: fixes + buildDevices test suite v1.13.2 2026-07-06 03:39:33 +03:00
Matysh 37604fa17e fix+test v1.13.2: audit round 3
- 12-test buildDevices suite on a fake hass (curation, markers, groups, dedup,
  icon rules, lock override, device_class fallback, primary/LQI/temp)
- t() substitutes all placeholder occurrences (pure subst() + regression test)
- _saveConfigNow resyncs config on rev conflict before rethrowing
- pointercancel clears the long-press timer (phantom info card on touch)
- repairs.py: plan-file check re-runs after every config save
- test build compiles devices.ts/types.ts; fix-test-build.mjs adds .js to ESM imports
2026-07-06 03:37:30 +03:00
Matysh 8a4f1a3033 feat v1.13.1: distribution — synthetic-home demo GIF, issue templates, CONTRIBUTING, Discussions (phase 10) 2026-07-06 01:03:38 +03:00
68 changed files with 4877 additions and 334 deletions
+36
View File
@@ -0,0 +1,36 @@
name: Bug report
description: Something in House Plan does not work as expected
labels: [bug]
body:
- type: input
id: version
attributes:
label: House Plan version
placeholder: v1.13.0
validations:
required: true
- type: input
id: ha_version
attributes:
label: Home Assistant version
placeholder: "2026.6"
validations:
required: true
- type: textarea
id: what
attributes:
label: What happened / what did you expect?
description: Steps to reproduce help a lot.
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs / browser console errors
description: "Settings → System → Logs (search: houseplan) and the browser console (F12)."
render: text
- type: textarea
id: diagnostics
attributes:
label: Diagnostics
description: "Settings → Devices & services → House Plan → ⋯ → Download diagnostics (personal fields are redacted automatically)."
@@ -0,0 +1,14 @@
name: Feature request
description: An idea to make House Plan better
labels: [enhancement]
body:
- type: textarea
id: problem
attributes:
label: What problem would this solve?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: How do you imagine it working?
+39
View File
@@ -0,0 +1,39 @@
# Contributing to House Plan
Thanks for your interest! The project is one HACS package: a storage **integration**
(`custom_components/houseplan/`, Python) and a **Lovelace card** (`src/`, TypeScript + Lit).
## Five-minute setup
```bash
git clone https://github.com/Matysh/houseplan-card && cd houseplan-card
npm ci # frontend toolchain
npm run typecheck # tsc --noEmit (strict)
npm test # node:test — pure logic, i18n parity, tap-action security
npm run build # tsc + rollup → dist/houseplan-card.js
pip install pytest voluptuous && python -m pytest tests_backend -q # pure backend tests
```
The HA-harness backend tests (`tests_backend/test_ha_*.py`) need Python ≥3.13 and
`pytest-homeassistant-custom-component home-assistant-frontend`; CI runs them on
every push — locally they are skipped when `homeassistant` is not importable.
## Ground rules
- **Docs in the same commit**: CHANGELOG entry for user-visible changes;
`docs/STATUS.md` for state changes; `docs/DEVELOPMENT.md` for new gotchas.
- Every UI string goes through `src/i18n/<lang>.json` (tests enforce en/ru key parity).
Adding a language = adding one JSON file + registering it in `src/i18n.ts`.
- The built card must be committed in sync: `cp dist/houseplan-card.js
custom_components/houseplan/frontend/` (CI compares them byte-for-byte).
- Tap actions have a security model (locks/alarms never toggle from the plan) —
see `resolveTapAction` in `src/logic.ts`; don't weaken it.
- Follow the Integration Quality Scale where applicable —
`custom_components/houseplan/quality_scale.yaml` tracks the self-assessment.
## Architecture
Start with `docs/ARCHITECTURE.md` (data model, WS API, coordinate system) and
`docs/STATUS.md` (current state). Release: bump the version in `package.json`,
`manifest.json`, `const.py`, `CARD_VERSION`, tag `vX.Y.Z`, publish a GitHub release —
the workflow attaches the card bundle.
+16 -1
View File
@@ -2,7 +2,7 @@
**A live map of your home right inside Home Assistant: floors, rooms and devices on a real floor plan — with live states, temperature and signal strength. Everything is configured with the mouse, without a single line of YAML.**
![Interactive house plan with devices](docs/images/01-plan.png)
![House Plan demo](docs/images/demo.gif)
🇷🇺 [Документация на русском](README.ru.md)
@@ -60,6 +60,21 @@ Key advantages in short:
The card is registered automatically — no need to add a Lovelace resource manually.
> **Card doesn't load (`Custom element doesn't exist: houseplan-card`) or you manage Lovelace
> resources in YAML?** Add the resource manually pointing at the URL the integration *serves*:
>
> ```yaml
> resources:
> - url: /houseplan_files/houseplan-card.js
> type: module
> ```
>
> Do **not** use `/custom_components/houseplan/frontend/houseplan-card.js` — that is the file
> on disk, which Home Assistant does not serve over HTTP (you'll get a `text/plain` MIME error
> and the element never registers). The correct, integration-served URL is
> `/houseplan_files/houseplan-card.js`. Both cards (`houseplan-card` and
> `houseplan-space-card`) ship in that one file — no separate resource is needed.
### Manually
1. Copy the `custom_components/houseplan` folder into the `config/custom_components` directory of your Home Assistant.
+16 -1
View File
@@ -2,7 +2,7 @@
**Живая карта вашего дома прямо в Home Assistant: этажи, комнаты и устройства на настоящем плане — с реальными состояниями, температурой и уровнем сигнала. Всё настраивается мышкой, без единой строчки YAML.**
![Интерактивный план дома с устройствами](docs/images/01-plan.png)
![House Plan demo](docs/images/demo.gif)
🇬🇧 [English documentation](README.md)
@@ -60,6 +60,21 @@ House Plan показывает ваш умный дом так, как он в
Карточка подключается автоматически — добавлять ресурс Lovelace вручную не нужно.
> **Карточка не грузится (`Custom element doesn't exist: houseplan-card`) или вы ведёте ресурсы
> Lovelace в YAML?** Добавьте ресурс вручную, указав URL, который *раздаёт сама интеграция*:
>
> ```yaml
> resources:
> - url: /houseplan_files/houseplan-card.js
> type: module
> ```
>
> **Не** используйте `/custom_components/houseplan/frontend/houseplan-card.js` — это путь к файлу
> на диске, который Home Assistant не отдаёт по HTTP (получите ошибку MIME `text/plain`, и элемент
> не зарегистрируется). Правильный URL, который раздаёт интеграция, — `/houseplan_files/houseplan-card.js`.
> Обе карточки (`houseplan-card` и `houseplan-space-card`) лежат в этом одном файле — отдельный ресурс
> не нужен.
### Вручную
1. Скопируйте папку `custom_components/houseplan` в каталог `config/custom_components` вашего Home Assistant.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 138 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 76 KiB

+17 -41
View File
@@ -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__)
@@ -79,48 +79,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
# start of the mobile app). If the resource registry is unavailable (YAML-mode
# Lovelace, old versions) — fall back to extra_module_url.
module_url = f"{FRONTEND_URL}?v={VERSION}"
if not await _register_lovelace_resource(hass, module_url):
registered = await _register_lovelace_resource(hass, module_url)
if not registered:
add_extra_js_url(hass, module_url)
await _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},
# Tell the user exactly where the card lives — the #1 support issue is people adding a
# Lovelace resource pointing at the on-disk path (/custom_components/...), which HA does
# not serve (wrong MIME → "Custom element doesn't exist"). The correct served URL is below.
if registered:
_LOGGER.info("House Plan card auto-registered as a Lovelace resource: %s", module_url)
else:
_LOGGER.info(
"House Plan card is served at %s . Lovelace resources look YAML-managed — add it "
"manually under `resources:` as { url: %s, type: module }. Do NOT use the on-disk "
"path /custom_components/houseplan/frontend/houseplan-card.js (HA does not serve it).",
module_url, module_url,
)
# 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}")
await async_check_plan_files(hass, entry)
return True
async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
+1 -1
View File
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
FILES_URL = "/houseplan_files/files"
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.13.0"
VERSION = "1.16.2"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.13.0"
"version": "1.16.2"
}
+52
View File
@@ -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}")
+14
View File
@@ -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})
+17
View File
@@ -0,0 +1,17 @@
# Synthetic demo home
A fully fictional house (plans, devices, states) used for README screenshots,
the demo GIF and headless smoke tests — so no real home data ever appears in
public materials.
- `srv/demo.html` — self-contained host page: `<ha-icon>`/`<ha-card>` stubs and a
fake `hass` (registries, states, `callWS`, `callService`, floors).
- `srv/assets/` — generated plan SVGs and `icons.js` (`node demo/gen_icons.mjs`,
needs the repo's devDependencies). The card bundle is copied from `dist/`:
`cp dist/houseplan-card.js demo/srv/assets/`.
- `serve.mjs` — playwright launcher (route interception, no web server).
- `smoke_*.mjs` — feature smoke tests; run with a Chromium installed via
`PLAYWRIGHT_BROWSERS_PATH=<dir> npx playwright install chromium-headless-shell`.
Note for sandboxed sessions: `/tmp` does not survive; this directory is the
persistent home of the harness (docs/DEVELOPMENT.md has the LD_LIBRARY_PATH recipe).
+22
View File
@@ -0,0 +1,22 @@
// Generate demo/srv/assets/icons.js: an { "mdi:name": "<svg path>" } map for every
// mdi: icon referenced in src/ and demo/ (the demo host stubs <ha-icon> with it).
import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
import * as mdi from '@mdi/js';
const names = new Set();
const scan = (dir) => {
for (const f of readdirSync(dir, { withFileTypes: true })) {
if (f.isDirectory()) { scan(`${dir}/${f.name}`); continue; }
if (!/\.(ts|json|html|mjs)$/.test(f.name) || f.name === 'icons.js') continue;
const txt = readFileSync(`${dir}/${f.name}`, 'utf8');
for (const m of txt.matchAll(/mdi:([a-z0-9-]+)/g)) names.add(m[1]);
}
};
scan('src'); scan('demo');
const map = {};
for (const n of [...names].sort()) {
const camel = 'mdi' + n.replace(/(^|-)(\w)/g, (_, __, c) => c.toUpperCase());
if (mdi[camel]) map['mdi:' + n] = mdi[camel];
}
writeFileSync('demo/srv/assets/icons.js', 'window.__ICONS=' + JSON.stringify(map) + ';\n');
console.log('icons:', Object.keys(map).length, 'of', names.size, 'referenced');
+25
View File
@@ -0,0 +1,25 @@
// Репро №3: пользовательский путь — открыть диалог, выставить «комфорт от 25», сохранить
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// living room: единственный термометр 22.4 → подменим на 24.0 как у пользователя
const h = window.__mkHass();
h.states = { ...h.states, 'sensor.living_temp': { ...h.states['sensor.living_temp'], state: '24.0' } };
c.hass = h; c._regSignature=''; c._maybeRebuildDevices(); await c.updateComplete;
// путь пользователя: диалог → режим temp, min=25 (макс не трогаем) → сохранить
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
out.dialogInit = { min: c._spaceDialog.tempMin, max: c._spaceDialog.tempMax, fill: c._spaceDialog.fillMode };
c._spaceDialog = { ...c._spaceDialog, fillMode: 'temp', tempMin: 25 };
await c._saveSpaceDialog(); await c.updateComplete;
const sp = c._serverCfg.spaces.find(s=>s.id==='f1');
out.savedSettings = sp.settings;
const styles = [...sr().querySelectorAll('.room.styled')].map(r => (r.getAttribute('style')||'').match(/--room-fill:([^;]+)/)?.[1]);
out.livingFill = styles[0];
out.expected = '#4fc3f7 (blue: 24 < 25)';
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+28
View File
@@ -0,0 +1,28 @@
// Shared launcher for demo captures: starts headless Chromium serving demo/srv/
// via request interception (no HTTP server needed). Usage: const {page,browser}=await launch();
import { chromium } from 'playwright';
import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const ROOT = dirname(fileURLToPath(import.meta.url)) + '/srv';
const CT = { '.html': 'text/html', '.js': 'text/javascript', '.svg': 'image/svg+xml' };
export async function launch(viewport = { width: 820, height: 760 }, scale = 1) {
const browser = await chromium.launch({ args: ['--no-sandbox'] });
const page = await (await browser.newContext({ viewport, deviceScaleFactor: scale })).newPage();
page.on('pageerror', (e) => console.log('EXC', e.message));
await page.route('**/*', (r) => {
const u = new URL(r.request().url());
let p = decodeURIComponent(u.pathname);
if (p === '/') p = '/demo.html';
const f = ROOT + p;
existsSync(f)
? r.fulfill({ status: 200, headers: { 'content-type': CT[p.slice(p.lastIndexOf('.'))] || 'application/octet-stream' }, body: readFileSync(f) })
: r.fulfill({ status: 404, body: 'nf' });
});
await page.goto('http://demo.local/demo.html', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__card?._model?.length > 0, { timeout: 9000 });
// hass flows continuously in production; the stub sets it once — nudge a rebuild
await page.evaluate(() => { const c = window.__card; c.hass = { ...c.hass }; });
await page.waitForFunction(() => window.__card._devices.length > 0, { timeout: 9000 });
return { page, browser };
}
+26
View File
@@ -0,0 +1,26 @@
// Smoke: the full houseplan-card honours the #space=<id> deep-link (hashchange).
import { launch } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 }, 1);
const res = await page.evaluate(async () => {
const card = window.__card;
const ids = card._model.map((s) => s.id);
const target = ids[1] || ids[0];
const before = card._space;
// simulate a deep-link arriving on the already-open dashboard
window.location.hash = '#space=' + target;
window.dispatchEvent(new HashChangeEvent('hashchange'));
await card.updateComplete;
const after = card._space;
// invalid hash must be ignored
window.location.hash = '#space=__nope__';
window.dispatchEvent(new HashChangeEvent('hashchange'));
await card.updateComplete;
const afterBad = card._space;
window.location.hash = '';
return { ids, before, target, after, afterBad };
});
await browser.close();
const ok = res.after === res.target && res.afterBad === res.target;
console.log(JSON.stringify(res));
if (!ok) { console.error('FAIL deeplink smoke'); process.exit(1); }
console.log('OK deep-link: full card switches to #space=<id>, ignores invalid ids');
+74
View File
@@ -0,0 +1,74 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const mk = () => document.createElement('houseplan-card');
const sr = (c) => c.shadowRoot || c.renderRoot;
// 1) пустая инсталляция: ноль устройств/зон, конфиг с пространством без комнат
const c1 = mk();
c1.setConfig({ type: 'custom:houseplan-card' });
document.body.appendChild(c1);
c1.hass = { language:'en', locale:{language:'en'}, devices:{}, entities:{}, areas:{}, states:{},
callWS: async (m) => m.type==='houseplan/config/get'
? { config:{ spaces:[{ id:'s1', title:'Empty', plan_url:null, aspect:1.4, view_box:[0,0,1,1], rooms:[], segments:[] }], markers:[], settings:{} }, rev:1 }
: { layout:{} },
connection:{ subscribeEvents: async()=>()=>{} } };
await new Promise(r=>setTimeout(r,150));
c1.hass = { ...c1.hass };
await c1.updateComplete; await new Promise(r=>setTimeout(r,50)); await c1.updateComplete;
out.emptyDevices = c1._devices.length;
out.emptyRenders = !!sr(c1).querySelector('.stage');
out.emptyCount = sr(c1).querySelector('.count')?.textContent.trim();
c1.remove();
// 2) XSS в именах комнат и устройств
const c2 = mk();
c2.setConfig({ type: 'custom:houseplan-card' });
document.body.appendChild(c2);
const evil = '<img src=x onerror=window.__pwned=1><b>bold</b>';
c2.hass = { language:'en', locale:{language:'en'},
devices:{ d1:{ id:'d1', name: evil, model:'M<script>1</script>', area_id:'a1', identifiers:[['x','1']] } },
entities:{}, areas:{ a1:{ area_id:'a1', name:'A1' } }, states:{},
callWS: async (m) => m.type==='houseplan/config/get'
? { config:{ spaces:[{ id:'s1', title:'S', plan_url:null, aspect:1, view_box:[0,0,1,1],
rooms:[{ id:'r1', name: evil, area:'a1', poly:[[0.1,0.1],[0.9,0.1],[0.9,0.9],[0.1,0.9]] }], segments:[] }], markers:[], settings:{} }, rev:1 }
: { layout:{} },
connection:{ subscribeEvents: async()=>()=>{} } };
await new Promise(r=>setTimeout(r,150));
c2.hass = { ...c2.hass };
await c2.updateComplete; await new Promise(r=>setTimeout(r,50)); await c2.updateComplete;
out.xssPwned = !!window.__pwned;
const lbl = sr(c2).querySelector('.roomlabel');
out.xssLabelIsText = lbl ? lbl.innerHTML.includes('&lt;img') || lbl.textContent.includes('<img') : 'no label';
out.xssDeviceRendered = sr(c2).querySelectorAll('.dev').length;
c2.remove();
// 3) легаси-записи layout v1 {x,y} без ключа s — игнорируются в norm-режиме
const c = window.__card;
c._layout = { ...c._layout, d_light1_legacy_test: { x: 500, y: 300 } };
const fake = { id: 'd_light1_legacy_test', space: 'f1' };
const p = c._pos(fake);
out.legacyIgnored = p.y !== 300; // centre y=400 if ignored; raw 300 would mean it leaked through
delete c._layout.d_light1_legacy_test;
// 4) перф: 150 устройств
const t0 = performance.now();
const devices = {}; const entities = {}; const states = {};
for (let i = 0; i < 150; i++) {
devices['p'+i] = { id:'p'+i, name:'Plug '+i, model:'Smart Plug', area_id:'living_room', identifiers:[['demo','p'+i]] };
entities['switch.p'+i] = { entity_id:'switch.p'+i, device_id:'p'+i, platform:'demo' };
states['switch.p'+i] = { state: i%2?'on':'off', attributes:{ linkquality: 50+i } };
}
const bigHass = { ...window.__mkHass(), devices:{...window.__mkHass().devices, ...devices},
entities:{...window.__mkHass().entities, ...entities}, states:{...window.__mkHass().states, ...states} };
c.hass = bigHass; c._regSignature=''; c._maybeRebuildDevices();
out.bigBuildMs = Math.round(performance.now() - t0);
out.bigCount = c._devices.length;
await c.updateComplete;
const t1 = performance.now(); c.requestUpdate(); await c.updateComplete;
out.bigRenderMs = Math.round(performance.now() - t1);
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+28
View File
@@ -0,0 +1,28 @@
// Asserts device icon badges sit exactly on their anchor point (no content-box/border drift).
import { launch } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 1000 }, 2);
const bad = await page.evaluate(() => {
const c = window.__card;
c._space = 'f1'; c._resetZoom(); c.requestUpdate();
const layer = c.renderRoot.querySelector('.devlayer');
const lr = layer.getBoundingClientRect();
const bad = [];
for (const d of layer.querySelectorAll('.dev')) {
const ax = lr.left + (parseFloat(d.style.left) / 100) * lr.width;
const ay = lr.top + (parseFloat(d.style.top) / 100) * lr.height;
const r = d.getBoundingClientRect();
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
const offX = cx - ax, offY = cy - ay;
// glyph centred within the badge
const icon = d.querySelector('ha-icon');
const svg = icon?.shadowRoot?.querySelector('svg') || icon?.shadowRoot?.querySelector('ha-svg-icon')?.shadowRoot?.querySelector('svg');
let gX = 0, gY = 0;
if (svg) { const sr = svg.getBoundingClientRect(); gX = sr.left + sr.width / 2 - cx; gY = sr.top + sr.height / 2 - cy; }
if (Math.abs(offX) > 0.6 || Math.abs(offY) > 0.6 || Math.abs(gX) > 0.6 || Math.abs(gY) > 0.6)
bad.push({ icon: icon?.getAttribute('icon'), offX: +offX.toFixed(2), offY: +offY.toFixed(2), glyphX: +gX.toFixed(2), glyphY: +gY.toFixed(2) });
}
return bad;
});
await browser.close();
if (bad.length) { console.error('FAIL icon-center: badges off anchor', JSON.stringify(bad.slice(0, 5))); process.exit(1); }
console.log('OK icon-center: all device badges centred on their anchor');
+53
View File
@@ -0,0 +1,53 @@
// Smoke: houseplan-space-card renders a static, non-interactive schematic + deep-link button.
import { launch } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
const res = await page.evaluate(async () => {
await customElements.whenDefined('houseplan-space-card');
const hass = window.__card.hass;
const spaceId = window.__card._model[0].id;
const host = document.createElement('div');
document.body.appendChild(host);
const mk = (cfg) => { const el = document.createElement('houseplan-space-card'); el.setConfig(cfg); el.hass = hass; host.appendChild(el); return el; };
const card = mk({ type: 'custom:houseplan-space-card', space: spaceId, button_target: '/plan-doma' });
// wait for the static stage to render (config loads via WS)
const t0 = Date.now();
while (!card.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) { await new Promise(r => setTimeout(r, 80)); }
await card.updateComplete;
const stage = card.renderRoot.querySelector('.hp-static-stage');
const pe = stage ? getComputedStyle(stage).pointerEvents : null;
const markers = card.renderRoot.querySelectorAll('.hp-static-stage .devlayer .dev').length;
const btn = card.renderRoot.querySelector('.hp-static-btn');
// deep-link: clicking the button pushes #space=<id>
let pushed = null;
const orig = history.pushState;
history.pushState = function (a, b, url) { pushed = url; return orig.apply(this, arguments); };
btn?.click();
history.pushState = orig;
// error card for an unknown space
const bad = mk({ type: 'custom:houseplan-space-card', space: '__nope__' });
await bad.updateComplete;
const errCard = bad.renderRoot.querySelector('.hp-static-error');
return {
stagePointerEvents: pe,
markers,
hasButton: !!btn,
deepLink: pushed,
errorShown: !!errCard,
errorText: errCard?.textContent?.trim() || null,
};
});
await browser.close();
const ok =
res.stagePointerEvents === 'none' &&
res.markers > 0 &&
res.hasButton &&
typeof res.deepLink === 'string' && res.deepLink.includes('#space=') &&
res.errorShown;
console.log(JSON.stringify(res));
if (!ok) { console.error('FAIL space-card smoke'); process.exit(1); }
console.log('OK space-card: static (pointer-events:none), markers rendered, deep-link button, error card');
+50
View File
@@ -0,0 +1,50 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// 1) дефолт (план есть): границ и лейблов нет
out.defaultStyled = sr().querySelectorAll('.room.styled').length;
out.defaultLabels = sr().querySelectorAll('.roomlabel').length;
// 2) включаем границы+имена+заливку по свету
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : {
...s, settings: { show_borders: true, show_names: true, room_color: '#ff8800', room_opacity: 0.8, fill_mode: 'light' },
})};
c.requestUpdate(); await c.updateComplete;
out.styled = sr().querySelectorAll('.room.styled').length;
out.labels = [...sr().querySelectorAll('.roomlabel')].map((l) => l.textContent.trim());
const liv = [...sr().querySelectorAll('.room.styled')][0];
out.livingStyle = liv.getAttribute('style');
// living: ceiling on → жёлтая; kitchen: нет light-сущностей → без заливки; bedroom light off → серая
const styles = [...sr().querySelectorAll('.room.styled')].map((r) => r.getAttribute('style'));
out.hasYellow = styles.some((s) => s.includes('#ffd45c'));
out.hasGrey = styles.some((s) => s.includes('#9aa0a6'));
out.kitchenNoFill = styles.some((s) => s.includes('--room-fill:transparent'));
// 3) lqi-заливка
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : {
...s, settings: { ...s.settings, fill_mode: 'lqi' },
})};
c.requestUpdate(); await c.updateComplete;
out.lqiFills = [...sr().querySelectorAll('.room.styled')].filter((r) => (r.getAttribute('style') || '').includes('hsl(')).length;
// 4) drag лейбла → layout rl_
const lbl = sr().querySelector('.roomlabel');
c._labelDown({ preventDefault(){}, stopPropagation(){}, clientX: 100, clientY: 100, target: { setPointerCapture(){} }, pointerId: 5 },
c._spaceModel().rooms[0], 'f1');
c._labelMove({ clientX: 160, clientY: 140 }, c._spaceModel().rooms[0], 'f1');
c._labelUp(c._spaceModel().rooms[0]);
out.labelSaved = !!c._layout['rl_r1'];
// 5) диалог: create + draw
c._openSpaceDialog('create'); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'Attic', source: 'draw', orientation: 'square' };
await c.updateComplete;
out.saveEnabled = !sr().querySelector('.dialog .btn.on[disabled]');
await c._saveSpaceDialog(); await c.updateComplete;
const attic = c._serverCfg.spaces.find((s) => s.title === 'Attic');
out.atticAspect = attic?.aspect;
out.atticSettings = attic?.settings;
out.atticNoPlan = attic ? attic.plan_url === null : null;
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+30
View File
@@ -0,0 +1,30 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const setFill = async (settings) => {
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({ ...s, settings })) };
c.requestUpdate(); await c.updateComplete;
return [...sr().querySelectorAll('.room.styled')].map((r) => {
const st = r.getAttribute('style') || '';
const m = st.match(/--room-fill:([^;]+)/);
return m ? m[1] : null;
});
};
// living room avg temp = 22.4 (единственный термометр в demo)
out.comfy = await setFill({ show_borders: true, fill_mode: 'temp', temp_min: 20, temp_max: 25 });
out.cold = await setFill({ show_borders: true, fill_mode: 'temp', temp_min: 23, temp_max: 26 });
out.hot = await setFill({ show_borders: true, fill_mode: 'temp', temp_min: 18, temp_max: 21 });
out.swapped = await setFill({ show_borders: true, fill_mode: 'temp', temp_min: 25, temp_max: 20 });
// диалог: поля границ видны только в режиме temp
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
out.dialogTempFields = sr().querySelectorAll('.tempin').length;
c._spaceDialog = { ...c._spaceDialog, fillMode: 'none' }; await c.updateComplete;
out.dialogHiddenWhenNone = sr().querySelectorAll('.tempin').length;
c._spaceDialog = null;
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+38
View File
@@ -0,0 +1,38 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch({ width: 640, height: 980 }, 2);
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
c.hass = { ...c.hass, locale: { language: 'ru' } };
await c.updateComplete;
// заливка temp вкл → класс filled и тултип с температурой
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({ ...s,
settings: { show_borders: true, show_names: true, fill_mode: 'temp', temp_min: 20, temp_max: 25 } })) };
c._regSignature=''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
out.filledClass = sr().querySelectorAll('.room.styled.filled').length; // только living (термометр)
out.unfilled = sr().querySelectorAll('.room.styled:not(.filled)').length;
// тултип комнаты: средняя температура
const room = sr().querySelector('.room');
room.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, composed: true, clientX: 200, clientY: 200 }));
await c.updateComplete;
out.tipTemp = c._tip?.temp;
out.tipHasTempLine = (sr().querySelector('.tip')?.textContent || '').includes('средняя температура');
c._tip = null;
// диалог: радио заливки, компактные поля, ширина
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
out.fillRadios = sr().querySelectorAll('input[name="fillmode"]').length;
out.tempInputs = sr().querySelectorAll('.temprange .tempin').length;
out.dialogWide = !!sr().querySelector('.dialog.wide .srcrow');
out.dialogWidth = Math.round(sr().querySelector('.dialog').getBoundingClientRect().width);
// NaN-защита: пустой ввод не ломает границы
const before = c._spaceDialog.tempMax;
const fakeEv = { target: { value: '' } };
// симулируем input с пустым значением через обработчик радио-строки — парсер должен сохранить старое
const n = parseFloat('');
out.nanGuard = !Number.isFinite(n) && c._spaceDialog.tempMax === before;
return out;
});
await page.screenshot({ path: '/tmp/ux_dialog.png' });
console.log(JSON.stringify(res, null, 1));
await browser.close();
+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 800">
<rect width="1000" height="800" fill="#f4f1ea"/>
<g opacity="0.35" stroke="#d9d2c4" stroke-width="1"><line x1="0" y1="0" x2="0" y2="800"/><line x1="50" y1="0" x2="50" y2="800"/><line x1="100" y1="0" x2="100" y2="800"/><line x1="150" y1="0" x2="150" y2="800"/><line x1="200" y1="0" x2="200" y2="800"/><line x1="250" y1="0" x2="250" y2="800"/><line x1="300" y1="0" x2="300" y2="800"/><line x1="350" y1="0" x2="350" y2="800"/><line x1="400" y1="0" x2="400" y2="800"/><line x1="450" y1="0" x2="450" y2="800"/><line x1="500" y1="0" x2="500" y2="800"/><line x1="550" y1="0" x2="550" y2="800"/><line x1="600" y1="0" x2="600" y2="800"/><line x1="650" y1="0" x2="650" y2="800"/><line x1="700" y1="0" x2="700" y2="800"/><line x1="750" y1="0" x2="750" y2="800"/><line x1="800" y1="0" x2="800" y2="800"/><line x1="850" y1="0" x2="850" y2="800"/><line x1="900" y1="0" x2="900" y2="800"/><line x1="950" y1="0" x2="950" y2="800"/><line x1="0" y1="0" x2="1000" y2="0"/><line x1="0" y1="50" x2="1000" y2="50"/><line x1="0" y1="100" x2="1000" y2="100"/><line x1="0" y1="150" x2="1000" y2="150"/><line x1="0" y1="200" x2="1000" y2="200"/><line x1="0" y1="250" x2="1000" y2="250"/><line x1="0" y1="300" x2="1000" y2="300"/><line x1="0" y1="350" x2="1000" y2="350"/><line x1="0" y1="400" x2="1000" y2="400"/><line x1="0" y1="450" x2="1000" y2="450"/><line x1="0" y1="500" x2="1000" y2="500"/><line x1="0" y1="550" x2="1000" y2="550"/><line x1="0" y1="600" x2="1000" y2="600"/><line x1="0" y1="650" x2="1000" y2="650"/><line x1="0" y1="700" x2="1000" y2="700"/><line x1="0" y1="750" x2="1000" y2="750"/></g>
<rect x="40" y="40" width="920" height="720" fill="none" stroke="#3b4753" stroke-width="14"/>
<line x1="550" y1="40" x2="550" y2="300" stroke="#3b4753" stroke-width="10"/>
<line x1="550" y1="380" x2="550" y2="620" stroke="#3b4753" stroke-width="10"/>
<line x1="550" y1="700" x2="550" y2="760" stroke="#3b4753" stroke-width="10"/>
<line x1="550" y1="360" x2="820" y2="360" stroke="#3b4753" stroke-width="10"/>
<line x1="900" y1="360" x2="960" y2="360" stroke="#3b4753" stroke-width="10"/>
<line x1="40" y1="480" x2="300" y2="480" stroke="#3b4753" stroke-width="10"/>
<line x1="380" y1="480" x2="550" y2="480" stroke="#3b4753" stroke-width="10"/>
<rect x="120" y="46" width="180" height="10" fill="#a8c6e8"/>
<rect x="640" y="46" width="140" height="10" fill="#a8c6e8"/>
<rect x="700" y="744" width="140" height="10" fill="#a8c6e8"/>
<text x="295" y="270" font-family="Arial" font-size="30" letter-spacing="4" fill="#8b95a1" text-anchor="middle" opacity="0.55">LIVING ROOM</text>
<text x="775" y="200" font-family="Arial" font-size="30" letter-spacing="4" fill="#8b95a1" text-anchor="middle" opacity="0.55">KITCHEN</text>
<text x="775" y="560" font-family="Arial" font-size="30" letter-spacing="4" fill="#8b95a1" text-anchor="middle" opacity="0.55">BEDROOM</text>
<text x="295" y="640" font-family="Arial" font-size="30" letter-spacing="4" fill="#8b95a1" text-anchor="middle" opacity="0.55">HALLWAY</text>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

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

After

Width:  |  Height:  |  Size: 920 B

+1391
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+148
View File
@@ -0,0 +1,148 @@
<!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'});
// Faithful to HA's ha-icon: host is display:block with a large inherited line-height,
// which pushes the (smaller) svg off-centre unless the consumer neutralises it.
this.shadowRoot.innerHTML='<style>:host{display:block;line-height:1.8;'+
'width:var(--mdc-icon-size,24px);height:var(--mdc-icon-size,24px);'+
'fill:currentColor;vertical-align:middle}'+
'svg{width:var(--mdc-icon-size,24px);height:var(--mdc-icon-size,24px);fill:currentColor;'+
'display:inline-block;vertical-align:baseline}</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>
+323 -110
View File
File diff suppressed because one or more lines are too long
+30 -1
View File
@@ -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
```
@@ -132,3 +131,33 @@ rectangles are rendered uniformly (hit-test: point-in-polygon / rect).
**File uploads go over HTTP** (not WS, which has a message-size limit): `POST /api/houseplan/upload`
(multipart: marker_id + file), HomeAssistantView, requires_auth. Served from `/houseplan_files/files/`.
## Second card: houseplan-space-card (read-only, v1.16.0)
The bundle registers **two** custom elements from one entry (`src/houseplan-card.ts`
imports `./space-card`):
- `houseplan-card` — the full interactive card.
- `houseplan-space-card` — a static, read-only schematic of ONE space for embedding.
Shared, framework-light modules keep the two views from diverging:
- `src/space-geometry.ts` — pure model/position math (`spaceModels`, `roomBounds`,
`roomCenter`, `defaultPositions`, `markerPos`, `labelPos`; no Lit import) — unit-tested,
mirrors the full card's private geometry.
- `src/space-render.ts` — `renderSpaceStatic()` draws the plan + configured room
borders/names + device markers (via `buildDevices`, same curation) with NO handlers,
NO live states, NO status/temperature fills. Uses the same CSS classes as the full card
(the space-card imports `cardStyles`) for visual parity.
- `src/config-store.ts` — module-level `{config, rev, layout}` cache shared by all embedded
cards (dedupes `houseplan/config/get`), seeded synchronously from the full card's
localStorage snapshot (`houseplan_card_cfg_v1`) and invalidated on `houseplan_config_updated`.
**Static contract:** the schematic layer (`.hp-static-stage`) is `pointer-events:none`; the
footer button lives outside it and stays clickable.
**Deep-link contract:** the footer button calls `navigate(button_target + "#space=<id>")`
(default target `/plan-doma`). The full card reads `#space=<id>` on load (a valid id wins over
`default_floor`) and on `hashchange`, without blocking manual space switching; an invalid/absent
hash falls back to the default.
+158
View File
@@ -1,5 +1,163 @@
# Changelog
## v1.16.2 — 2026-07-11 (docs+log: correct card resource URL — fixes "Custom element doesn't exist")
- **Support issue #2**: users adding a Lovelace resource that points at the on-disk path
`/custom_components/houseplan/frontend/houseplan-card.js` get a `text/plain` MIME error and
"Custom element doesn't exist: houseplan-card" — HA does not serve `custom_components/` over
HTTP. The integration serves the card at **`/houseplan_files/houseplan-card.js`** (verified:
`200 text/javascript`) and auto-registers it as a Lovelace resource in storage mode.
- README (en+ru) now documents the correct URL and the common mistake, incl. a YAML-mode
`resources:` snippet.
- On setup the integration logs (INFO) the exact served URL and, when Lovelace resources are
YAML-managed, how to add it manually — so the fix is discoverable from the logs.
## v1.16.1 — 2026-07-08 (space-card shows room fills as configured)
- **The static `houseplan-space-card` now renders room fills exactly as configured on the
full card** (temperature / signal / lights coloring), as a snapshot of the states passed
in via `hass` — reverting the v1.16.0 omission. The card still does not subscribe to state
changes itself and stays non-interactive (`pointer-events:none`); fills refresh when HA
hands the card an updated `hass`. Added shared `areaLqi()` in devices.ts.
## v1.16.0 — 2026-07-08 (new: houseplan-space-card + deep-link)
- **New second card `custom:houseplan-space-card`** — a READ-ONLY, static schematic of a
single space, embeddable on any dashboard. Draws the configured plan + room borders/names +
device markers at their saved positions, with **no interactivity** (the schematic layer is
`pointer-events:none` — no clicks/hover/tooltips/drag/more-info) and **no live states**
(no state subscription, no status/temperature fills). A footer button opens the space in the
full component via a **deep-link** (`#space=<id>`). Config: `space` (required), `title`,
`show_button`, `button_label`, `button_target`, `aspect_ratio`, `icon_size`; unknown space → a
tidy error card. GUI editor with a space dropdown from the integration config.
- **Deep-link in the full card**: on load it reads `#space=<id>` (valid id wins over
`default_floor`) and listens to `hashchange`, without blocking manual space switching.
- **Shared rendering**: pure geometry in `space-geometry.ts` (unit-tested), the static drawer in
`space-render.ts`, and a **module-level config cache** (`config-store.ts`, rev-keyed, seeded from
the full card's localStorage snapshot, invalidated on `houseplan_config_updated`) so N embedded
cards on one board share a single WS request. Both cards ship in the one bundle.
- Tests: 48 → 54 frontend (space geometry) + demo smokes `smoke_space_card` and `smoke_deeplink`.
- Note: status/temperature fills are intentionally omitted from the static card (they are live);
it shows configured room borders/names + neutral icons. Marker/position edits reflect after the
config event or a reload.
## v1.15.6 — 2026-07-08 (room hover also reveals the border)
- Hovering a room now **shows its outline** even when borders are turned off. The stroke
colour (`--room-stroke`) is now always set to the room colour and only hidden via
`--room-stroke-op` — so the existing `stroke-opacity: 1` on hover reveals a crisp border
(previously the stroke was `transparent` when borders were off, so hover showed nothing).
## v1.15.5 — 2026-07-08 (fix: room hover was always grey even when filled)
- Room hover now **darkens the current fill** for filled rooms and only greys **unfilled**
ones (as intended since v1.15.1). The legacy `.room.overlay:hover` / `.room.yard:hover`
grey rules were still matching styled rooms and, being applied to `fill` directly, beat
the `--room-fill` variable — so a temperature/light/zigbee-filled room turned grey on
hover. Scoped those legacy rules with `:not(.styled)`; styled rooms are now governed only
by `.styled.filled:hover` (brightness 0.78) and `.styled:not(.filled):hover` (grey).
## v1.15.4 — 2026-07-08 (fix: device icon vertical centering — proper root cause)
- **Root cause of the off-centre icon, found in the live app** (the demo stub hid it):
HA's real `<ha-icon>` host is `display:block` with a large inherited `line-height`
(~22 px for a ~12 px glyph), so the SVG sat ~1.8 px **below** the badge centre.
Fix: `.dev ha-icon` is now a zero-line-height flex box — the glyph centres exactly.
- Reverted the v1.15.3 `box-sizing: border-box` (it shrank the badge by 2 px and made the
vertical offset *more* visible — "worse"). The 1 px anchor drift is instead corrected by
the centering margin (`-(size/2 + 1px)`), keeping the original badge size.
- Verified in the real dashboard: anchor offset 0, glyph offset 0, badge size unchanged.
- Demo `ha-icon` stub made faithful to HA (block + line-height) so `smoke_icon_center`
now actually reproduces and guards this; the smoke also asserts glyph-in-badge centering.
## v1.15.3 — 2026-07-08 (fix: device icon 1px off its anchor point)
- Device icon badges were sitting **1 px down-and-right of their true point**: `.dev`
used the default `content-box`, so the 1 px border made the rendered square 2 px
wider than the width the centering margin assumed. Added `box-sizing: border-box` —
the badge centre now lands exactly on the device coordinate (verified in the demo:
anchor offset 1 px → 0). The glyph itself was already centred within the square.
## v1.15.2 — 2026-07-08 (fix: room average temperature counted non-thermometers)
- **Average room temperature now uses only devices the card treats as thermometers**
(`mdi:thermometer` / `mdi:air-filter`). Previously `areaTemp` swept every device in the
area through `tempFor`, so fridges, TRV heads and smart plugs leaked their readings into
the average (e.g. a 8.3 °C valve/appliance temperature dragging a room down).
- **`isTempEntity` now excludes chip/diagnostic temperatures**: `*_device_temperature`
sensors and any entity in the `config`/`diagnostic` category are no longer treated as a
room temperature — so even a genuine thermometer no longer reports its chip temperature.
- Affects the temperature room fill and the room tooltip average. (+3 frontend tests: 46 → 48.)
## v1.15.1 — 2026-07-06 (display-settings UX round from live usage)
- **Comfort-bounds input hardening**: clearing a temperature field can no longer
collapse a bound to 0 (`Number('') === 0` — this silently turned "comfort from
25" into a 0–25 range after the auto-swap, showing green at 24°). Inputs now
parse with `parseFloat` + `isFinite` guard and the save path falls back to the
defaults for non-finite values.
- Room tooltip now shows the **average room temperature** (what the temperature
fill actually uses — averages every thermometer in the area, including TRVs).
- **Hover no longer recolors rooms blue**: filled rooms darken their current fill
(`brightness(0.78)`), unfilled rooms get a light grey tint.
- Fill mode selector is a **radio group** with short labels (no color legend);
the comfort bounds sit compactly inline on the temperature row (56 px inputs).
- The space dialog is wider (500 px) — the settings no longer feel cramped.
## v1.15.0 — 2026-07-06 (temperature room fill)
- New room fill mode **"Temperature"**: light blue below the comfort range, green
inside it, warm yellow above. The comfort bounds (default 20–25 °C) are editable
right in the space dialog and appear only when the mode is selected; bounds
entered in the wrong order are swapped automatically. A room's temperature is
the average of its devices' temperature readings; rooms without a reading stay
unfilled. (+`areaTemp` helper, 3 new frontend tests, backend schema fields.)
## v1.14.0 — 2026-07-06 (per-space display settings, hand-drawn spaces, testing checklist)
- **Per-space "Display" settings** (space dialog): always-visible room borders,
room name labels, a border/name color picker with an opacity slider, and a room
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.
- GitHub issue templates (bug report with diagnostics hint, feature request),
`CONTRIBUTING.md` (5-minute setup, ground rules, architecture pointers),
Discussions enabled.
## v1.13.0 — 2026-07-05 (universality: floors import, icon rules, tap actions)
- **Floors import wizard**: on first run, if the HA registry has floors, the card
offers to create a space per floor — names prefilled, the (mandatory) plan image is
+46 -7
View File
@@ -13,14 +13,14 @@
| Item | State |
|---|---|
| Version | **v1.13.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
| Version | **v1.16.2** 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 | 54 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)
@@ -37,6 +37,36 @@
uninstall cleanup, HA-harness tests in CI, quality_scale.yaml.
- **v1.13.0** — universality: floors-import wizard, editable icon rules (+device_class
fallback), tap actions with a security model, i18n dictionaries in JSON, light-theme pass.
- **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.
- **v1.15.1** — display-settings UX: radio fill selector, inline compact bounds
(with the Number('')→0 bound-collapse bug fixed), avg room temp in the tooltip,
darken-on-hover, wider space dialog.
- **v1.15.2** — fix: average room temperature (fill + tooltip) counted non-thermometers
(fridges/TRVs/chip `*_device_temperature`/diagnostic); now thermometer/air-monitor only.
- **v1.15.3** — fix: device icon badge sat 1 px off its anchor (content-box + 1 px
border); `box-sizing: border-box` centres it exactly on the device point.
- **v1.15.4** — fix: real `ha-icon` (block + big line-height) put the glyph ~1.8 px low;
`.dev ha-icon` is now a zero-line-height flex box. Reverted v1.15.3 border-box (shrank the
badge). Verified live. Demo stub made faithful so the smoke guards it.
- **v1.15.5** — fix: room hover was always grey; legacy overlay/yard hover rules scoped
with `:not(.styled)` so filled rooms darken their fill, unfilled ones grey.
- **v1.15.6** — room hover also reveals the border (stroke colour kept, hidden via
opacity) even when borders are off.
- **v1.16.0** — NEW read-only `houseplan-space-card` (static single-space schematic,
pointer-events:none, deep-link button) + `#space=<id>` deep-link in the full card; shared
space-geometry/space-render + module-level config cache (config-store).
- **v1.16.1** — space-card renders room fills as configured on the full card (snapshot),
no longer omitted; +shared areaLqi().
## Where things live
@@ -49,13 +79,22 @@
## 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 next: see ROADMAP.md (quality-scale conformance is the next big theme).
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
screenshots with synthetic ones; measure backend coverage (>95% goal); mypy strict.
5. The demo harness lives in /tmp/demo (synthetic home: demo.html + capture.mjs) —
rebuildable from this repo + docs/DEVELOPMENT.md notes; frames → PIL → GIF.
## How to resume work in a fresh session (checklist)
+181
View File
@@ -0,0 +1,181 @@
# 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 inline on the radio row (swapped bounds tolerated [auto], clearing a field cannot zero a bound [auto]); rooms without a temperature reading stay unfilled [auto]
- [ ] Fill mode is a radio group (no dropdown); labels carry no color legend
- [ ] Room hover darkens the current fill (no recolor to blue); unfilled rooms hover light grey
- [ ] Room tooltip shows the average room temperature when any thermometer reports [auto]
- [ ] Average room temperature counts ONLY thermometer/air-monitor devices — fridges, TRV heads,
smart-plug chip temperatures (`*_device_temperature`) and diagnostic-category temps are excluded [auto]
- [ ] Space dialog is 500 px wide; the comfort-bounds inputs are compact (56 px)
- [ ] Device icon badge is centred exactly on its point (no 1 px down-right drift) [auto]
- [ ] Device glyph is centred within its badge (no vertical drift — real ha-icon is block+line-height) [auto]
- [ ] 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.
## houseplan-space-card (read-only embedded)
- [ ] `type: custom:houseplan-space-card, space: <id>` renders the space identical to the full
card's plan (background + configured borders/names + room fills + icons), no header/controls [auto]
- [ ] The schematic is fully non-interactive: click/hover anywhere does nothing — no more-info,
no tooltip, no drag (`.hp-static-stage` is pointer-events:none) [auto]
- [ ] Footer button opens the full component already showing that space (deep-link `#space=<id>`) [auto]
- [ ] Several cards with different `space` coexist on one board; one shared config WS request
- [ ] Unknown `space` → tidy error card [auto]
- [ ] `show_button: false` hides the footer
- [ ] Full card honours `#space=<id>` on load and on hashchange; invalid id ignored [auto]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1012 KiB

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 823 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 853 KiB

+58 -2
View File
@@ -1,21 +1,23 @@
{
"name": "houseplan-card",
"version": "1.12.0",
"version": "1.15.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.12.0",
"version": "1.15.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",
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.13.0",
"version": "1.16.2",
"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"
+8
View File
@@ -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);
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Module-level houseplan config cache shared by every embedded card on a board,
* so N cards do NOT issue N identical `houseplan/config/get` requests. The cache
* is invalidated when the integration emits `houseplan_config_updated`
* (fired on config/set); subscribers are then notified to reload.
*
* The full `houseplan-card` already persists a `{config, rev, layout}` snapshot in
* localStorage (`houseplan_card_cfg_v1`) for its own instant start — we seed from it
* so embedded cards paint immediately, then refresh from the server in the background.
*/
const LS_CFG = 'houseplan_card_cfg_v1';
export interface HpConfigSnapshot {
config: any | null;
rev: number;
layout: Record<string, any>;
}
let cache: HpConfigSnapshot | null = null;
let inflight: Promise<HpConfigSnapshot> | null = null;
let subscribed = false;
const listeners = new Set<() => void>();
/** Instant, synchronous best-effort snapshot from the full card's localStorage cache. */
export function cachedSnapshot(): HpConfigSnapshot | null {
if (cache) return cache;
try {
const c = JSON.parse(localStorage.getItem(LS_CFG) || 'null');
if (c && c.config && Array.isArray(c.config.spaces)) {
return { config: c.config, rev: c.rev || 0, layout: c.layout || {} };
}
} catch {
/* ignore */
}
return null;
}
async function fetchFresh(hass: any): Promise<HpConfigSnapshot> {
const [cfgResp, layResp] = await Promise.all([
hass.callWS({ type: 'houseplan/config/get' }),
hass.callWS({ type: 'houseplan/layout/get' }),
]);
cache = {
config: cfgResp?.config ?? null,
rev: cfgResp?.rev ?? 0,
layout: layResp?.layout ?? {},
};
if (!subscribed && hass.connection?.subscribeEvents) {
subscribed = true;
try {
await hass.connection.subscribeEvents(() => {
cache = null; // invalidate; listeners reload
listeners.forEach((l) => l());
}, 'houseplan_config_updated');
} catch {
subscribed = false;
}
}
return cache;
}
/** Get the shared config snapshot (cached, deduped across cards). */
export function getConfig(hass: any): Promise<HpConfigSnapshot> {
if (cache) return Promise.resolve(cache);
if (inflight) return inflight;
inflight = fetchFresh(hass).finally(() => {
inflight = null;
});
return inflight;
}
/** Subscribe to config-changed notifications; returns an unsubscribe function. */
export function onConfigChange(cb: () => void): () => void {
listeners.add(cb);
return () => listeners.delete(cb);
}
+51
View File
@@ -40,6 +40,10 @@ export function domainOfDevice(hass: any, dev: any, entIds: string[]): string {
}
export function isTempEntity(hass: any, eid: string): boolean {
// Температура чипа самого устройства — это диагностика, не комнатная температура.
if (/_device_temperature$/.test(eid)) return false;
// Диагностические/конфигурационные сущности (чип, батарея, калибровки) — не комната.
if (hass.entities?.[eid]?.entity_category) return false;
const st = hass.states[eid];
if (!st) return /_temperature$/.test(eid);
const a = st.attributes || {};
@@ -292,3 +296,50 @@ 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). */
/** Average zigbee signal (LQI) across an area's non-virtual devices, or null. */
export function areaLqi(hass: any, devices: { area: string; virtual?: boolean; entities: string[] }[], area: string): number | null {
const vals: number[] = [];
for (const d of devices) {
if (d.area !== area || d.virtual) continue;
const l = lqiFor(hass, d.entities);
if (l != null) vals.push(l);
}
return averageLqi(vals);
}
export function areaTemp(
hass: any,
devices: { area: string; icon?: string; entities: string[] }[],
area: string,
): number | null {
const vals: number[] = [];
for (const d of devices) {
if (d.area !== area) continue;
// Учитываем только устройства, которые сама карточка считает термометрами —
// не холодильники, термоголовки, розетки с температурой чипа и т.п.
if (d.icon !== 'mdi:thermometer' && d.icon !== 'mdi:air-filter') continue;
const t = tempFor(hass, d.entities);
if (t != null) vals.push(t);
}
if (!vals.length) return null;
return Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 10) / 10;
}
+284 -39
View File
@@ -14,16 +14,19 @@ 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';
import './editor';
import './space-card';
import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.13.0';
const CARD_VERSION = '1.16.2';
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';
@@ -67,7 +70,7 @@ class HouseplanCard extends LitElement {
private _devices: DevItem[] = [];
private _regSignature = '';
private _defPos: Record<string, { x: number; y: number }> = {};
private _tip: { x: number; y: number; title: string; meta: string; lqi?: number | null } | null = null;
private _tip: { x: number; y: number; title: string; meta: string; lqi?: number | null; temp?: number | null } | null = null;
private _selId: string | null = null;
private _toast = '';
private _toastTimer?: number;
@@ -120,9 +123,33 @@ 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);
private _hashApplied = false;
/** Deep-link: read `#space=<id>` from the URL (used by embedded houseplan-space-card). */
private _hashSpace(): string {
const m = /(?:^|[#&])space=([^&]+)/.exec(window.location.hash || '');
return m ? decodeURIComponent(m[1]) : '';
}
private _onHashChange = (): void => {
const id = this._hashSpace();
if (id && this._model.find((sp) => sp.id === id) && id !== this._space) {
this._space = id;
this._selId = null;
this._restoreZoom();
this.requestUpdate();
}
};
private _drag: { id: string; sx: number; sy: number; ox: number; oy: number; moved: boolean } | null = null;
private _holdTimer?: number;
@@ -157,10 +184,12 @@ class HouseplanCard extends LitElement {
public connectedCallback(): void {
super.connectedCallback();
window.addEventListener('keydown', this._keyHandler);
window.addEventListener('hashchange', this._onHashChange);
}
public disconnectedCallback(): void {
window.removeEventListener('keydown', this._keyHandler);
window.removeEventListener('hashchange', this._onHashChange);
clearTimeout(this._holdTimer);
this._roViewport?.disconnect();
this._roViewport = undefined;
@@ -241,7 +270,9 @@ class HouseplanCard extends LitElement {
this._cfgRev = c.rev || 0;
this._layout = c.layout || {};
this._serverStorage = true;
if (config.default_floor) this._space = config.default_floor;
const hs = this._hashSpace();
if (hs && this._model.find((sp) => sp.id === hs)) { this._space = hs; this._hashApplied = true; }
else if (config.default_floor) this._space = config.default_floor;
else if (!this._model.find((sp) => sp.id === this._space)) this._space = this._model[0]?.id || this._space;
}
} catch {
@@ -397,7 +428,11 @@ class HouseplanCard extends LitElement {
if ((ev?.data?.rev ?? -1) !== this._cfgRev) this._reloadConfigOnly();
}, 'houseplan_config_updated');
}
if (this._norm && !this._model.find((s) => s.id === this._space)) {
const hs = this._hashSpace();
if (!this._hashApplied && hs && this._model.find((s) => s.id === hs)) {
this._space = hs;
this._hashApplied = true;
} else if (this._norm && !this._model.find((s) => s.id === this._space)) {
this._space = this._model[0]?.id || this._space;
}
this._cacheSnapshot();
@@ -583,7 +618,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' : '';
@@ -889,9 +927,9 @@ class HouseplanCard extends LitElement {
}, 3500);
}
private _showTip(ev: MouseEvent, title: string, meta: string, lqi?: number | null): void {
private _showTip(ev: MouseEvent, title: string, meta: string, lqi?: number | null, temp?: number | null): void {
if (this._drag) return;
this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi };
this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi, temp };
}
// ================= ROOM MARKUP EDITOR =================
@@ -1441,9 +1479,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 +1532,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 +1541,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 +1557,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: Number.isFinite(d.tempMin) ? Math.min(d.tempMin, d.tempMax) : DEFAULT_TEMP_MIN,
temp_max: Number.isFinite(d.tempMax) ? Math.max(d.tempMin, d.tempMax) : DEFAULT_TEMP_MAX,
};
await this._saveConfigNow();
this._spaceDialog = null;
if (d.mode === 'create') this._space = sp.id;
@@ -1554,12 +1623,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 +1661,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 +1849,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 +1937,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[] = [];
// keep the stroke colour even when borders are hidden, so hover can reveal it
st.push(`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 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) {
cls += ' filled';
st.push(`--room-fill:${fillC}`, `--room-fill-op:${(0.3 * disp.opacity).toFixed(3)}`);
} else st.push('--room-fill:transparent', '--room-fill-op:0');
style = st.join(';');
}
const tip = (e: MouseEvent) =>
this._showTip(e, r.name, this._t('tip.room'),
this._config?.show_signal ? this._roomLqi(r.area) : null);
const label = !space.bg || this._markup;
this._config?.show_signal ? this._roomLqi(r.area) : null,
r.area ? areaTemp(this.hass, this._devices, r.area) : null);
const label = (!space.bg && !disp.showNames) || this._markup;
const c = this._roomCenter(r);
const shape = r.poly
? 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 +1981,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
@@ -1888,6 +2000,9 @@ class HouseplanCard extends LitElement {
${this._tip
? html`<div class="tip" style="left:${this._tip.x + 12}px;top:${this._tip.y + 12}px">
<b>${this._tip.title}</b>${this._tip.meta ? html`<span class="m">${this._tip.meta}</span>` : nothing}
${this._tip.temp != null
? html`<span class="m">${this._t('tip.temp_avg')} <b>${this._tip.temp}°</b></span>`
: nothing}
${this._tip.lqi != null
? html`<span class="m">${this._t('tip.lqi')}
<b style="color:${lqiColor(this._tip.lqi)}">${this._tip.lqi}</b></span>`
@@ -1917,6 +2032,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 +2040,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;
@@ -2149,7 +2328,7 @@ class HouseplanCard extends LitElement {
private _renderSpaceDialog(): TemplateResult {
const d = this._spaceDialog!;
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog wide" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:floor-plan"></ha-icon>
${d.mode === 'create' ? this._t('space.new') : this._t('space.header')}
${this._importTotal > 0 && d.mode === 'create'
@@ -2164,18 +2343,84 @@ 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>
${[['none', 'fill.none'], ['lqi', 'fill.lqi'], ['light', 'fill.light'], ['temp', 'fill.temp']].map(
([v, k]) => html`<label class="srcrow">
<input type="radio" name="fillmode" .checked=${d.fillMode === v}
@change=${() => (this._spaceDialog = { ...d, fillMode: v as any })} />
<span>${this._t(k as any)}</span>
${v === 'temp' && d.fillMode === 'temp'
? html`<span class="temprange">
<input class="namein tempin" type="number" step="0.5" .value=${String(d.tempMin)}
@input=${(e: Event) => {
const n = parseFloat((e.target as HTMLInputElement).value);
if (Number.isFinite(n)) this._spaceDialog = { ...d, tempMin: n };
}} />
–
<input class="namein tempin" type="number" step="0.5" .value=${String(d.tempMax)}
@input=${(e: Event) => {
const n = parseFloat((e.target as HTMLInputElement).value);
if (Number.isFinite(n)) this._spaceDialog = { ...d, tempMax: n };
}} />
°C
</span>`
: nothing}
</label>`,
)}
</div>
<div class="row">
${d.mode === 'edit'
@@ -2189,8 +2434,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>
+2 -3
View File
@@ -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 };
+28 -1
View File
@@ -151,5 +151,32 @@
"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",
"fill.light": "Lights",
"space.source_file": "I have a floor-plan image",
"space.source_draw": "No image — I'll outline rooms by hand",
"space.orientation": "Canvas",
"orient.landscape": "Landscape",
"orient.portrait": "Portrait",
"orient.square": "Square",
"fill.temp": "Temperature",
"space.temp_min": "Comfort from",
"space.temp_max": "to",
"tip.temp_avg": "average temperature:",
"space_card.button": "Open the space plan",
"space_card.not_found": "Space “{id}” not found",
"space_card.loading": "Loading…",
"editor.space": "Space",
"editor.show_button": "Show button",
"editor.button_label": "Button label",
"editor.button_target": "Target dashboard path",
"editor.aspect_ratio": "Aspect ratio (e.g. 16:9 or auto)"
}
+28 -1
View File
@@ -151,5 +151,32 @@
"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": "до",
"tip.temp_avg": "средняя температура:",
"space_card.button": "Перейти к пространству",
"space_card.not_found": "Пространство «{id}» не найдено",
"space_card.loading": "Загрузка…",
"editor.space": "Пространство",
"editor.show_button": "Показывать кнопку",
"editor.button_label": "Текст кнопки",
"editor.button_target": "Путь дашборда (куда вести)",
"editor.aspect_ratio": "Соотношение сторон (напр. 16:9 или auto)"
}
+76
View File
@@ -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;
}
+235
View File
@@ -0,0 +1,235 @@
/**
* houseplan-space-card — a READ-ONLY, static schematic of a single houseplan space,
* embeddable on any dashboard. Renders exactly what is configured (plan + configured
* room borders/names + device markers at their saved positions) with NO interactivity
* (no clicks/hover/tooltips/drag/more-info) and NO live states. A footer button opens
* the space in the full component via a deep-link (`#space=<id>`).
*/
import { LitElement, html, nothing, css, type TemplateResult, type PropertyValues } from 'lit';
import { cardStyles } from './styles';
import { renderSpaceStatic, spaceModels } from './space-render';
import { getConfig, onConfigChange, cachedSnapshot, type HpConfigSnapshot } from './config-store';
import { t, langOf, type Lang } from './i18n';
import './space-editor';
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
const ev = new Event(type, { bubbles: true, composed: true }) as any;
ev.detail = detail ?? {};
node.dispatchEvent(ev);
};
const navigate = (path: string) => {
history.pushState(null, '', path);
fireEvent(window, 'location-changed', { replace: false });
};
interface SpaceCardConfig {
type: string;
space: string;
title?: string;
show_button?: boolean;
button_label?: string;
button_target?: string;
aspect_ratio?: string;
icon_size?: number;
language?: string;
}
class HouseplanSpaceCard extends LitElement {
public hass?: any;
private _config?: SpaceCardConfig;
private _snap: HpConfigSnapshot | null = null;
private _loading = false;
private _unsub?: () => void;
static properties = {
hass: { attribute: false },
_config: { state: true },
_snap: { state: true },
};
public static getConfigElement() {
return document.createElement('houseplan-space-card-editor');
}
public static getStubConfig(hass: any): Partial<SpaceCardConfig> {
const snap = cachedSnapshot();
const first = spaceModels(snap?.config || null)[0]?.id || '';
return { type: 'custom:houseplan-space-card', space: first, show_button: true };
}
public setConfig(config: SpaceCardConfig): void {
if (!config || !config.space) {
throw new Error('houseplan-space-card: "space" is required');
}
this._config = { show_button: true, button_target: '/plan-doma', ...config };
// instant paint from the full card's localStorage snapshot, refresh in the background
this._snap = this._snap || cachedSnapshot();
}
public connectedCallback(): void {
super.connectedCallback();
this._unsub = onConfigChange(() => {
this._loading = false;
this._snap = null;
this.requestUpdate();
});
}
public disconnectedCallback(): void {
this._unsub?.();
this._unsub = undefined;
super.disconnectedCallback();
}
protected willUpdate(changed: PropertyValues): void {
if (this.hass && !this._loading && (!this._snap || changed.has('hass'))) {
if (!this._snap || !this._loadedOnce) this._load();
}
}
private _loadedOnce = false;
private async _load(): Promise<void> {
if (!this.hass || this._loading) return;
this._loading = true;
try {
const snap = await getConfig(this.hass);
this._snap = snap;
this._loadedOnce = true;
} catch {
/* keep any localStorage snapshot */
} finally {
this._loading = false;
this.requestUpdate();
}
}
private get _lang(): Lang {
return langOf(this.hass, this._config?.language);
}
public getCardSize(): number {
const models = spaceModels(this._snap?.config || null);
const sp = models.find((s) => s.id === this._config?.space);
if (sp) {
const ratio = sp.vb[3] / sp.vb[2]; // h/w
return Math.max(3, Math.round(ratio * 8)) + (this._config?.show_button === false ? 0 : 1);
}
return 6;
}
private _errorCard(msg: string): TemplateResult {
return html`<ha-card><div class="hp-static-error">${msg}</div></ha-card>`;
}
protected render(): TemplateResult | typeof nothing {
if (!this._config) return nothing;
const cfg = this._snap?.config;
if (!cfg) {
// still loading and no snapshot yet
return html`<ha-card><div class="hp-static-error">${t(this._lang, 'space_card.loading')}</div></ha-card>`;
}
const spaceId = this._config.space;
const stage = renderSpaceStatic({
hass: this.hass,
cfg,
layout: this._snap?.layout || {},
spaceId,
iconSize: this._config.icon_size,
lang: this._lang,
});
if (!stage) {
return this._errorCard(t(this._lang, 'space_card.not_found', { id: spaceId }));
}
const sp = spaceModels(cfg).find((s) => s.id === spaceId);
const title = this._config.title !== undefined ? this._config.title : sp?.title || '';
const showButton = this._config.show_button !== false;
const label = this._config.button_label || t(this._lang, 'space_card.button');
return html`
<ha-card>
${title ? html`<div class="hp-static-title">${title}</div>` : nothing}
${stage}
${showButton
? html`<div class="hp-static-foot">
<button class="hp-static-btn" @click=${this._goToSpace}>${label}</button>
</div>`
: nothing}
</ha-card>
`;
}
private _goToSpace = (): void => {
const base = (this._config?.button_target || '/plan-doma').replace(/#.*$/, '');
navigate(`${base}#space=${encodeURIComponent(this._config!.space)}`);
};
static styles = [
cardStyles,
css`
.hp-static-title {
font-weight: 700;
padding: 10px 14px 6px;
font-size: 16px;
color: var(--primary-text-color);
}
.hp-static-stage {
position: relative;
width: 100%;
container-type: inline-size;
overflow: hidden;
pointer-events: none; /* kill ALL interaction on the schematic (§4) */
background: var(--ha-card-background, var(--card-background-color, #111));
}
.hp-static-stage svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.hp-static-stage .devlayer {
position: absolute;
inset: 0;
}
.hp-static-foot {
padding: 8px 12px 12px;
pointer-events: auto; /* the button stays clickable */
}
.hp-static-btn {
width: 100%;
padding: 9px 14px;
border: none;
border-radius: 10px;
background: var(--primary-color, #3ea6ff);
color: var(--text-primary-color, #fff);
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.hp-static-btn:hover {
filter: brightness(1.08);
}
.hp-static-error {
padding: 24px;
text-align: center;
color: var(--secondary-text-color, #9aa4ad);
}
`,
];
}
if (!customElements.get('houseplan-space-card')) {
customElements.define('houseplan-space-card', HouseplanSpaceCard);
}
(window as any).customCards = (window as any).customCards || [];
if (!(window as any).customCards.find((c: any) => c.type === 'houseplan-space-card')) {
(window as any).customCards.push({
type: 'houseplan-space-card',
name: 'House Plan — Space (static)',
description: 'Read-only static schematic of a single houseplan space, with a deep-link button.',
preview: false,
documentation: 'https://github.com/Matysh/houseplan-card',
});
}
export { HouseplanSpaceCard };
+85
View File
@@ -0,0 +1,85 @@
/** Config editor (Lovelace GUI) for houseplan-space-card. */
import { LitElement, html, nothing } from 'lit';
import { langOf, t, type Lang } from './i18n';
class HouseplanSpaceCardEditor extends LitElement {
public hass?: any;
private _config?: any;
private _spaces: { value: string; label: string }[] | null = null;
private _spacesLoading = false;
static properties = {
hass: { attribute: false },
_config: { state: true },
_spaces: { state: true },
};
public setConfig(config: any): void {
this._config = config;
}
private async _loadSpaces(): Promise<void> {
if (this._spaces || this._spacesLoading || !this.hass) return;
this._spacesLoading = true;
try {
const resp = await this.hass.callWS({ type: 'houseplan/config/get' });
this._spaces = (resp?.config?.spaces || []).map((s: any) => ({ value: s.id, label: s.title || s.id }));
} catch {
this._spaces = [];
} finally {
this._spacesLoading = false;
}
}
private get _lang(): Lang {
return langOf(this.hass, this._config?.language);
}
private get _schema(): any[] {
const spaces = this._spaces || [];
return [
spaces.length
? { name: 'space', selector: { select: { mode: 'dropdown', options: spaces } } }
: { name: 'space', selector: { text: {} } },
{ name: 'title', selector: { text: {} } },
{ name: 'show_button', selector: { boolean: {} } },
{ name: 'button_label', selector: { text: {} } },
{ name: 'button_target', selector: { text: {} } },
{ name: 'aspect_ratio', selector: { text: {} } },
{ name: 'icon_size', selector: { number: { min: 1, max: 6, step: 0.1, mode: 'box' } } },
];
}
protected render() {
if (!this.hass || !this._config) return nothing;
this._loadSpaces();
const L = this._lang;
const labels: Record<string, string> = {
space: t(L, 'editor.space'),
title: t(L, 'editor.title'),
show_button: t(L, 'editor.show_button'),
button_label: t(L, 'editor.button_label'),
button_target: t(L, 'editor.button_target'),
aspect_ratio: t(L, 'editor.aspect_ratio'),
icon_size: t(L, 'editor.icon_size'),
};
return html`<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${this._schema}
.computeLabel=${(s: any) => labels[s.name] || s.name}
@value-changed=${this._valueChanged}
></ha-form>`;
}
private _valueChanged(ev: CustomEvent): void {
const config = { ...this._config, ...ev.detail.value };
const e = new Event('config-changed', { bubbles: true, composed: true }) as any;
e.detail = { config };
this.dispatchEvent(e);
}
}
if (!customElements.get('houseplan-space-card-editor')) {
customElements.define('houseplan-space-card-editor', HouseplanSpaceCardEditor);
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Pure geometry/model helpers for houseplan spaces — no Lit/DOM imports, so they
* are directly unit-tested. Shared by the static renderer (space-render.ts) and
* mirror the full card's private math.
*/
import { declump } from './logic';
import type { ServerConfig, SpaceModel, RoomCfg, DevItem } from './types';
export const NORM_W = 1000; // width of the render space for normalized configs
export type Pt = { x: number; y: number };
export type Layout = Record<string, { s?: string; x: number; y: number } | undefined>;
/** Build render-space models (NORM_W × NORM_W/aspect) from a server config. */
export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
if (!cfg || !Array.isArray(cfg.spaces)) return [];
return cfg.spaces.map((s: any) => {
const H = NORM_W / s.aspect;
const scale = (r: any): RoomCfg => ({
id: r.id,
name: r.name,
area: r.area ?? null,
x: r.x != null ? r.x * NORM_W : undefined,
y: r.y != null ? r.y * H : undefined,
w: r.w != null ? r.w * NORM_W : undefined,
h: r.h != null ? r.h * H : undefined,
poly: r.poly ? r.poly.map((p: number[]) => [p[0] * NORM_W, p[1] * H]) : undefined,
});
return {
id: s.id,
title: s.title,
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
bg: s.plan_url ? { href: s.plan_url, x: 0, y: 0, w: NORM_W, h: H } : null,
rooms: (s.rooms || []).map(scale),
} as SpaceModel;
});
}
/** Bounding rectangle of a room (rect or polygon) in render units. */
export function roomBounds(r: RoomCfg): { x: number; y: number; w: number; h: number } {
if (r.poly && r.poly.length) {
const xs = r.poly.map((p) => p[0]);
const ys = r.poly.map((p) => p[1]);
const x = Math.min(...xs);
const y = Math.min(...ys);
return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
}
return { x: r.x ?? 0, y: r.y ?? 0, w: r.w ?? 0, h: r.h ?? 0 };
}
/** Geometric centre of a room (label anchor). */
export function roomCenter(r: RoomCfg): number[] {
if (r.poly) {
const n = r.poly.length;
return [r.poly.reduce((a, p) => a + p[0], 0) / n, r.poly.reduce((a, p) => a + p[1], 0) / n];
}
return [r.x! + r.w! / 2, r.y! + Math.min(r.w!, r.h!) * 0.1];
}
/** Auto grid positions for a single space's area devices (identical to the full card). */
export function defaultPositions(devs: DevItem[], model: SpaceModel, iconPct: number): Record<string, Pt> {
const map: Record<string, Pt> = {};
const minDist = (iconPct / 100) * NORM_W * 1.3;
for (const r of model.rooms) {
if (!r.area) continue;
const ds = devs.filter((d) => d.area === r.area);
if (!ds.length) continue;
const b = roomBounds(r);
const pad = Math.min(b.w, b.h) * 0.1;
const iw = b.w - pad * 2;
const ih = b.h - pad * 2;
const cols = Math.max(1, Math.round(Math.sqrt((ds.length * iw) / Math.max(ih, 1))));
const cw = iw / cols;
const ch = ih / Math.max(Math.ceil(ds.length / cols), 1);
const pts = ds.map((_, i) => ({
x: b.x + pad + cw * ((i % cols) + 0.5),
y: b.y + pad + ch * (Math.floor(i / cols) + 0.5),
}));
declump(pts, b, minDist, pad * 0.5);
ds.forEach((d, i) => (map[d.id] = pts[i]));
}
return map;
}
/** Marker position in render units: saved layout → default grid → space centre. */
export function markerPos(d: DevItem, layout: Layout, cfg: ServerConfig, defPos: Record<string, Pt>, model: SpaceModel): Pt {
const saved = layout[d.id];
if (saved && saved.s === d.space) {
const aspect = cfg.spaces.find((x: any) => x.id === d.space)?.aspect || 1;
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
}
if (defPos[d.id]) return defPos[d.id];
const vb = model.vb;
return { x: vb[0] + vb[2] / 2, y: vb[1] + vb[3] / 2 };
}
/** Saved room-label position (layout key rl_<roomId>) or the room centre. */
export function labelPos(r: RoomCfg, spaceId: string, layout: Layout, cfg: ServerConfig): Pt {
const saved = layout['rl_' + (r.id || '')];
if (saved && saved.s === spaceId) {
const aspect = cfg.spaces.find((x: any) => x.id === spaceId)?.aspect || 1;
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
}
const c = roomCenter(r);
return { x: c[0], y: c[1] };
}
+131
View File
@@ -0,0 +1,131 @@
/**
* Shared STATIC renderer for a single houseplan space — used by the read-only
* `houseplan-space-card`. Draws exactly what is CONFIGURED (plan background,
* configured room borders/names, device markers at their saved positions), with NO interactivity
* (pointer-events:none) and NO state subscription; room fills are rendered exactly as
* configured on the full card (a snapshot of the current states passed via `hass`).
* Geometry/model math lives in space-geometry.ts (pure, unit-tested).
*/
import { html, svg, nothing, type TemplateResult } from 'lit';
import { buildDevices, areaLqi, areaLights, areaTemp } from './devices';
import { spaceDisplayOf, roomFillColor } from './logic';
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
import { t, type Lang } from './i18n';
import type { ServerConfig } from './types';
import {
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, type Layout,
} from './space-geometry';
export { spaceModels } from './space-geometry';
export interface StaticRenderOpts {
hass: any;
cfg: ServerConfig;
layout: Layout;
spaceId: string;
iconSize?: number;
lang: Lang;
}
/**
* Static schematic of one space. Returns the inner stage template (svg + marker
* layer) or null when the space id is unknown (the caller renders an error card).
*/
export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const models = spaceModels(o.cfg);
const space = models.find((s) => s.id === o.spaceId);
if (!space) return null;
const vb = space.vb;
const disp = spaceDisplayOf(o.cfg.spaces.find((s: any) => s.id === o.spaceId));
const cfgSize = o.iconSize ?? 2.5;
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
const areaToSpace: Record<string, string> = {};
for (const s of o.cfg.spaces || []) for (const r of (s as any).rooms || []) if (r.area) areaToSpace[r.area] = (s as any).id;
const excluded = o.cfg.settings?.exclude_integrations ? new Set(o.cfg.settings.exclude_integrations) : EXCLUDED_DOMAINS;
const iconRules = compileIconRules(
o.cfg.settings?.icon_rules?.length ? o.cfg.settings.icon_rules : DEFAULT_ICON_RULES,
);
const loc = (k: 'device.unnamed' | 'device.light_group' | 'device.fallback' | 'device.virtual') => t(o.lang, k);
const all = buildDevices({
hass: o.hass,
areaToSpace,
markers: o.cfg.markers || [],
settings: o.cfg.settings || {},
excluded,
showAll: !!o.cfg.settings?.show_all,
firstSpaceId: models[0]?.id || '',
loc,
iconRules,
});
const devs = all.filter((d) => d.space === o.spaceId);
const defPos = defaultPositions(devs, space, iconPct);
const roomShapes = space.rooms
.filter((r) => r.area || disp.showBorders)
.map((r) => {
let cls = 'room ' + (space.bg ? 'overlay' : 'yard');
let style = '';
if (disp.showBorders || disp.fill !== 'none') {
cls += ' styled';
const parts = [`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`];
// fill rendered exactly as configured on the full card (snapshot of current states)
const fillC = r.area
? roomFillColor(
disp.fill,
disp.fill === 'lqi' ? areaLqi(o.hass, devs, r.area) : null,
disp.fill === 'light' ? areaLights(o.hass, devs, r.area) : 'none',
disp.fill === 'temp' ? areaTemp(o.hass, devs, r.area) : null,
disp.tempMin,
disp.tempMax,
)
: null;
if (fillC) {
cls += ' filled';
parts.push(`--room-fill:${fillC}`, `--room-fill-op:${(0.3 * disp.opacity).toFixed(3)}`);
} else {
parts.push('--room-fill:transparent', '--room-fill-op:0');
}
style = parts.join(';');
}
const svgLabel = !space.bg && !disp.showNames;
const c = roomCenter(r);
const shape = r.poly
? svg`<polygon class="${cls}" style="${style}" points="${r.poly.map((p) => p.join(',')).join(' ')}"></polygon>`
: 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}"></rect>`;
return svg`${shape}${svgLabel ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
});
const markers = devs.map((d) => {
const p = markerPos(d, o.layout, o.cfg, defPos, space);
const left = ((p.x - vb[0]) / vb[2]) * 100;
const top = ((p.y - vb[1]) / vb[3]) * 100;
return html`<div class="dev ${d.virtual ? 'virtual' : ''}" style="left:${left}%;top:${top}%">
<ha-icon icon="${d.icon}"></ha-icon>
</div>`;
});
const labels = disp.showNames
? space.rooms
.filter((r) => r.name)
.map((r) => {
const p = labelPos(r, space.id, o.layout, o.cfg);
const left = ((p.x - vb[0]) / vb[2]) * 100;
const top = ((p.y - vb[1]) / vb[3]) * 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}">${r.name}</div>`;
})
: [];
return html`
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}">
<svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet">
${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}
${roomShapes}
</svg>
<div class="devlayer" style="--icon-size:${iconPct}cqw">${markers}${labels}</div>
</div>
`;
}
+88 -6
View File
@@ -191,19 +191,51 @@ export const cardStyles = css`
stroke: transparent;
stroke-width: 2;
}
.room.overlay:hover {
fill: rgba(62, 166, 255, 0.18);
stroke: var(--hp-accent);
.room.overlay:not(.styled):hover {
fill: #9aa0a6;
fill-opacity: 0.22;
stroke: var(--hp-muted);
}
.room.yard {
fill: rgba(75, 140, 90, 0.14);
stroke: #4b8c5a;
stroke-width: 2;
}
.room.yard:hover {
.room.yard:not(.styled):hover {
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);
}
/* hover: darken the current fill instead of recoloring; grey when unfilled */
.room.styled.filled:hover {
filter: brightness(0.78);
stroke-opacity: 1;
}
.room.styled:not(.filled):hover {
fill: #9aa0a6;
fill-opacity: 0.22;
stroke-opacity: 1;
}
.roomlabel {
position: absolute;
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;
@@ -280,9 +312,10 @@ export const cardStyles = css`
}
.dev {
position: absolute;
/* центр квадрата (включая рамку 1px) точно на точке привязки: -(size/2 + border) */
width: var(--icon-size, 2.5cqw);
height: var(--icon-size, 2.5cqw);
margin: calc(var(--icon-size, 2.5cqw) / -2) 0 0 calc(var(--icon-size, 2.5cqw) / -2);
margin: calc(var(--icon-size, 2.5cqw) / -2 - 1px) 0 0 calc(var(--icon-size, 2.5cqw) / -2 - 1px);
border-radius: 22%;
background: var(--hp-bg);
border: 1px solid var(--hp-line);
@@ -298,6 +331,10 @@ export const cardStyles = css`
}
.dev ha-icon {
--mdc-icon-size: calc(var(--icon-size, 2.5cqw) * 0.62);
display: flex;
align-items: center;
justify-content: center;
line-height: 0;
}
.dev:active {
cursor: grabbing;
@@ -383,6 +420,51 @@ 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; }
.temprange {
display: inline-flex;
align-items: center;
gap: 5px;
margin-left: auto;
color: var(--hp-muted);
font-size: 12px;
}
/* beat the generic .dialog .body .namein { width:100% } rule */
.dialog .body .temprange .tempin { width: 56px; flex: none; padding: 3px 6px; }
.srcrow { flex-wrap: nowrap; }
.srcrow > span:first-of-type { white-space: nowrap; }
.colorrow .opl { color: var(--hp-muted); font-size: 12px; }
.colorrow .opv { font-size: 12px; min-width: 34px; text-align: right; }
.planrow {
display: flex;
align-items: center;
@@ -416,7 +498,7 @@ export const cardStyles = css`
flex: 1;
}
.dialog.wide {
width: min(440px, 94vw);
width: min(500px, 94vw);
}
.dialog .body {
max-height: 66vh;
+226
View File
@@ -0,0 +1,226 @@
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 only thermometer devices, null when none report', () => {
const hass = mkHass({ states: {
'sensor.t1': { state: '20.0', attributes: { device_class: 'temperature' } },
'sensor.t2': { state: '23.1', attributes: { device_class: 'temperature' } },
'sensor.hum': { state: '55', attributes: { device_class: 'humidity' } },
}});
const devs = [
{ area: 'living', icon: 'mdi:thermometer', entities: ['sensor.t1'] },
{ area: 'living', icon: 'mdi:thermometer', entities: ['sensor.t2'] },
{ area: 'bath', icon: 'mdi:water-percent', entities: ['sensor.hum'] },
];
assert.equal(areaTemp(hass, devs, 'living'), 21.6); // (20+23.1)/2=21.55 → 21.6
assert.equal(areaTemp(hass, devs, 'bath'), null);
assert.equal(areaTemp(hass, devs, 'garage'), null);
});
test('areaTemp: ignores non-thermometer devices that expose a temperature (fridge, TRV, chip)', () => {
const hass = mkHass({
entities: { 'sensor.plug_device_temperature': { entity_id: 'sensor.plug_device_temperature', entity_category: 'diagnostic' } },
states: {
'sensor.room': { state: '22.0', attributes: { device_class: 'temperature' } },
'sensor.fridge_t': { state: '4.5', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
'sensor.trv_current': { state: '8.3', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
'sensor.plug_device_temperature': { state: '31', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
},
});
const devs = [
{ area: 'living', icon: 'mdi:thermometer', entities: ['sensor.room'] },
{ area: 'living', icon: 'mdi:fridge', entities: ['sensor.fridge_t'] },
{ area: 'living', icon: 'mdi:radiator', entities: ['sensor.trv_current'] },
{ area: 'living', icon: 'mdi:power-socket-de', entities: ['sensor.plug_device_temperature'] },
];
// только настоящий термометр (22.0), холодильник/термоголовка/чип игнорируются
assert.equal(areaTemp(hass, devs, 'living'), 22.0);
});
test('isTempEntity: excludes chip temperature and diagnostic-category entities', () => {
const hass = mkHass({
entities: { 'sensor.calibrated': { entity_id: 'sensor.calibrated', entity_category: 'diagnostic' } },
states: {
'sensor.room_temperature': { state: '21', attributes: { device_class: 'temperature' } },
'sensor.plug_device_temperature': { state: '30', attributes: { device_class: 'temperature' } },
'sensor.calibrated': { state: '19', attributes: { device_class: 'temperature' } },
},
});
// настоящий комнатный датчик проходит
assert.equal(tempFor(hass, ['sensor.room_temperature']), 21);
// чип и диагностика — нет
assert.equal(tempFor(hass, ['sensor.plug_device_temperature']), null);
assert.equal(tempFor(hass, ['sensor.calibrated']), null);
// если в устройстве и чип, и настоящий — берётся настоящий
assert.equal(tempFor(hass, ['sensor.plug_device_temperature', 'sensor.room_temperature']), 21);
});
+53 -1
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
fitView, declump, safeUrl, resolveTapAction, floorsOf,
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);
});
+72
View File
@@ -0,0 +1,72 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
NORM_W, spaceModels, roomBounds, roomCenter, defaultPositions, markerPos, labelPos,
} from '../test-build/space-geometry.js';
const cfg = {
spaces: [{
id: 'f1', title: '1st', aspect: 2, plan_url: '/plans/f1.svg', view_box: [0, 0, 1, 1],
rooms: [{ id: 'r1', name: 'Room', area: 'a1', poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]] }],
}, {
id: 'yard', title: 'Yard', aspect: 1, view_box: [0, 0, 1, 1], rooms: [],
}],
markers: [], settings: {},
};
test('spaceModels: scales vb/rooms by NORM_W and H=NORM_W/aspect; bg only with plan_url', () => {
const m = spaceModels(cfg);
assert.equal(m.length, 2);
const f1 = m[0];
assert.deepEqual(f1.vb, [0, 0, 1000, 500]); // aspect 2 → H 500
assert.equal(f1.bg.href, '/plans/f1.svg');
assert.deepEqual(f1.rooms[0].poly, [[100, 50], [500, 50], [500, 250], [100, 250]]);
assert.equal(m[1].bg, null); // no plan_url
assert.equal(spaceModels(null).length, 0);
});
test('roomBounds + roomCenter for a polygon', () => {
const r = spaceModels(cfg)[0].rooms[0];
assert.deepEqual(roomBounds(r), { x: 100, y: 50, w: 400, h: 200 });
assert.deepEqual(roomCenter(r), [300, 150]);
});
test('markerPos: saved layout → default grid → space centre', () => {
const model = spaceModels(cfg)[0];
const dev = { id: 'd1', space: 'f1', area: 'a1', entities: [] };
// saved layout (normalized) → render units
assert.deepEqual(
markerPos(dev, { d1: { s: 'f1', x: 0.2, y: 0.3 } }, cfg, {}, model),
{ x: 200, y: 150 }, // 0.2*1000, 0.3*(1000/2)
);
// default grid position (inside the room)
const defPos = defaultPositions([dev], model, 2.5);
assert.ok(defPos.d1);
assert.deepEqual(markerPos(dev, {}, cfg, defPos, model), defPos.d1);
const b = roomBounds(model.rooms[0]);
assert.ok(defPos.d1.x >= b.x && defPos.d1.x <= b.x + b.w && defPos.d1.y >= b.y && defPos.d1.y <= b.y + b.h);
// no layout, no defPos → space centre
assert.deepEqual(markerPos(dev, {}, cfg, {}, model), { x: 500, y: 250 });
});
test('labelPos: saved rl_<id> → render units; else room centre', () => {
const model = spaceModels(cfg)[0];
const r = model.rooms[0];
assert.deepEqual(labelPos(r, 'f1', { rl_r1: { s: 'f1', x: 0.3, y: 0.4 } }, cfg), { x: 300, y: 200 });
assert.deepEqual(labelPos(r, 'f1', {}, cfg), { x: 300, y: 150 }); // room centre
});
test('defaultPositions: several devices in one room are spread (declumped, distinct)', () => {
const model = spaceModels(cfg)[0];
const devs = [0, 1, 2, 3].map((i) => ({ id: 'd' + i, space: 'f1', area: 'a1', entities: [] }));
const pos = defaultPositions(devs, model, 2.5);
assert.equal(Object.keys(pos).length, 4);
const keys = Object.keys(pos);
for (let i = 0; i < keys.length; i++)
for (let j = i + 1; j < keys.length; j++) {
const a = pos[keys[i]], b = pos[keys[j]];
assert.ok(Math.hypot(a.x - b.x, a.y - b.y) > 1, 'positions distinct');
}
});
test('NORM_W is 1000', () => assert.equal(NORM_W, 1000));
+28
View File
@@ -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)
+5 -2
View File
@@ -9,6 +9,9 @@
},
"include": [
"src/logic.ts",
"src/rules.ts"
"src/rules.ts",
"src/devices.ts",
"src/types.ts",
"src/space-geometry.ts"
]
}
}