mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 19:28:46 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cbf4ece4e | ||
|
|
6609bfadd5 | ||
|
|
498b852e86 | ||
|
|
fabeab81aa | ||
|
|
246de26122 | ||
|
|
1847c7b8ed | ||
|
|
fbd3887e14 | ||
|
|
5f9cfb9e10 | ||
|
|
cc27a6b2fa | ||
|
|
927e7d7054 |
@@ -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.
|
||||
@@ -145,6 +160,9 @@ matches, the entity *device class* decides (thermometer for temperature sensors,
|
||||
|
||||
### Step 6. Adding your own devices manually
|
||||
|
||||
You can also place a **single entity** (not just a whole device): start typing in the binding search and individual entities appear next to devices — handy when one device exposes several values (e.g. temperature and humidity) and you want each as its own icon.
|
||||
|
||||
|
||||
Not everything has to be left to the automation. With the **+** button in the header you can place any device, group or a **virtual point** on the plan (for example, an "Inlet valve" that does not exist as a device). Set a name, icon, model, link, description and, if you wish, attach a **PDF manual**.
|
||||
|
||||

|
||||
|
||||
@@ -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.
|
||||
@@ -149,6 +164,9 @@ title: План дома
|
||||
|
||||
### Шаг 6. Добавление своих устройств вручную
|
||||
|
||||
Можно поставить и **отдельную сущность** (не только устройство целиком): начните печатать в поиске привязки — рядом с устройствами появятся отдельные сущности. Удобно, когда одно устройство отдаёт несколько значений (например, температуру и влажность), а вы хотите каждое своей иконкой.
|
||||
|
||||
|
||||
Не всё нужно оставлять на автоматику. Кнопкой **+** в шапке можно поставить на план любое устройство, группу или **виртуальную точку** (например, «Вентиль на вводе», которого нет как устройства). Задайте имя, иконку, модель, ссылку, описание и при желании приложите **PDF-инструкцию**.
|
||||
|
||||

|
||||
|
||||
@@ -79,8 +79,21 @@ 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)
|
||||
# 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,
|
||||
)
|
||||
|
||||
await async_check_plan_files(hass, entry)
|
||||
return True
|
||||
|
||||
Binary file not shown.
@@ -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.15.2"
|
||||
VERSION = "1.17.2"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.15.2"
|
||||
"version": "1.17.2"
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
+192
-114
File diff suppressed because one or more lines are too long
+7
-3
@@ -27,9 +27,13 @@ class HaIcon extends HTMLElement{
|
||||
_r(){
|
||||
const p=(window.__ICONS||{})[this.getAttribute('icon')]||'';
|
||||
if(!this.shadowRoot) this.attachShadow({mode:'open'});
|
||||
this.shadowRoot.innerHTML='<style>:host{display:inline-flex;align-items:center;justify-content:center;'+
|
||||
'width:var(--mdc-icon-size,24px);height:var(--mdc-icon-size,24px);vertical-align:middle}'+
|
||||
'svg{width:100%;height:100%;fill:currentColor;display:block}</style>'+
|
||||
// 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>';
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+211
-115
File diff suppressed because one or more lines are too long
@@ -131,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.
|
||||
|
||||
@@ -1,5 +1,101 @@
|
||||
# Changelog
|
||||
|
||||
## v1.17.2 — 2026-07-11 (humidity badge: gate on the sensor, not the icon)
|
||||
- Fix on top of v1.17.1: the humidity `%` badge is now shown whenever the marker's primary
|
||||
entity is a humidity sensor (`device_class: humidity`), regardless of the resolved icon.
|
||||
Previously it required the `mdi:water-percent` icon, so a humidity sensor whose name matched
|
||||
another icon rule (e.g. a "Myheat Влажность …" sensor → boiler icon) showed no value.
|
||||
Verified live (45.2 → 45%). (+1 test: 57 → 58.)
|
||||
|
||||
## v1.17.1 — 2026-07-11 (humidity value next to the icon, like temperature)
|
||||
- **Humidity sensors now show their value (%) next to the icon**, mirroring the temperature
|
||||
badge. Any marker resolved to the humidity icon (`mdi:water-percent`) — a humidity device or a
|
||||
humidity entity placed on its own (v1.17.0) — gets an integer `%` badge and the value in its
|
||||
tooltip. New `isHumEntity`/`humFor` (diagnostic entities excluded), `DevItem.hum`, `.hval`
|
||||
badge, gated by the same "sensor values" option as temperature. (+2 tests: 55 → 57.)
|
||||
|
||||
## v1.17.0 — 2026-07-11 (place individual entities, not just whole devices — issue #1)
|
||||
- **You can now put a single entity on the plan as its own icon** — e.g. a climate sensor
|
||||
exposes temperature AND humidity; add the device (shows temperature) and separately add the
|
||||
humidity entity as a second icon. In the "add device" dialog, start typing in the binding
|
||||
search and individual entities now appear alongside devices/helpers (surfaced only while
|
||||
searching, so the default list stays clean); the sub-label shows the domain and parent device.
|
||||
- Entity markers now get a **sensible auto icon** (name rules → `device_class` → chip) instead of
|
||||
the generic shape, and a **temperature value** when the entity is a thermometer/air-monitor.
|
||||
The `entity:<eid>` binding already existed (used by helpers/light groups); this exposes it for
|
||||
any entity. (+1 test: 54 → 55.)
|
||||
|
||||
## 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
|
||||
|
||||
+16
-2
@@ -13,14 +13,14 @@
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.15.2** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
||||
| Version | **v1.17.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/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 | 41 frontend (node:test, incl. a 12-test buildDevices suite on a fake hass) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13; skipped locally — sandbox has py3.10) |
|
||||
| Tests | 58 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)
|
||||
|
||||
@@ -53,6 +53,20 @@
|
||||
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
|
||||
|
||||
|
||||
@@ -56,6 +56,8 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
- [ ] 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)
|
||||
|
||||
@@ -166,3 +168,14 @@ tap/hold/wizard/rules smokes). Bugs found during the run, fixed in the same rele
|
||||
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]
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.15.2",
|
||||
"version": "1.17.2",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+42
-2
@@ -104,6 +104,27 @@ export function tempFor(hass: any, entIds: string[]): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A humidity-measuring entity (device_class humidity or *_humidity), excluding diagnostics. */
|
||||
export function isHumEntity(hass: any, eid: string): boolean {
|
||||
if (hass.entities?.[eid]?.entity_category) return false;
|
||||
const st = hass.states[eid];
|
||||
if (!st) return /_humidity$/.test(eid);
|
||||
const a = st.attributes || {};
|
||||
return a.device_class === 'humidity' || (a.unit_of_measurement === '%' && /_humidity$/.test(eid)) || /_humidity$/.test(eid);
|
||||
}
|
||||
|
||||
/** First readable humidity value (integer %) among the entities, or null. */
|
||||
export function humFor(hass: any, entIds: string[]): number | null {
|
||||
for (const eid of entIds) {
|
||||
if (!isHumEntity(hass, eid)) continue;
|
||||
const st = hass.states[eid];
|
||||
if (!st) continue;
|
||||
const v = parseFloat(st.state);
|
||||
if (!isNaN(v)) return Math.round(v);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Group light entities: HA light-group (platform=group) and Z2M groups (device model=Group). */
|
||||
export function lightGroups(hass: any, enabled: boolean): { eid: string; name: string; area: string }[] {
|
||||
if (!enabled) return [];
|
||||
@@ -206,6 +227,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
};
|
||||
item.primary = primaryEntity(h, entIds, icon);
|
||||
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, entIds);
|
||||
if (item.primary && isHumEntity(h, item.primary)) item.hum = humFor(h, entIds);
|
||||
rest.push(item);
|
||||
}
|
||||
|
||||
@@ -254,6 +276,8 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
};
|
||||
item.primary = primaryEntity(h, entIds, icon);
|
||||
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, entIds);
|
||||
if (item.primary && isHumEntity(h, item.primary)) item.hum = humFor(h, entIds);
|
||||
if (item.primary && isHumEntity(h, item.primary)) item.hum = humFor(h, entIds);
|
||||
applyMarker(item, m);
|
||||
rest.push(item);
|
||||
} else if (kind === 'entity') {
|
||||
@@ -261,18 +285,23 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
const area = m.area || reg?.area_id || (reg?.device_id && h.devices[reg.device_id]?.area_id) || '';
|
||||
const space = (area && areaToSpace[area]) || m.space || firstSpaceId;
|
||||
const st = h.states[ref];
|
||||
const nm = reg?.name || st?.attributes?.friendly_name || ref;
|
||||
let icon = resolveIcon(h, nm, '', [ref], iconRules);
|
||||
if (ref.startsWith('lock.')) icon = 'mdi:lock';
|
||||
const item: DevItem = {
|
||||
id: m.id,
|
||||
name: reg?.name || st?.attributes?.friendly_name || ref,
|
||||
name: nm,
|
||||
model: '',
|
||||
area,
|
||||
space,
|
||||
icon: 'mdi:shape-outline',
|
||||
icon,
|
||||
entities: [ref],
|
||||
primary: ref,
|
||||
bindingKind: 'entity',
|
||||
bindingRef: ref,
|
||||
};
|
||||
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, [ref]);
|
||||
if (isHumEntity(h, ref)) item.hum = humFor(h, [ref]);
|
||||
applyMarker(item, m);
|
||||
rest.push(item);
|
||||
} else {
|
||||
@@ -315,6 +344,17 @@ export function areaLights(hass: any, devices: { area: string; entities: string[
|
||||
}
|
||||
|
||||
/** 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[] }[],
|
||||
|
||||
+56
-10
@@ -17,15 +17,16 @@ import {
|
||||
spaceDisplayOf, roomFillColor, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
|
||||
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
|
||||
} from './logic';
|
||||
import { buildDevices, lqiFor, tempFor, areaLights, areaTemp } from './devices';
|
||||
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, 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.15.2';
|
||||
const CARD_VERSION = '1.17.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';
|
||||
@@ -134,6 +135,21 @@ class HouseplanCard extends LitElement {
|
||||
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;
|
||||
@@ -168,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;
|
||||
@@ -252,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 {
|
||||
@@ -408,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();
|
||||
@@ -617,6 +641,12 @@ class HouseplanCard extends LitElement {
|
||||
return tempFor(this.hass, d.entities);
|
||||
}
|
||||
|
||||
private _liveHum(d: DevItem): number | null {
|
||||
if (!this._config?.show_temperature) return null; // same "sensor values" toggle as temperature
|
||||
if (!d.primary || !isHumEntity(this.hass, d.primary)) return null;
|
||||
return humFor(this.hass, d.entities);
|
||||
}
|
||||
|
||||
// ================= interaction =================
|
||||
|
||||
private _openMoreInfo(entityId?: string): void {
|
||||
@@ -1238,6 +1268,23 @@ class HouseplanCard extends LitElement {
|
||||
sub: eid.split('.')[0] + ' · ' + (reg.platform === 'group' ? this._t('marker.sub_group') : this._t('marker.sub_helper')),
|
||||
});
|
||||
}
|
||||
// Individual entities — surfaced only while searching (avoids a huge default list); lets you
|
||||
// place a single entity of a multi-entity device (e.g. the humidity of a climate sensor) as
|
||||
// its own icon. Uses the same entity: binding as helpers/groups.
|
||||
const q = (this._markerDialog?.bindingFilter || '').toLowerCase().trim();
|
||||
if (q) {
|
||||
const seen = new Set(list.map((o) => o.value));
|
||||
for (const [eid, reg] of Object.entries<any>(h.entities)) {
|
||||
const v = 'entity:' + eid;
|
||||
if (taken.has(v) || seen.has(v) || reg.hidden) continue;
|
||||
const stt = h.states[eid];
|
||||
const label = reg.name || stt?.attributes?.friendly_name || eid;
|
||||
const dev = reg.device_id ? h.devices[reg.device_id] : null;
|
||||
const devName = dev ? (dev.name_by_user || dev.name || '') : '';
|
||||
if (!(label + ' ' + eid + ' ' + devName).toLowerCase().includes(q)) continue;
|
||||
list.push({ value: v, label, sub: eid.split('.')[0] + ' · ' + this._t('marker.sub_entity') + (devName ? ' · ' + devName : '') });
|
||||
}
|
||||
}
|
||||
const f = (this._markerDialog?.bindingFilter || '').toLowerCase().trim();
|
||||
const filtered = f
|
||||
? list.filter((o) => (o.label + ' ' + o.sub + ' ' + o.value).toLowerCase().includes(f))
|
||||
@@ -1919,11 +1966,8 @@ class HouseplanCard extends LitElement {
|
||||
if (!this._markup && (disp.showBorders || disp.fill !== 'none')) {
|
||||
cls += ' styled';
|
||||
const st: string[] = [];
|
||||
if (disp.showBorders) {
|
||||
st.push(`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.opacity}`);
|
||||
} else {
|
||||
st.push('--room-stroke:transparent', '--room-stroke-op:0');
|
||||
}
|
||||
// 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,
|
||||
@@ -1999,6 +2043,7 @@ class HouseplanCard extends LitElement {
|
||||
const top = ((p.y - view.y) / view.h) * 100;
|
||||
const cls = this._stateClass(d);
|
||||
const temp = this._liveTemp(d);
|
||||
const hum = this._liveHum(d);
|
||||
const lqi = this._config?.show_signal && !d.virtual ? lqiFor(this.hass, d.entities) : null;
|
||||
return html`<div
|
||||
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''}"
|
||||
@@ -2006,7 +2051,7 @@ class HouseplanCard extends LitElement {
|
||||
@click=${(e: MouseEvent) => this._clickDevice(e, d)}
|
||||
@mousemove=${(e: MouseEvent) =>
|
||||
this._showTip(e, d.name,
|
||||
d.model + (temp != null ? ' · ' + temp + '°' : '') + (lqi != null ? ' · LQI ' + lqi : ''))}
|
||||
d.model + (temp != null ? ' · ' + temp + '°' : '') + (hum != null ? ' · ' + hum + '%' : '') + (lqi != null ? ' · LQI ' + lqi : ''))}
|
||||
@mouseleave=${() => (this._tip = null)}
|
||||
@pointerdown=${(e: PointerEvent) => this._pointerDown(e, d)}
|
||||
@pointermove=${(e: PointerEvent) => this._pointerMove(e, d)}
|
||||
@@ -2015,6 +2060,7 @@ class HouseplanCard extends LitElement {
|
||||
>
|
||||
<ha-icon icon="${d.icon}"></ha-icon>
|
||||
${temp != null ? html`<span class="tval">${temp}°</span>` : nothing}
|
||||
${hum != null ? html`<span class="hval">${hum}%</span>` : nothing}
|
||||
${lqi != null ? html`<span class="lqi" style="color:${lqiColor(lqi)}">${lqi}</span>` : nothing}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
+10
-1
@@ -170,5 +170,14 @@
|
||||
"fill.temp": "Temperature",
|
||||
"space.temp_min": "Comfort from",
|
||||
"space.temp_max": "to",
|
||||
"tip.temp_avg": "average temperature:"
|
||||
"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)",
|
||||
"marker.sub_entity": "entity"
|
||||
}
|
||||
|
||||
+10
-1
@@ -170,5 +170,14 @@
|
||||
"fill.temp": "По температуре",
|
||||
"space.temp_min": "Комфорт от",
|
||||
"space.temp_max": "до",
|
||||
"tip.temp_avg": "средняя температура:"
|
||||
"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)",
|
||||
"marker.sub_entity": "сущность"
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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] };
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
+25
-3
@@ -191,7 +191,7 @@ export const cardStyles = css`
|
||||
stroke: transparent;
|
||||
stroke-width: 2;
|
||||
}
|
||||
.room.overlay:hover {
|
||||
.room.overlay:not(.styled):hover {
|
||||
fill: #9aa0a6;
|
||||
fill-opacity: 0.22;
|
||||
stroke: var(--hp-muted);
|
||||
@@ -201,7 +201,7 @@ export const cardStyles = css`
|
||||
stroke: #4b8c5a;
|
||||
stroke-width: 2;
|
||||
}
|
||||
.room.yard:hover {
|
||||
.room.yard:not(.styled):hover {
|
||||
fill: rgba(75, 140, 90, 0.24);
|
||||
stroke: #6fbf86;
|
||||
}
|
||||
@@ -312,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);
|
||||
@@ -330,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;
|
||||
@@ -378,6 +383,23 @@ export const cardStyles = css`
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dev .hval {
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
margin-left: calc(var(--icon-size, 2.5cqw) * 0.1);
|
||||
background: var(--card-background-color, var(--hp-bg));
|
||||
border: 1px solid #4fc3f7;
|
||||
border-radius: calc(var(--icon-size, 2.5cqw) * 0.18);
|
||||
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14);
|
||||
font-size: calc(var(--icon-size, 2.5cqw) * 0.45);
|
||||
font-weight: 700;
|
||||
line-height: calc(var(--icon-size, 2.5cqw) * 0.68);
|
||||
color: var(--hp-txt);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dev .lqi {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
|
||||
@@ -61,6 +61,7 @@ export interface DevItem {
|
||||
entities: string[];
|
||||
primary?: string;
|
||||
temp?: number | null;
|
||||
hum?: number | null;
|
||||
virtual?: boolean;
|
||||
marker?: Marker; // linked config marker (metadata, overrides)
|
||||
bindingKind?: 'device' | 'entity' | 'virtual';
|
||||
|
||||
+57
-1
@@ -1,6 +1,6 @@
|
||||
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 { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp } from '../test-build/devices.js';
|
||||
import { compileIconRules } from '../test-build/rules.js';
|
||||
|
||||
/** Minimal fake hass around the pieces buildDevices reads. */
|
||||
@@ -224,3 +224,59 @@ test('isTempEntity: excludes chip temperature and diagnostic-category entities',
|
||||
// если в устройстве и чип, и настоящий — берётся настоящий
|
||||
assert.equal(tempFor(hass, ['sensor.plug_device_temperature', 'sensor.room_temperature']), 21);
|
||||
});
|
||||
|
||||
test('buildDevices: entity marker gets an auto icon + temperature (split a multi-entity device)', () => {
|
||||
const h = mkHass({
|
||||
entities: {
|
||||
'sensor.clim_temp': { entity_id: 'sensor.clim_temp', device_id: 'clim', platform: 'demo' },
|
||||
'sensor.clim_hum': { entity_id: 'sensor.clim_hum', device_id: 'clim', platform: 'demo' },
|
||||
},
|
||||
states: {
|
||||
'sensor.clim_temp': { state: '22.5', attributes: { device_class: 'temperature', friendly_name: 'Climate Temperature' } },
|
||||
'sensor.clim_hum': { state: '55', attributes: { device_class: 'humidity', friendly_name: 'Climate Humidity' } },
|
||||
},
|
||||
});
|
||||
const ctx = baseCtx(h, { markers: [
|
||||
{ id: 'e1', binding: 'entity:sensor.clim_temp' },
|
||||
{ id: 'e2', binding: 'entity:sensor.clim_hum' },
|
||||
] });
|
||||
const devs = buildDevices(ctx);
|
||||
const t = devs.find((d) => d.id === 'e1');
|
||||
const hum = devs.find((d) => d.id === 'e2');
|
||||
assert.equal(t.icon, 'mdi:thermometer');
|
||||
assert.equal(t.temp, 22.5);
|
||||
// humidity resolves to a real icon (device_class fallback), not the old generic shape
|
||||
assert.ok(hum && hum.icon && hum.icon !== 'mdi:shape-outline');
|
||||
assert.equal(hum.primary, 'sensor.clim_hum'); // more-info target
|
||||
});
|
||||
|
||||
test('humFor: reads a humidity entity as an integer %, ignores non-humidity', () => {
|
||||
const h = mkHass({ states: {
|
||||
'sensor.h': { state: '54.7', attributes: { device_class: 'humidity' } },
|
||||
'sensor.t': { state: '22', attributes: { device_class: 'temperature' } },
|
||||
}});
|
||||
assert.equal(humFor(h, ['sensor.t', 'sensor.h']), 55); // rounded
|
||||
assert.equal(humFor(h, ['sensor.t']), null);
|
||||
});
|
||||
|
||||
test('buildDevices: humidity entity marker shows a humidity badge (item.hum), water-percent icon', () => {
|
||||
const h = mkHass({
|
||||
entities: { 'sensor.clim_hum': { entity_id: 'sensor.clim_hum', device_id: 'clim', platform: 'demo' } },
|
||||
states: { 'sensor.clim_hum': { state: '54.7', attributes: { device_class: 'humidity', friendly_name: 'Climate Humidity' } } },
|
||||
});
|
||||
const hum = buildDevices(baseCtx(h, { markers: [{ id: 'e2', binding: 'entity:sensor.clim_hum' }] })).find((d) => d.id === 'e2');
|
||||
assert.equal(hum.icon, 'mdi:water-percent');
|
||||
assert.equal(hum.hum, 55);
|
||||
assert.equal(hum.temp, undefined);
|
||||
});
|
||||
|
||||
test('buildDevices: humidity badge is gated on device_class, not the icon (name may force another icon)', () => {
|
||||
const h = mkHass({
|
||||
entities: { 'sensor.mh_hum': { entity_id: 'sensor.mh_hum', device_id: 'mh', platform: 'demo' } },
|
||||
// name contains "myheat" → icon rule resolves to water-boiler, NOT water-percent…
|
||||
states: { 'sensor.mh_hum': { state: '45.2', attributes: { device_class: 'humidity', friendly_name: 'Myheat Влажность 1 этаж' } } },
|
||||
});
|
||||
const d = buildDevices(baseCtx(h, { markers: [{ id: 'e', binding: 'entity:sensor.mh_hum' }] })).find((x) => x.id === 'e');
|
||||
assert.notEqual(d.icon, 'mdi:water-percent'); // name rule won the icon
|
||||
assert.equal(d.hum, 45); // …but the humidity value is still shown (gated on device_class)
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
+3
-2
@@ -11,6 +11,7 @@
|
||||
"src/logic.ts",
|
||||
"src/rules.ts",
|
||||
"src/devices.ts",
|
||||
"src/types.ts"
|
||||
"src/types.ts",
|
||||
"src/space-geometry.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user