Compare commits

...
10 Commits
Author SHA1 Message Date
Matysh 40cb0302e3 Release v1.44.8
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 6s
Validate / frontend (push) Successful in 1m23s
Validate / backend (push) Failing after 6m59s
Validate / smoke (push) Successful in 7m26s
v1.44.6 room climate counts only air temperature
v1.44.7 plan backgrounds never displayed (signed-url regression)
v1.44.8 an uploaded plan never reached the config
2026-07-27 15:36:09 +03:00
Matysh 14cc4df4bd chore: sync the committed card bundle with dist (v1.44.8)
Validate / hacs (push) Failing after 11s
Validate / hassfest (push) Failing after 9s
Validate / frontend (push) Successful in 1m36s
Validate / backend (push) Failing after 6m41s
Validate / smoke (push) Failing after 37s
CI checks `cmp dist == custom_components/houseplan/frontend`; the three
previous commits shipped source and docs without the rebuilt bundle, so
validate.yml failed on all of them. Same folder that HA serves statically —
the one that must never be skipped.
2026-07-27 15:33:21 +03:00
Matysh ead56dd9b6 v1.44.8: an uploaded plan never reached the config
Found on the owner's install: the image lands in /config/houseplan/plans, the
space keeps plan_url=null, the plan never shows and re-saving does not help.

_saveSpaceDialog held a reference to the space object across the await that
uploads the file. _reloadConfigOnly() — which runs on every
houseplan_config_updated event — REPLACES _serverCfg, so that reference became
an orphan: plan_url, aspect, title and every display setting were written into a
detached object while the save shipped the untouched config. In 'create' mode
the whole new space was lost the same way.

- upload first, then touch the config; no reference is held across an await.
- _saveConfigNow() sets _cfgWriting like the debounced writer, so a revision
  arriving mid-save defers its reload instead of replacing the config (audit L2
  extended to this path).
- demo/smoke_plan_upload_race.mjs: on v1.44.7 the sent config still carries the
  OLD plan_url and the created space is missing; passes here. The demo's
  config/get now returns a fresh object, as a real server does — returning the
  same reference is what hid this class of bug from the smoke layer.
- DEVELOPMENT.md: the deploy target is custom_components/houseplan/frontend/,
  and deploy verification must go over HTTP. A copy placed next to __init__.py
  is served by nobody — that cost two deployments today.
- docs: CHANGELOG.md + CHANGELOG.ru.md + TESTING.md + STATUS.md.
2026-07-27 15:14:19 +03:00
Matysh 018b37940f v1.44.7: plan backgrounds never displayed (regression from v1.44.5)
The card signs content urls because a browser cannot authenticate an <image
href>. But _display() was called inside _buildModel(), and the space model is
memoized on the config fingerprint — so the UNSIGNED url froze in the cache and
the signature, which did arrive, never reached the element. The plan never
loaded, and the browser kept hitting the unsigned path: 401, which Home
Assistant reports as a failed login attempt from the viewer's own IP (that is
how the owner spotted it). PDF links were unaffected: they already resolved at
render time.

- _buildModel() keeps the raw plan_url; the render pass calls _display().
- _display() returns '' for an unsigned content url instead of the plain path,
  and the <image> is not emitted at all until the signature lands — no 401, no
  spurious login-attempt warning.
- _resign() replaces 'drop everything and re-request': the previous urls are
  kept until the new ones arrive, so a wall tablet never blanks.
- demo/smoke_plan_signed.mjs: reproduces on v1.44.6 (href stays ?v=..., never
  ?authSig=), passes here. TESTING.md row added.
- docs: CHANGELOG.md + CHANGELOG.ru.md + STATUS.md.
2026-07-27 15:05:46 +03:00
Matysh ebeaa5c0c6 v1.44.6: room climate counts only air temperature
After v1.44.5 read the area registry instead of visible icons, every hidden
temperature entity in the area became a candidate, including ones measuring
something other than room air. Verified against a live 60-area install: a NAS
processor temperature, kettle water, a 90 C sauna heater and a virtual
better_thermostat all leaked into room averages.

- areaClimate(): skip entity_category (diagnostic/config), skip EXCLUDED_DOMAINS
  platforms, skip entity ids naming a non-air medium (water/coolant/flow_temp/
  return_temp/target/setpoint/chip/cpu/processor/board/device_temp/batter/
  freezer/fridge/oven/kettle/boiler).
- rules.ts: kettle/thermopot -> mdi:kettle, sauna/harvia -> mdi:hot-tub, so they
  no longer fall through to the generic thermometer rule.
- test: all four real false positives asserted out, one real sensor left.
- docs: CHANGELOG.md + CHANGELOG.ru.md + STATUS.md snapshot.
2026-07-27 14:38:50 +03:00
Matysh 02ba18dc7b Merge dev: v1.44.3..v1.44.5 (B1 regression fix, audit follow-up, room climate) 2026-07-27 14:24:46 +03:00
Matysh 715a93ec61 fix v1.44.5: room climate counts hidden sensors; drop the stale room tooltip
- areaClimate() walks the HA registry for the area instead of the list
  of VISIBLE icons: a thermometer hidden by curation or by the user was
  silently dropped from the room card, tooltip and temperature fill
  (field report). Curation still filters fridges/TRVs; the auto icon is
  used on purpose so a custom marker icon cannot change what a device
  measures; an explicit per-room source still wins
- room tooltip no longer says 'open the area' — room clicks were removed
  in v1.40.1 (the link icon does it)
- +1 unit test (120); both changelogs updated
2026-07-27 14:21:50 +03:00
Matysh 09b0ba41a5 fix v1.44.4: audit follow-up B2, B5, L4 sub-item
B2: the HTTP upload view failed OPEN when the config entry was
unavailable while the WS path failed closed — both now share one
may_write() policy helper (new auth.py) that denies non-admins when the
policy cannot be read.

B5: _finite now guards room rects, polygon vertices, view_box and
opening coordinates, not just layout positions; the declared
MAX_OPENINGS cap is finally enforced.

L4 (sub-item): every drag pipeline captures the pointer through the
tolerant helper (an inactive pointerId used to kill device/label/resize
drags); decor shapes gained a bounds clamp so they cannot be dragged far
outside the plan and persisted there.

+2 backend tests (16); both changelogs updated in this commit
2026-07-27 14:14:25 +03:00
Matysh 0467cee98a fix v1.44.3: signed content paths — plans and PDFs load again (B1 regression)
The v1.43.0 auth fix closed the hole but left the DISPLAY path
unauthenticated: HA authenticates by a Bearer header or an authSig
signed path, and an <image href> / <a href> sends neither, so plan
backgrounds and manual links returned 401. Reproduced live before the
fix (fetch 401, Image onerror).

- new WS houseplan/content/sign mints async_sign_path urls (24 h,
  bound to the connection's refresh token, only for our own endpoint)
- the card resolves display urls through _display(): signed when known,
  requests a batched signature otherwise, re-renders when it lands, and
  drops all signatures every 12 h so long-lived wall tablets stay valid
- houseplan-space-card signs its background too
- backend test asserts the unsigned url is refused and the signed one
  returns the bytes WITHOUT an Authorization header
2026-07-27 14:08:29 +03:00
Matysh c0653dfc73 docs: add docs/CHANGELOG.ru.md (Russian changelog from v1.42.0)
- 10 most recent releases translated; older entries stay English-only
- policy updated in STATUS.md and CONTRIBUTING: user-visible changes go
  into BOTH changelogs in the same commit (the user base is largely
  Russian-speaking — see the Telegram chat)
- cross-links between the two files and from both READMEs
2026-07-27 13:57:48 +03:00
29 changed files with 1146 additions and 145 deletions
+6
View File
@@ -3,6 +3,12 @@
Thanks for your interest! The project is one HACS package: a storage **integration**
(`custom_components/houseplan/`, Python) and a **Lovelace card** (`src/`, TypeScript + Lit).
## Changelog
User-visible changes go into **both** changelogs in the same commit:
`docs/CHANGELOG.md` (English) and `docs/CHANGELOG.ru.md` (Russian). Entries
older than v1.42.0 exist only in the English file — no need to backfill them.
## Where to ask
Not sure whether something is a bug, or just want to discuss an idea before
+2
View File
@@ -268,6 +268,8 @@ turned the way it is mounted.
reports and feature requests (please attach your House Plan version).
- 💡 [GitHub discussions](https://github.com/Matysh/houseplan-card/discussions) —
longer-form ideas.
- 📜 [Changelog](docs/CHANGELOG.md) — what changed in every version
([на русском](docs/CHANGELOG.ru.md)).
When reporting a problem, the version number helps a lot: it is shown in the
browser console on load (`HOUSEPLAN-CARD vX.Y.Z`) and in **Settings → Devices &
+1
View File
@@ -271,6 +271,7 @@ title: План дома
и запросы фич (пожалуйста, указывайте версию House Plan).
- 💡 [Discussions](https://github.com/Matysh/houseplan-card/discussions) — для
развёрнутых обсуждений.
- 📜 [История изменений](docs/CHANGELOG.ru.md) — что менялось в каждой версии.
Версия видна в консоли браузера при загрузке (`HOUSEPLAN-CARD vX.Y.Z`) и в
**Настройки → Устройства и службы → House Plan** — с ней разбираться сильно
+28
View File
@@ -0,0 +1,28 @@
"""Single source of truth for the write-authorization policy.
The WS and HTTP paths used to duplicate this decision and drifted apart: the
WS copy was fixed to fail closed while the upload view still failed OPEN when
the config entry was unavailable (audit follow-up B2, 2026-07-27). One helper,
one behaviour.
"""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from .const import CONF_ADMIN_ONLY
from .store import get_entry
def may_write(hass: HomeAssistant, user) -> bool:
"""True when `user` may modify House Plan data.
Fails CLOSED: when the entry cannot be read — during a reload, or while the
integration is disabled — the policy is unknown, and "unknown" is not the
same as "permissive": only admins are allowed through.
"""
is_admin = bool(getattr(user, "is_admin", False))
entry = get_entry(hass)
if entry is None:
return is_admin
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, False))
return is_admin if admin_only else True
+1 -1
View File
@@ -13,7 +13,7 @@ FILES_URL = "/houseplan_files/files"
CONTENT_URL = "/api/houseplan/content"
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.44.2"
VERSION = "1.44.8"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+3 -7
View File
@@ -19,7 +19,7 @@ except ImportError: # older HA versions
from homeassistant.core import HomeAssistant
from .const import CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, PLANS_DIR
from .store import get_entry
from .auth import may_write
from .validation import (
FILE_EXTENSIONS,
MAX_FILE_BYTES,
@@ -95,12 +95,8 @@ class HouseplanUploadView(HomeAssistantView):
async def post(self, request: web.Request) -> web.Response:
hass: HomeAssistant = request.app[KEY_HASS]
entry = get_entry(hass)
admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False))
if admin_only:
user = request.get("hass_user")
if user is None or not user.is_admin:
return web.json_response({"error": "unauthorized"}, status=403)
if not may_write(hass, request.get("hass_user")):
return web.json_response({"error": "unauthorized"}, status=403)
marker_id = "misc"
filename: str | None = None
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.44.2"
"version": "1.44.8"
}
+11 -11
View File
@@ -73,7 +73,7 @@ POS_SCHEMA = vol.Schema(
)
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
POINT = vol.All([vol.Coerce(float)], vol.Length(min=2, max=2))
POINT = vol.All([_finite], vol.Length(min=2, max=2))
def _require_geometry(room: dict) -> dict:
@@ -102,10 +102,10 @@ ROOM_SCHEMA = vol.All(
extra=vol.ALLOW_EXTRA,
),
),
vol.Optional("x"): vol.Coerce(float),
vol.Optional("y"): vol.Coerce(float),
vol.Optional("w"): vol.Coerce(float),
vol.Optional("h"): vol.Coerce(float),
vol.Optional("x"): _finite,
vol.Optional("y"): _finite,
vol.Optional("w"): _finite,
vol.Optional("h"): _finite,
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3)),
},
extra=vol.ALLOW_EXTRA,
@@ -161,17 +161,17 @@ SPACE_SCHEMA = vol.Schema(
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)),
vol.Required("view_box"): vol.All([_finite], vol.Length(min=4, max=4)),
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
vol.Optional("openings"): [
vol.Optional("openings"): vol.All([
vol.Schema(
{
vol.Required("id"): str,
vol.Required("type"): vol.Any("door", "window"),
vol.Required("x"): vol.Coerce(float),
vol.Required("y"): vol.Coerce(float),
vol.Required("angle"): vol.Coerce(float),
vol.Required("x"): _finite,
vol.Required("y"): _finite,
vol.Required("angle"): _finite,
vol.Required("length"): vol.All(vol.Coerce(float), vol.Range(min=0.001, max=1)),
vol.Optional("contact"): vol.Any(str, None),
vol.Optional("lock"): vol.Any(str, None),
@@ -181,7 +181,7 @@ SPACE_SCHEMA = vol.Schema(
},
extra=vol.ALLOW_EXTRA,
)
],
], vol.Length(max=MAX_OPENINGS)),
# Legacy: walls are derived from room outlines since v1.19.0 — a line has no
# independent existence. Still accepted so a stale browser tab cannot fail a save;
# the card strips the field on every write.
+44 -12
View File
@@ -17,6 +17,7 @@ from .const import (
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
CONTENT_URL, PLANS_DIR, PLANS_URL,
)
from .auth import may_write
from .store import HouseplanData, get_data, get_entry
from .validation import (
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_PLAN_BYTES,
@@ -39,6 +40,7 @@ def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_plan_set)
websocket_api.async_register_command(hass, ws_files_migrate)
websocket_api.async_register_command(hass, ws_files_cleanup)
websocket_api.async_register_command(hass, ws_content_sign)
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
@@ -55,18 +57,8 @@ def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | No
def _check_write(hass: HomeAssistant, connection) -> bool:
"""May this connection write?
Fails CLOSED (audit B2): when the entry cannot be read — during a reload or
while the integration is disabled — the policy is unknown, and "unknown" is
not the same as "permissive". Previously this returned True and ws_plan_set,
which never touches the runtime helper, accepted uploads in that window.
"""
entry = get_entry(hass)
if entry is None:
return bool(getattr(connection.user, "is_admin", False))
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, False))
return connection.user.is_admin if admin_only else True
"""May this connection write? Thin wrapper over the shared policy."""
return may_write(hass, getattr(connection, "user", None))
# ---------------- layout ----------------
@@ -210,6 +202,46 @@ async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any])
connection.send_result(msg["id"], {"ok": True, "mapping": mapping, "copied": len(mapping)})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/content/sign",
vol.Required("paths"): [str],
}
)
@websocket_api.async_response
async def ws_content_sign(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Sign content paths so the BROWSER can fetch them.
Home Assistant authenticates HTTP requests by a Bearer header or an
`authSig` signed path — there is no cookie auth. An <image href> inside SVG
and a plain <a href> can send neither, so after the content endpoint became
`requires_auth` the plan backgrounds and PDF links returned 401 (audit
follow-up B1 regression, 2026-07-27 — reproduced live).
The card asks for signatures and uses the signed urls for display.
"""
from datetime import timedelta
from homeassistant.components.http.auth import async_sign_path
out: dict[str, str] = {}
token_id = getattr(connection, "refresh_token_id", None)
for path in msg["paths"][:200]:
if not isinstance(path, str) or not path.startswith(CONTENT_URL + "/"):
continue # only ever sign our own content endpoint
clean = path.split("?", 1)[0]
try:
try:
signed = async_sign_path(hass, clean, timedelta(hours=24), refresh_token_id=token_id)
except TypeError: # older HA signature: (hass, refresh_token_id, path, expiration)
signed = async_sign_path(hass, token_id, clean, timedelta(hours=24))
except Exception as err: # noqa: BLE001 — signing must never break the card
_LOGGER.warning("House Plan: could not sign %s: %s", clean, err)
continue
out[path] = signed
connection.send_result(msg["id"], {"urls": out})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/files/cleanup",
+76
View File
@@ -0,0 +1,76 @@
// Подложка (фон плана) лежит за requires_auth-эндпоинтом: браузер не умеет
// авторизовать <image href>, поэтому карточка просит бэкенд подписать путь.
// Регрессия 2026-07-27: _display() вызывался внутри _buildModel(), а модель
// мемоизируется по отпечатку конфига — неподписанный url «замерзал» в кэше,
// подпись до <image> не доезжала. План не отображался никогда, а браузер
// продолжал дёргать неподписанный путь → 401 → HA писал «неудачный вход»
// с собственного IP пользователя.
import { launch, checkAll, finish } 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 bgHref = () => {
const im = sr().querySelector('.stage svg image');
return im ? im.getAttribute('href') : null;
};
let signCalls = 0;
let release;
const gate = new Promise((r) => { release = r; });
const base = c.hass.callWS;
c.hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/content/sign') {
signCalls++;
const n = signCalls;
if (n === 1) await gate;
const urls = {};
for (const p of m.paths) urls[p] = p.split('?')[0] + '?authSig=SIG' + n;
return { urls };
}
return base(m);
} };
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : {
...s, plan_url: '/api/houseplan/content/plans/_/f1.svg?v=17831509',
})};
c._cfgEpoch++;
c.requestUpdate(); await c.updateComplete;
// до подписи ничего не рисуем: неподписанный запрос вернул бы 401
out.hrefBeforeSign = bgHref();
release();
await new Promise((r) => setTimeout(r, 150));
await c.updateComplete;
// подпись доехала до атрибута, а не осела в кэше модели
out.signRequested = signCalls;
out.hrefSigned = bgHref();
// перерисовка по состоянию HA не теряет подпись и не просит её заново
c.requestUpdate(); await c.updateComplete;
out.hrefAfterRerender = bgHref();
out.signRequestedAfterRerender = signCalls;
// ре-подпись на долгоживущем экране: старый url держится до нового ответа
const before = bgHref();
c._resign();
out.resignKeepsPlan = bgHref() === before;
await new Promise((r) => setTimeout(r, 80));
await c.updateComplete;
out.hrefAfterResign = bgHref();
return out;
});
// зафиксировано прогоном на v1.44.7 и сверено с кодом
checkAll(res, {
hrefBeforeSign: null,
signRequested: 1,
hrefSigned: '/api/houseplan/content/plans/_/f1.svg?authSig=SIG1',
hrefAfterRerender: '/api/houseplan/content/plans/_/f1.svg?authSig=SIG1',
signRequestedAfterRerender: 1,
resignKeepsPlan: true,
hrefAfterResign: '/api/houseplan/content/plans/_/f1.svg?authSig=SIG2',
});
await finish(browser);
+70
View File
@@ -0,0 +1,70 @@
// Загрузка подложки: ссылка обязана долететь до конфига.
// Баг 2026-07-27 (найден на боевой установке): _saveSpaceDialog держал ссылку
// на объект пространства через await загрузки файла. Любое событие
// houseplan_config_updated в этот момент вызывает _reloadConfigOnly(), которое
// ЗАМЕНЯЕТ _serverCfg — и plan_url/aspect/settings уезжали в осиротевший
// объект, а на сервер уходил нетронутый конфиг. Симптом: файл на диске есть,
// подложки нет, пересохранение не помогает.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const base = c.hass.callWS;
let reloadDuringUpload = 0;
c.hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/plan/set') {
// пока файл «загружается», прилетает чужая ревизия конфига
reloadDuringUpload++;
await c._reloadConfigOnly(true);
return { ok: true, url: '/api/houseplan/content/plans/_/' + m.space_id + '.png?v=42' };
}
if (m.type === 'houseplan/config/set') { c.__sent = m.config; return { ok: true, rev: 99 }; }
if (m.type === 'houseplan/config/get') {
// сервер отдаёт СВЕЖИЙ объект, а не тот же самый — как в реальном HA
const r = await base(m);
return { ...r, config: JSON.parse(JSON.stringify(r.config)) };
}
return base(m);
} };
// редактирование существующего пространства: подложка + новый заголовок
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'Ground', source: 'file',
planFile: { ext: 'png', b64: 'AAAA', aspect: 1.6 } };
await c._saveSpaceDialog(); await c.updateComplete;
out.reloadHappened = reloadDuringUpload === 1;
const sentF1 = (c.__sent?.spaces || []).find((s) => s.id === 'f1');
const liveF1 = (c._serverCfg?.spaces || []).find((s) => s.id === 'f1');
out.sentPlanUrl = sentF1?.plan_url;
out.sentAspect = sentF1?.aspect;
out.sentTitle = sentF1?.title;
out.livePlanUrl = liveF1?.plan_url;
out.dialogClosed = c._spaceDialog === null;
// создание пространства при том же сбое: оно должно доехать целиком
c._openSpaceDialog('create'); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'Attic', source: 'file',
planFile: { ext: 'png', b64: 'BBBB', aspect: 0.8 } };
await c._saveSpaceDialog(); await c.updateComplete;
const attic = (c.__sent?.spaces || []).find((s) => s.title === 'Attic');
out.atticSaved = !!attic;
out.atticHasPlan = !!attic && typeof attic.plan_url === 'string' && attic.plan_url.includes('/content/plans/');
out.atticAspect = attic?.aspect;
return out;
});
// зафиксировано прогоном на v1.44.8 и сверено с кодом
checkAll(res, {
reloadHappened: true,
sentPlanUrl: '/api/houseplan/content/plans/_/f1.png?v=42',
sentAspect: 1.6,
sentTitle: 'Ground',
livePlanUrl: '/api/houseplan/content/plans/_/f1.png?v=42',
dialogClosed: true,
atticSaved: true,
atticHasPlan: true,
atticAspect: 0.8,
});
await finish(browser);
File diff suppressed because one or more lines are too long
+24 -24
View File
File diff suppressed because one or more lines are too long
+97
View File
@@ -1,5 +1,102 @@
# Changelog
## v1.44.8 — 2026-07-27
- **An uploaded plan is actually attached to the space.** `_saveSpaceDialog`
held a reference to the space object across the `await` that uploads the
image. Every `houseplan_config_updated` event runs `_reloadConfigOnly()`,
which *replaces* `_serverCfg` — so the reference became an orphan and
`plan_url`, `aspect`, the title and all display settings were written into a
detached object while the save shipped the untouched config. The file landed
on disk, the plan never appeared, and re-saving could not help. Creating a
space in that window lost the space entirely.
The upload now happens *before* the config is touched, and nothing is held
across an await.
- **`_saveConfigNow` marks the write in flight** (`_cfgWriting`), like the
debounced writer already did, so a remote revision arriving mid-save defers
its reload instead of replacing the config underneath it (audit L2 extended
to this path).
- Regression test `demo/smoke_plan_upload_race.mjs` fails on v1.44.7 and passes
here.
## v1.44.7 — 2026-07-27
- **Plan backgrounds are visible again (regression from v1.44.5).** Since the
content endpoint requires authentication, the card asks the backend to sign
the plan's url — but the signing happened inside the *memoized* space model,
which is cached on the config fingerprint. The unsigned url froze in that
cache, so the signature never reached the `<image>` element and the plan never
loaded. The url is now resolved at render time, outside the cache. (PDF links
were unaffected — they already resolved at render time.)
- **No more "failed login attempt" from your own IP.** While the plan was
broken the browser kept requesting the unsigned path, which returns 401 and
makes Home Assistant raise a login-attempt warning for the viewer's own
address. The card now renders nothing until the signature is in hand, so an
unsigned request is never made.
- **Long-lived screens no longer blink.** The 12-hour re-signing used to drop
every signature and wait for new ones; it now keeps the current urls until the
replacements arrive, so a wall tablet never shows an empty plan.
- Regression test `demo/smoke_plan_signed.mjs` fails on v1.44.6 and passes here.
## v1.44.6 — 2026-07-27
- **Only room *air* counts as room climate.** After v1.44.5 started reading the
area registry instead of the visible icons, every hidden temperature entity in
the area became a candidate — including ones that measure something other than
the air. Three guards now run before averaging: entities marked
diagnostic/config are skipped, entities from curated-out integrations are
skipped, and entity ids naming a non-air medium are skipped
(`water`, `coolant`, `flow_temp`, `return_temp`, `target`, `setpoint`, `chip`,
`cpu`, `processor`, `board`, `device_temp`, `batter`, `freezer`, `fridge`,
`oven`, `kettle`, `boiler`).
On a live 60-area install this removed four real false positives: a NAS
processor temperature, the water in a smart kettle, a 90 °C sauna heater and a
virtual `better_thermostat` duplicating the real sensor.
- **New icon rules:** kettles/thermopots get `mdi:kettle`, saunas
(`sauna`, `harvia`, `парная`) get `mdi:hot-tub` — previously both fell through
to the generic thermometer rule, which is also what made them count as room
climate.
## v1.44.5 — 2026-07-27
- **Room climate now counts every sensor in the area**, including devices that
are not placed on the plan (hidden by curation or by you). Previously the
average was taken over the visible icons only, so hiding a thermometer
silently removed it from the room card, the tooltip and the temperature fill.
Curation still applies (fridges, TRVs and chip-temperature plugs stay out),
and an explicit per-room source still wins.
- The room tooltip no longer says "open the area" — clicking a room stopped
navigating in v1.40.1; the link icon on the room card does that.
## v1.44.4 — 2026-07-27 (audit follow-up: B2, B5, L4)
- **One authorization policy (B2).** The HTTP upload view still failed **open**
when the config entry was unavailable while the WebSocket path failed closed —
the two had drifted apart. Both now call the same `may_write` helper, which
denies non-admins whenever the policy cannot be read.
- **NaN/Infinity refused on every coordinate (B5).** The finite-number check
guarded only layout positions; room rects, polygon vertices, `view_box` and
opening coordinates accepted `"NaN"`, which serializes to `null` and corrupts
the stored geometry permanently. The `MAX_OPENINGS` cap was defined but never
wired in — the openings list was unbounded.
- **Drag hardening (L4 sub-item).** The tolerant `setPointerCapture` wrapper is
now used by every drag pipeline (device, label, resize), not just openings —
an inactive pointer id could kill a drag outright. Decor shapes gained a
bounds clamp: they can no longer be dragged far outside the plan and saved
there.
## v1.44.3 — 2026-07-27 (fix: plans and manuals load again)
- **The authenticated content endpoint had no working browser path.** v1.43.0
closed the security hole correctly, but Home Assistant authenticates HTTP
requests by a Bearer header or an `authSig` signed path — and an SVG
`<image href>` or a plain `<a href>` sends neither. Plan backgrounds and PDF
links returned **401** on a real dashboard (reproduced live before the fix).
The card now asks the backend to sign what it displays
(`houseplan/content/sign`, 24 h, bound to the session's refresh token, only
for our own endpoint), re-renders when signatures arrive, and refreshes them
every 12 hours so wall tablets keep working. A backend test fetches a signed
url **without** an Authorization header and asserts 200, and 401 without the
signature.
> 🇷🇺 Русская версия: [CHANGELOG.ru.md](CHANGELOG.ru.md) (записи с v1.42.0).
## v1.44.2 — 2026-07-27 (external code review: CR-1…CR-3)
A second, adversarial review (of v1.44.0) produced three findings; all are
+281
View File
@@ -0,0 +1,281 @@
# История изменений
> Русская версия [docs/CHANGELOG.md](CHANGELOG.md). Переведены записи начиная
> с v1.42.0 (2026-07-26); более ранние доступны только в английском файле.
>
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
## v1.44.8 — 2026-07-27
- **Загруженная подложка действительно привязывается к пространству.**
`_saveSpaceDialog` держал ссылку на объект пространства через `await`
загрузки картинки. Любое событие `houseplan_config_updated` запускает
`_reloadConfigOnly()`, а оно *заменяет* `_serverCfg` — ссылка становилась
осиротевшей, и `plan_url`, `aspect`, заголовок и все настройки отображения
писались в отсоединённый объект, тогда как на сервер уходил нетронутый
конфиг. Файл попадал на диск, подложка не появлялась, пересохранение не
помогало. Создание пространства в этот момент теряло пространство целиком.
Теперь загрузка идёт *до* обращения к конфигу, и ни одна ссылка не живёт
через await.
- **`_saveConfigNow` помечает запись как выполняющуюся** (`_cfgWriting`) — так
же, как отложенный писатель, — поэтому чужая ревизия, пришедшая посреди
сохранения, откладывает перечитывание вместо подмены конфига (аудит L2,
расширен на этот путь).
- Регрессионный тест `demo/smoke_plan_upload_race.mjs` падает на v1.44.7 и
проходит здесь.
## v1.44.7 — 2026-07-27
- **Подложки снова отображаются (регрессия с v1.44.5).** Эндпоинт с файлами
требует авторизации, поэтому карточка просит бэкенд подписать ссылку на план —
но подпись подставлялась внутри *мемоизированной* модели пространства, а она
кэшируется по отпечатку конфига. Неподписанная ссылка «замерзала» в кэше,
подпись до элемента `<image>` не доезжала, и план не грузился никогда. Теперь
ссылка вычисляется в момент отрисовки, вне кэша. (Ссылки на PDF не страдали —
там она и так вычислялась при отрисовке.)
- **Больше нет «неудачной попытки входа» с собственного IP.** Пока подложка была
сломана, браузер продолжал дёргать неподписанный путь, тот отвечал 401, и
Home Assistant поднимал предупреждение о неудачном входе с адреса самого
зрителя. Теперь до получения подписи не рисуется ничего, и неподписанный
запрос не уходит вовсе.
- **Долгоживущие экраны не моргают.** Переподписывание раз в 12 часов раньше
сбрасывало все подписи и ждало новые; теперь текущие ссылки держатся до
прихода замены, так что настенный планшет не показывает пустой план.
- Регрессионный тест `demo/smoke_plan_signed.mjs` падает на v1.44.6 и проходит
здесь.
## v1.44.6 — 2026-07-27
- **Климатом комнаты считается только температура *воздуха*.** После v1.44.5,
когда данные стали браться из реестра зон, а не с видимых значков,
кандидатами стали все скрытые датчики температуры в зоне — в том числе те,
что меряют вовсе не воздух. Перед усреднением теперь работают три фильтра:
пропускаются сущности с категорией диагностика/настройка, сущности
исключённых интеграций и сущности, в id которых назван не-воздушный носитель
(`water`, `coolant`, `flow_temp`, `return_temp`, `target`, `setpoint`, `chip`,
`cpu`, `processor`, `board`, `device_temp`, `batter`, `freezer`, `fridge`,
`oven`, `kettle`, `boiler`).
На живой установке с 60 зонами это убрало четыре реальных ложных
срабатывания: температуру процессора NAS, воду в умном чайнике, сауну с 90 °C
и виртуальный `better_thermostat`, дублирующий настоящий датчик.
- **Новые правила иконок:** чайники и термопоты получают `mdi:kettle`, сауны
(`sauna`, `harvia`, `парная`) — `mdi:hot-tub`. Раньше и те и другие попадали
под общее правило термометра, из-за чего и учитывались в климате комнаты.
## v1.44.5 — 2026-07-27
- **Климат комнаты считается по всем датчикам зоны**, включая устройства,
которых нет на плане (скрыты курированием или вами). Раньше среднее бралось
только по видимым значкам, поэтому скрытый термометр молча выпадал из
карточки комнаты, подсказки и температурной заливки. Курирование сохранено
(холодильники, термоголовки и розетки с температурой чипа не считаются), а
явно выбранный источник в настройках комнаты по-прежнему главнее.
- Из подсказки к комнате убрана фраза «открыть зону» — клик по комнате перестал
никуда вести ещё в v1.40.1, для перехода есть значок-ссылка у названия.
## v1.44.4 — 2026-07-27 (доработка по аудиту: B2, B5, L4)
- **Единая политика авторизации (B2).** HTTP-загрузка по-прежнему **разрешала**
запись, когда запись о конфигурации недоступна, тогда как WebSocket-путь уже
отказывал — они разошлись. Теперь оба вызывают общий помощник `may_write`,
который в неопределённой ситуации пропускает только администраторов.
- **NaN/Infinity отвергаются во всех координатах (B5).** Проверка на конечность
числа стояла только у позиций раскладки; прямоугольники комнат, вершины
полигонов, `view_box` и координаты проёмов принимали `"NaN"`, который при
записи превращается в `null` и необратимо портит геометрию. Ограничение
`MAX_OPENINGS` было объявлено, но нигде не использовалось — список проёмов
оставался безразмерным.
- **Укрепление перетаскивания (часть L4).** Безопасная обёртка над
`setPointerCapture` теперь используется во всех сценариях перетаскивания
(устройства, подписи, изменение размера), а не только у проёмов — «мёртвый»
идентификатор указателя мог оборвать перетаскивание. Фигуры декора получили
ограничение по границам: их больше нельзя утащить далеко за пределы плана и
сохранить там.
## v1.44.3 — 2026-07-27 (исправление: планы и инструкции снова загружаются)
- **У аутентифицированной выдачи контента не было рабочего пути для браузера.**
Версия v1.43.0 закрыла дыру правильно, но Home Assistant аутентифицирует
HTTP-запросы либо заголовком Bearer, либо подписанным путём `authSig` — а
`<image href>` внутри SVG и обычная ссылка `<a href>` не отправляют ни того,
ни другого. На настоящем дашборде фоны планов и ссылки на PDF отдавали
**401** (воспроизведено вживую до исправления). Теперь карточка просит бэкенд
подписать то, что собирается показать (`houseplan/content/sign`, 24 часа,
привязано к токену сессии, только для нашего эндпоинта), перерисовывается,
когда подписи приходят, и обновляет их каждые 12 часов, чтобы настенные
планшеты продолжали работать. Тест бэкенда скачивает подписанный адрес **без**
заголовка авторизации и проверяет 200, а без подписи — 401.
## v1.44.2 — 2026-07-27 (внешнее код-ревью: CR-1…CR-3)
Второе, состязательное ревью (версии v1.44.0) дало три находки — все закрыты.
- **Правило про замки теперь сформулировано точно и проверяется (CR-1).**
Рецензент справедливо отметил, что утверждение «замок нельзя открыть с плана»
было слишком абсолютным: кнопка в карточке двери действительно вызывает
сервис. Эта кнопка — осознанное продуктовое решение, поэтому правило
переписано там, где ему место («никогда случайным нажатием; ровно одна
подписанная поверхность»), отпирание теперь **спрашивает подтверждение**, а
новый смок-тест проверяет все пять путей управления и доказывает, что значки,
`controls[]` и карточка устройства по-прежнему отказывают замкам.
- **Перенос вложений стал транзакционным (CR-2).** При смене привязки маркера
файлы раньше ПЕРЕМЕЩАЛИСЬ до сохранения конфига с проверкой ревизии: если
сохранение отклонялось, на сервере оставались старые ссылки, а файлы уже
уехали. Теперь сервер копирует, конфиг фиксируется, и только после этого
старая папка удаляется (`houseplan/files/cleanup`).
- **Неудачный или частичный перенос больше не переписывает ссылки (CR-3).**
Копирование возвращает точное соответствие «исходное имя → записанное»;
переписываются только подтверждённые копии, при совпадении имён файл получает
уникальное имя вместо молчаливой ссылки на чужой файл, а ошибка переноса
показывается тостом.
## v1.44.1 — 2026-07-27
- Ссылка на чат сообщества добавлена везде, где её ищут:
**https://t.me/ha_houseplan** (бейдж и строка в шапке обоих README, раздел
«Помощь и обмен опытом», контакт-ссылки в шаблонах issue, CONTRIBUTING,
STATUS и SCOPE).
## v1.44.0 — 2026-07-27 (отзыв пользователя: сначала управление)
- **Карточка устройства стала поверхностью управления.** Она открывается со
списка управляемых сущностей: лампы, розетки и вентиляторы переключаются
прямо здесь кнопками под палец, шторы, замки и климат передают управление в
штатный more-info Home Assistant. Модель, ссылки и PDF-инструкции ушли ниже —
на настенном планшете эта карточка нужна для управления домом, а не для
чтения документации (из полевого отзыва). Служебные (config/diagnostic)
сущности в списке не показываются, замки по-прежнему не переключаются
нажатием в карточке.
- **«Это устройство — источник света»** — новый флаг у устройства. Умный
выключатель с обычными (не умными) светильниками теперь даёт ореол в заливке
«Свет по источникам» без создания хелпера-группы: свечение следует за самим
выключателем либо за лампами, привязанными в «Управляет источниками света».
## v1.43.3 — 2026-07-27 (отзыв пользователя: обнаруживаемость и тач)
- **Настройки комнаты невозможно было найти.** Шестерёнка из v1.42.0 жила
внутри подписи комнаты размером 0.9em от её шрифта и с прозрачностью 60% —
несколько бледных пикселей на обычном плане. Теперь это кнопка-пилюля
«⚙ Комната» фиксированного читаемого размера, не зависящая от масштаба
карточки, и она появляется **даже у комнат без имени** (их там и называют).
Заодно разблокировались слайдеры размеров шрифта, до которых никто не мог
добраться.
- **Строка показателей увеличена** с 0.62 до 0.75 от размера названия — автор
отзыва мог увеличить название, но строка датчиков оставалась нечитаемой на
планшете. Множители комнаты и пространства работают поверх.
- **Тултипы на тач-устройствах, вторая попытка.** Проверки `(hover: none)`
оказалось мало: некоторые устройства, оболочки, стилусы и подключённые мыши
сообщают `hover: hover`, и подсказки продолжали висеть под пальцем. Теперь
карточка запоминает первое же касание (touch/pen) и гасит открытую подсказку.
## v1.43.2 — 2026-07-27 (внешний аудит: слой тестов)
- **Смок-тесты наконец умеют падать (T1).** Все 48 браузерных смоков печатали
булевы значения и всегда завершались с кодом 0 — регрессия была видна в их
собственном выводе, а прогон считался успешным. `demo/serve.mjs` теперь
экспортирует `check`/`checkAll`/`finish`: каждый факт проверяется по имени,
несовпадения и необработанные исключения внутри карточки дают ненулевой код
возврата. Проверено намеренной поломкой блокировки редакторов в киоске —
соответствующий смок покраснел.
- **Набор гоняется в CI (T2)** отдельной джобой `smoke` после `frontend`,
против свежесобранного бандла (закоммиченная копия в `demo/srv/assets` —
снимок, на нём легко получить «зелёный» отчёт о несуществующем коде), с
выгрузкой логов при падении.
- **`docs/TESTING.md` приведён в соответствие (T3).** `[auto]` теперь означает
«существует именованная проверка, которая падает», и рядом написано, где она;
72 пункта, где автоматизация была намерением, честно помечены `[manual]`.
Исправлены два давних противоречия: строка про «ноль кнопок редактирования в
Просмотре» (неверна с v1.30.1) и строка про клик по проёму (снова верна
с v1.43.1).
- Три смока проверяли поведение, которого уже нет (ожидания времён v1.39.0 и
v1.25); теперь они тестируют текущий контракт.
## v1.43.1 — 2026-07-27 (внешний аудит: исправления P1)
- **Стоимость отрисовки (L1).** Home Assistant подменяет объект `hass` при
любом изменении состояния в доме, и каждая такая отрисовка пересчитывала всю
геометрию плана — `_openPairs()` вызывался по разу на комнату (кубическая
математика коллинеарных наложений), модель пространства строилась дважды.
Теперь и то, и другое мемоизируется по структурному отпечатку конфига и
вынесено из цикла по комнатам; сброс кэша происходит синхронно в момент
мутации, а не внутри дебаунса.
- **Тап против перетаскивания у проёмов (L4).** У перетаскивания двери или окна
не было порога движения, поэтому любое дрожание пальца считалось
перетаскиванием: диалог свойств не открывался, а в конфиг писалось
неизменённое состояние (что подпитывало гонку L2). Теперь порог 3 px, как во
всех остальных сценариях перетаскивания, и запись только при реальном
изменении геометрии.
- **Вогнутые комнаты (G2).** Вложенность определялась через среднее арифметическое
вершин — а оно лежит СНАРУЖИ U- и L-образных комнат, поэтому комнаты-острова
в них отвергались как пересечение, а дырка в заливке не рисовалась. Теперь
вычисляется настоящая внутренняя точка (`interiorPoint`).
- **Дедупликация стен (G3).** `segKey` сортировал концы по сырым float, а
печатал округлённые, поэтому одна общая стена могла дать два ключа и
рисовалась дважды. Сначала округление, потом сортировка.
- **Укрепление бэкенда (B2–B5).** Проверка прав на запись теперь **отказывает**,
когда запись о конфигурации недоступна (раньше во время перезагрузки
интеграции запись разрешалась); `layout/set` поддерживает `expected_rev` и
сообщает о конфликте так же, как хранилище конфига; `config/set` без
`expected_rev` поверх непустого хранилища пишет предупреждение в лог;
координаты отвергают NaN/Infinity, а у пространств, комнат, маркеров, декора
и раскладки появились щедрые ограничения размера.
## v1.43.0 — 2026-07-27 (внешний аудит: исправления P0)
Внешний аудит кода версии v1.41.1 нашёл четыре критические проблемы. Все четыре
исправлены и покрыты регрессионными тестами.
- **Молчаливая потеря правок при сохранении (L2).** Отложенная запись конфига
читала его в момент срабатывания, поэтому пришедшее в промежутке событие
`houseplan_config_updated` подменяло конфиг, и правка пользователя исчезала
без единой ошибки — воспроизводилось даже в одной вкладке. Теперь дебаунс
умеет `flush()`/`pending()`, перезагрузка сперва дописывает отложенную
запись и откладывается, пока запись в полёте, а неудачная перезагрузка
наконец сообщает о себе вместо молчания.
- **Разрез разрушал геометрию комнаты (G1).** Разрез, начинающийся и
заканчивающийся на ОДНОЙ стене (вырезание ниши — совершенно естественное
действие), давал две самопересекающиеся комнаты, суммарная площадь которых
вдвое превышала исходную, и проверка пересечений это не ловила. Теперь такие
разрезы корректно вырезают нишу, а инвариант разбиения (части в сумме дают
исходную площадь) отклоняет всё остальное.
- **Планы и загруженные файлы отдавались без авторизации (B1).** Любой, кто мог
достучаться до вашего Home Assistant, скачивал планы этажей и вложенные
инструкции без входа в систему. Теперь их отдаёт аутентифицированный
обработчик; сохранённые старые адреса переписываются на чтении, так что
ничего не ломается. **Старые публичные пути исчезают только после
перезапуска Home Assistant.**
- **Диалоги могли воскреснуть и обнулить карточку (L3).** Закрытие диалога во
время неудачного сохранения превращало его состояние в пустую «оболочку»,
отрисовщик падал, и карточка оставалась пустой до перезагрузки страницы.
Защита добавлена во все четыре процедуры сохранения, тост об ошибке
по-прежнему показывается.
## v1.42.2 — 2026-07-26
- На тач-устройствах подсказки при наведении больше не выскакивают при каждом
касании (из отзыва: «на планшете при тапе вылезают доп. надписи — мешают»).
Подсказки теперь только для мыши; на тач та же информация есть в карточках
комнат и в карточке устройства по долгому нажатию.
## v1.42.1 — 2026-07-26 (размеры шрифтов карточек комнат)
- Закрываем отзыв «нельзя настроить размер шрифта»: **три слайдера**. В
настройках пространства появился базовый размер шрифта карточек комнат для
всего пространства; в настройках комнаты — независимые размеры **названия** и
**строки показателей** (50–300% каждый). Эффекты перемножаются и складываются
с растягиванием карточки за уголки и множителем экрана в киоск-режиме.
- В обоих диалогах показывается **живой пример карточки**, который меняется
прямо во время перетаскивания слайдеров.
## v1.42.0 — 2026-07-26 (настройки комнаты — третий уровень)
- **У настроек теперь четыре уровня**: общие → пространство → комната →
устройство; более конкретный уровень переопределяет более общий (решение
владельца, зафиксировано в ARCHITECTURE). В этом релизе добавлен уровень
КОМНАТЫ.
- У каждой карточки комнаты в редакторе плана появилась **шестерёнка**:
переименовать комнату, сменить её зону HA, переопределить **тип заливки**
только для этой комнаты (работает и в glow-пространствах — «без заливки»
выводит комнату из темноты) и выбрать явный **источник температуры и
влажности** — любое устройство или сущность HA вместо среднего по комнате.
Источник питает карточку комнаты, всплывающую подсказку и температурную
заливку и работает даже у комнат без зоны HA (случай из отзыва: собственный
template-сенсор, привязанный к помещению).
- Тот же раздел настроек появляется в диалоге комнаты сразу после замыкания
контура.
+12 -2
View File
@@ -51,9 +51,19 @@ cp dist/houseplan-card.js custom_components/houseplan/frontend/
- SSH: port **323**, root, key `ha_jb` (the user uploads it to the chat; in the sandbox /tmp/ha_jb, chmod 600).
- JS: `scp -P 323 -i /tmp/ha_jb dist/houseplan-card.js root@ha.jbstudio.pro:/config/custom_components/houseplan/frontend/`
- **The `frontend/` subfolder is not optional.** `__init__.py` registers
`Path(__file__).parent / "frontend" / "houseplan-card.js"` as the static path.
A copy dropped next to `__init__.py` (…/houseplan/houseplan-card.js) is served
by nobody: md5 on the server matches, the browser still gets the old bundle,
and hours go into debugging a bug that was already fixed. Cost this mistake
once: 2026-07-27, two releases deployed into the void.
- The whole integration: tar c custom_components/houseplan (--exclude __pycache__) → tar x on the server.
- **Verification is mandatory**: `md5sum` locally == on the server == `curl http://homeassistant:8123/houseplan_files/houseplan-card.js | md5sum`
(inside the SSH add-on `localhost` is NOT HA, use the host `homeassistant`).
- **Verification is mandatory, and it must go over HTTP** — comparing md5 against
the file you just copied proves nothing about what the browser receives. The
one check that counts:
`curl -s https://ha.jbstudio.pro/houseplan_files/houseplan-card.js | grep -o '1\.[0-9]*\.[0-9]*' | sort -u`
must print the version just built. (Inside the SSH add-on `localhost` is NOT
HA — use the host `homeassistant`.)
- Python changes require an HA restart (`ha core restart`, holds the connection until it finishes, HTTP
comes back up in 1–3 min). JS changes — just a page refresh (the static path is served
with no-cache).
+7 -5
View File
@@ -5,22 +5,24 @@
> state, where everything lives, and how to continue safely.
>
> **Documentation policy (mandatory):** every change is documented *in the same
> commit* — CHANGELOG entry for anything user-visible, STATUS.md for state changes
> commit* — a CHANGELOG entry for anything user-visible **in BOTH
> `docs/CHANGELOG.md` (English) and `docs/CHANGELOG.ru.md` (Russian, since
> v1.42.0 — the user base is largely Russian-speaking, see the Telegram chat)**, STATUS.md for state changes
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
> ARCHITECTURE.md for design changes, ROADMAP.md when plans move.
## Snapshot (2026-07-24)
## Snapshot (2026-07-27)
| Item | State |
|---|---|
| Version | **v1.41.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Version | **v1.44.8** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
| GitHub | https://github.com/Matysh/houseplan-card — `main` = releases up to **v1.40.1**; `dev` ahead with v1.40.2+ (speaker icons, kiosk). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 1–3 months (checked 2026-07-24) |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.41.0** via direct copy (HACS custom repo also installed) |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.44.8** via direct copy (HACS custom repo also installed) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
| Tests | 111 frontend (node:test) + 12 pure backend + 12 HA-harness (CI, py3.13); ~30 demo smoke suites (headless chromium) |
| Tests | 121 frontend (node:test) + 12 pure backend + 12 HA-harness (CI, py3.13); ~30 demo smoke suites (headless chromium) |
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
| Product scope | docs/SCOPE.md (2026-07-22) is the feature guard rail — check before accepting any feature |
+34
View File
@@ -50,6 +50,29 @@
never silently linked); urls are rewritten only for confirmed copies
[auto: unit logic.test + tests_backend]
- [ ] Plans and PDFs load in a real browser (v1.44.3, B1 regression): open a
dashboard with an uploaded plan — the background renders and a manual link
opens; DevTools shows /api/houseplan/content/... returning 200 via a
signed url, while the same url without authSig returns 401
[auto: tests_backend + manual]
- [ ] Auth policy is single-sourced (v1.44.4, B2): the HTTP upload and every WS
write use the same `may_write`, which denies non-admins when the config
entry is unavailable [auto: tests_backend]
- [ ] Coordinates and caps (v1.44.4, B5): NaN/Infinity are refused on room
rects, polygon vertices, view_box and openings — not only in layout; the
openings list honours MAX_OPENINGS [auto: tests_backend]
- [ ] Drag hardening (v1.44.4, L4 sub-item): every drag pipeline captures the
pointer through the tolerant helper; decor shapes cannot be dragged more
than a quarter of the plan outside the viewBox [auto: smoke_decor]
- [ ] Room climate counts hidden sensors (v1.44.5): a thermometer that is NOT
placed on the plan (hidden by curation or by the user) still feeds the
room card, the tooltip and the temperature fill; fridges/TRVs still do
not; an explicit per-room source still wins [auto: unit devices.test]
- [ ] Room tooltip wording (v1.44.5): hovering a room shows its name (plus
temperature/signal when available) and no longer claims "open the area" —
room clicks were removed in v1.40.1 [manual]
## Environments matrix
Run the *core flows* (marked ★ below) in each environment at least once per minor release:
@@ -211,6 +234,17 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
are only reachable through /api/houseplan/content/… with a session; the
old /houseplan_files/plans|files paths return 404 after a restart; old
stored URLs keep working (rewritten on read) [auto+manual]
- [ ] Plan upload survives a concurrent config revision (v1.44.8): with a second
tab open on the same plan, attach a background image in space settings —
the plan shows immediately, `plan_url` is in `.storage/houseplan.config`,
and the same holds when the space is being CREATED, not edited
[auto: smoke_plan_upload_race]
- [ ] Signed plan background (v1.44.7): a space whose plan lives on the content
endpoint renders its background image with an `authSig` query — the plan is
visible after a plain page load, and Home Assistant logs NO failed-login
attempt from the viewer's own IP. Nothing is requested before the signature
arrives; a 12 h re-sign keeps the previous url until the new one lands
[auto: smoke_plan_signed]
- [ ] Dialog zombies (v1.43.0, audit L3): close a dialog (Esc) while its save is
in flight and let the save fail — the dialog stays closed, the card keeps
rendering, the error toast still fires [auto: unit: logic.test + manual]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.44.2",
"version": "1.44.8",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+69 -2
View File
@@ -2,7 +2,7 @@
* Building the device list from HA registries: curation, light groups,
* markers (overrides/virtual). No Lit/DOM — only the hass object.
*/
import { iconFor, iconFromDeviceClasses, DOMAIN_PRIORITY, FALLBACK_ICON, type CompiledIconRule } from './rules';
import { iconFor, iconFromDeviceClasses, DOMAIN_PRIORITY, FALLBACK_ICON, type CompiledIconRule, EXCLUDED_DOMAINS } from './rules';
import { averageLqi } from './logic';
import type { DevItem, Marker, ServerConfig } from './types';
@@ -162,7 +162,7 @@ export function lightGroups(hass: any, enabled: boolean): { eid: string; name: s
}
/** Icon with the full fallback chain: name rules → entity device_class → chip. */
function resolveIcon(hass: any, name: string, model: string | undefined, entIds: string[], rules?: CompiledIconRule[]): string {
export function resolveIcon(hass: any, name: string, model: string | undefined, entIds: string[], rules?: CompiledIconRule[]): string {
const byRules = iconFor(name, model, rules);
if (byRules !== FALLBACK_ICON) return byRules;
const classes: string[] = [];
@@ -400,6 +400,73 @@ export function areaHum(
return Math.round(vals.reduce((a, b) => a + b, 0) / vals.length);
}
/**
* Entity ids that measure something other than room air, however honest their
* device_class is: water and coolant loops, chip/CPU/board temperatures,
* battery temperature, setpoints (target/external) and the like.
*/
const NON_AIR_RE = new RegExp(
[
'water', 'voda', 'coolant', 'flow_?temp', 'return_?temp', 'target', 'setpoint',
'chip', 'cpu', 'processor', 'board', 'core_temp', 'device_temp',
'batter', 'akkum', 'freezer', 'fridge', 'oven', 'kettle', 'boiler',
].join('|'),
'i',
);
/**
* Room climate from EVERY sensor of the area — including devices that are not
* placed on the plan (hidden by curation or by the user). The old helpers read
* the visible-icon list, so hiding a thermometer silently removed it from the
* room card (field report, 2026-07-27).
*
* Curation is kept: only devices the card itself recognises as thermometers /
* air monitors count, so fridges, TRVs and chip-temperature plugs stay out.
* The AUTO icon is used on purpose — a custom marker icon must not change what
* a device measures.
*/
export function areaClimate(
hass: any, area: string, kind: 'temp' | 'hum', rules?: CompiledIconRule[],
): number | null {
if (!area || !hass?.entities) return null;
const groups = new Map<string, { name: string; model?: string; ents: string[] }>();
for (const [eid, reg] of Object.entries<any>(hass.entities)) {
const dev = reg.device_id ? hass.devices?.[reg.device_id] : null;
const entArea = reg.area_id || dev?.area_id || null;
if (entArea !== area) continue;
// Not every "temperature" is room air. Real finds on a live install: the
// NAS processor temperature, the water in a smart kettle, a sauna heater at
// 90 C and a virtual better_thermostat duplicating the real sensor (field
// question, 2026-07-27). Three guards, cheapest first:
if (reg.entity_category) continue; // diagnostic/config readings
if (EXCLUDED_DOMAINS.has(reg.platform)) continue; // curated-out integrations
if (NON_AIR_RE.test(eid)) continue; // water/chip/flow/target/...
const key = reg.device_id || eid;
if (!groups.has(key)) {
const st = hass.states?.[eid];
groups.set(key, {
name: (dev ? dev.name_by_user || dev.name : reg.name || st?.attributes?.friendly_name || eid) || eid,
model: dev?.model,
ents: [],
});
}
groups.get(key)!.ents.push(eid);
}
const vals: number[] = [];
for (const g of groups.values()) {
const icon = resolveIcon(hass, g.name, g.model, g.ents, rules);
const ok = kind === 'temp'
? icon === 'mdi:thermometer' || icon === 'mdi:air-filter'
: icon === 'mdi:thermometer' || icon === 'mdi:air-filter' || icon === 'mdi:water-percent';
if (!ok) continue;
const v = kind === 'temp' ? tempFor(hass, g.ents) : humFor(hass, g.ents);
if (v != null) vals.push(v);
}
if (!vals.length) return null;
const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
return kind === 'temp' ? Math.round(avg * 10) / 10 : Math.round(avg);
}
/** How many of the area's lights are on: {on, total}, or null without lights. */
export function areaLightStats(
hass: any,
+150 -28
View File
@@ -22,7 +22,7 @@ import {
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
} from './logic';
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue } from './devices';
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimate } from './devices';
import type {
OpeningCfg,
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
@@ -32,7 +32,7 @@ import './space-card';
import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.44.2';
const CARD_VERSION = '1.44.8';
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';
@@ -90,6 +90,22 @@ const debounce = <T extends (...a: any[]) => void>(fn: T, ms: number): Debounced
return wrapped;
};
/**
* Capture the pointer for a drag, tolerating an inactive pointerId.
*
* `setPointerCapture` throws for synthetic events and for pointers some
* browsers consider gone; that killed a drag outright. The opening pipeline
* was hardened for this, the device/label/resize ones were not (audit
* follow-up L4 sub-item) — now they all go through here.
*/
const capturePointer = (ev: PointerEvent): void => {
try {
(ev.target as Element | null)?.setPointerCapture?.(ev.pointerId);
} catch {
/* an inactive pointerId must never kill the drag */
}
};
class HouseplanCard extends LitElement {
public hass?: any;
private _config?: CardConfig;
@@ -348,6 +364,9 @@ class HouseplanCard extends LitElement {
public connectedCallback(): void {
super.connectedCallback();
window.addEventListener('keydown', this._keyHandler);
// signatures expire (24 h); refresh well before that on long-lived screens
clearInterval(this._resignTimer);
this._resignTimer = window.setInterval(() => this._resign(), 12 * 3600 * 1000);
if (this._config?.kiosk && Number(this._config?.cycle) > 0) {
clearInterval(this._cycleTimer);
this._cycleTimer = window.setInterval(() => this._cycleTick(), Number(this._config.cycle) * 1000);
@@ -361,6 +380,9 @@ class HouseplanCard extends LitElement {
clearTimeout(this._kioskDotsTimer);
clearTimeout(this._kioskHoldTimer);
clearTimeout(this._reloadRetry);
clearTimeout(this._signTimer);
clearInterval(this._resignTimer);
clearTimeout(this._toastTimer);
this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown
window.removeEventListener('hashchange', this._onHashChange);
clearTimeout(this._holdTimer);
@@ -571,7 +593,11 @@ class HouseplanCard extends LitElement {
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: contentUrl(s.plan_url), x: 0, y: 0, w: NORM_W, h: H } : null,
// raw url on purpose: the model is memoized on the config fingerprint,
// so a signed url baked in here would freeze BEFORE the signature
// arrives and the plan would never load (bug found 2026-07-27).
// _display() is called at render time instead.
bg: s.plan_url ? { href: s.plan_url, x: 0, y: 0, w: NORM_W, h: H } : null,
rooms: s.rooms.map(scale),
};
});
@@ -756,6 +782,66 @@ class HouseplanCard extends LitElement {
}
private _reloadRetry?: number;
/**
* Signed urls for the content endpoint (audit follow-up B1 regression).
* A browser cannot authenticate an <image href> or an <a href>: HA takes a
* Bearer header or an `authSig` signed path, and an element sends neither.
* So the card asks the backend to sign what it is about to display.
*/
private _signed: Record<string, string> = {};
private _signPending = new Set<string>();
private _signTimer?: number;
/** Display url: the signed variant when we have one, else the plain path. */
private _display(url: string | null | undefined): string {
const u = contentUrl(url);
if (!u.startsWith('/api/houseplan/content/')) return u;
if (this._signed[u]) return this._signed[u];
this._requestSignature(u);
// Empty, NOT the plain path: an unsigned request to a `requires_auth` view
// returns 401 and Home Assistant raises a "failed login attempt" for the
// viewer's own IP. Callers skip rendering until the signature lands.
return '';
}
private _requestSignature(url: string): void {
if (this._signPending.has(url) || !this.hass?.callWS) return;
this._signPending.add(url);
clearTimeout(this._signTimer);
// batch: a plan switch asks for several urls in the same tick
this._signTimer = window.setTimeout(() => {
const paths = [...this._signPending];
this._signPending.clear();
this.hass
.callWS({ type: 'houseplan/content/sign', paths })
.then((r: any) => {
if (!r?.urls) return;
this._signed = { ...this._signed, ...r.urls };
this.requestUpdate();
})
.catch(() => undefined); // unsigned urls simply keep failing; no loop
}, 30);
}
/**
* Re-sign everything periodically: a wall tablet outlives a signature.
* The old urls are kept until the new ones arrive — dropping them first would
* blank the plan for a round trip (and, if the socket is down, until it heals).
*/
private _resignTimer?: number;
private _resign(): void {
const paths = Object.keys(this._signed);
if (!paths.length || !this.hass?.callWS) return;
this.hass
.callWS({ type: 'houseplan/content/sign', paths })
.then((r: any) => {
if (!r?.urls) return;
this._signed = { ...this._signed, ...r.urls };
this.requestUpdate();
})
.catch(() => undefined);
}
private _dirtyPos = new Set<string>();
private _persistLayout = debounce(() => {
@@ -1309,7 +1395,7 @@ class HouseplanCard extends LitElement {
ev.preventDefault();
const p = this._pos(d);
this._drag = { id: d.id, sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false };
(ev.target as HTMLElement).setPointerCapture(ev.pointerId);
capturePointer(ev);
this._tip = null;
}
@@ -1652,7 +1738,7 @@ class HouseplanCard extends LitElement {
ev.preventDefault();
const p = this._snap(this._svgPoint(ev));
this._decorDraft = { kind: t, a: p, b: p, pid: ev.pointerId };
(ev.target as HTMLElement).setPointerCapture?.(ev.pointerId);
capturePointer(ev);
return true;
}
if (t === 'text') {
@@ -1712,15 +1798,25 @@ class HouseplanCard extends LitElement {
id: shape.id, start: this._svgPoint(ev), orig: JSON.parse(JSON.stringify(shape)),
pid: ev.pointerId, moved: false,
};
(ev.target as HTMLElement).setPointerCapture?.(ev.pointerId);
capturePointer(ev);
}
private _decorMoveUpdate(ev: PointerEvent): void {
const m = this._decorMove!;
const p = this._svgPoint(ev);
const g = this._gridPitch;
const dx = snapToGrid(p[0] - m.start[0], g) / NORM_W;
const dy = snapToGrid(p[1] - m.start[1], g) / this._decorH;
let dx = snapToGrid(p[0] - m.start[0], g) / NORM_W;
let dy = snapToGrid(p[1] - m.start[1], g) / this._decorH;
// audit follow-up L4: decor had neither a threshold nor a bounds clamp, so
// a shape could be dragged far outside the viewBox and persisted there.
const o = m.orig;
const curX = o.kind === 'line' ? Math.min(o.x1, o.x2) : o.x;
const curY = o.kind === 'line' ? Math.min(o.y1, o.y2) : o.y;
const w = o.kind === 'line' ? Math.abs(o.x2 - o.x1) : (o.w || 0);
const h = o.kind === 'line' ? Math.abs(o.y2 - o.y1) : (o.h || 0);
const lim = 0.25; // a quarter of the plan may hang outside, no more
dx = Math.max(-curX - lim, Math.min(1 + lim - curX - w, dx));
dy = Math.max(-curY - lim, Math.min(1 + lim - curY - h, dy));
if (dx || dy) m.moved = true;
const sp = this._curSpaceCfg;
sp.decor = this._decorList.map((x) => {
@@ -2031,7 +2127,7 @@ class HouseplanCard extends LitElement {
ev.preventDefault();
ev.stopPropagation();
try {
(ev.target as Element).setPointerCapture?.(ev.pointerId);
capturePointer(ev);
} catch {
/* an inactive pointerId (synthetic events, some browsers) must not kill the drag */
}
@@ -2873,12 +2969,30 @@ class HouseplanCard extends LitElement {
const wasFirst = d.mode === 'create' && (this._serverCfg?.spaces.length || 0) === 0;
this._spaceDialog = { ...d, busy: true };
try {
const drawAspect = d.orientation === 'portrait' ? 0.707 : d.orientation === 'square' ? 1 : 1.414;
const spaceId = d.mode === 'create' ? 's' + Date.now().toString(36) : d.spaceId!;
/* Upload BEFORE touching the config, and never hold a reference to a
config object across an await. `houseplan/config/get` runs on every
`houseplan_config_updated` event and REPLACES `_serverCfg`; a space
object captured before the upload is then detached, so plan_url,
aspect and settings were written into an orphan and the save shipped
the untouched config. Symptom: the file lands on disk, the plan never
appears, and re-saving does not help (owner's install, 2026-07-27). */
let uploaded: { url: string; aspect: number } | null = null;
if (d.source === 'file' && d.planFile) {
const resp = await this.hass.callWS({
type: 'houseplan/plan/set', space_id: spaceId, ext: d.planFile.ext, data: d.planFile.b64,
});
uploaded = { url: resp.url, aspect: d.planFile.aspect };
}
// from here on: no awaits until the save, so `sp` cannot be orphaned
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),
id: spaceId,
title: d.title.trim(),
plan_url: null,
aspect: d.source === 'draw' ? drawAspect : 1.414,
@@ -2887,15 +3001,13 @@ class HouseplanCard extends LitElement {
};
cfg.spaces.push(sp);
} else {
sp = cfg.spaces.find((x: any) => x.id === d.spaceId);
sp = cfg.spaces.find((x: any) => x.id === spaceId);
if (!sp) throw new Error('space ' + spaceId + ' is gone from the config');
sp.title = d.title.trim();
}
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;
if (uploaded) {
sp.plan_url = uploaded.url;
sp.aspect = uploaded.aspect;
}
// switching an existing space to "draw" detaches its background image
// (the uploaded file stays on disk; only the reference is cleared)
@@ -2975,14 +3087,23 @@ class HouseplanCard extends LitElement {
private async _saveConfigNow(): Promise<void> {
this._dropLegacySegments();
this._cfgEpoch++;
// same flag the debounced writer uses: while it is set, an incoming
// `houseplan_config_updated` defers its reload instead of replacing the
// config under an unfinished write (audit L2, extended to this path)
this._cfgWriting = true;
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();
if (e?.code === 'conflict') {
this._cfgWriting = false;
await this._reloadConfigOnly();
}
throw e;
} finally {
this._cfgWriting = false;
}
}
@@ -3528,8 +3649,8 @@ class HouseplanCard extends LitElement {
${this._editing && !this._markup
? svg`<rect x="${vb[0]}" y="${vb[1]}" width="${vb[2]}" height="${vb[3]}" fill="url(#hp-grid)" pointer-events="none"></rect>`
: nothing}
${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" />`
${space.bg && this._display(space.bg.href)
? svg`<image href="${this._display(space.bg.href)}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />`
: nothing}
${this._renderDecorLayer()}
${(() => {
@@ -3579,7 +3700,7 @@ class HouseplanCard extends LitElement {
style = st.join(';');
}
const tip = (e: MouseEvent) =>
this._showTip(e, r.name, this._t('tip.room'),
this._showTip(e, r.name, '',
showLqi ? this._roomLqi(r.area) : null,
this._roomTemp(r));
const label = !space.bg && !disp.showNames && !this._markup;
@@ -3759,14 +3880,15 @@ class HouseplanCard extends LitElement {
private _roomTemp(r: RoomCfg): number | null {
const src = r.settings?.temp_source;
if (src) return sourceValue(this.hass, src, 'temp');
return r.area ? areaTemp(this.hass, this._devices, r.area) : null;
// every sensor of the area, placed on the plan or not (field report)
return r.area ? areaClimate(this.hass, r.area, 'temp', this._iconRules) : null;
}
/** Room humidity honouring the tier-3 source override. */
private _roomHum(r: RoomCfg): number | null {
const src = r.settings?.hum_source;
if (src) return sourceValue(this.hass, src, 'hum');
return r.area ? areaHum(this.hass, this._devices, r.area) : null;
return r.area ? areaClimate(this.hass, r.area, 'hum', this._iconRules) : null;
}
private _resetRoomDialogFields(): void {
@@ -3880,7 +4002,7 @@ class HouseplanCard extends LitElement {
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);
capturePointer(ev);
this._tip = null;
}
@@ -3926,7 +4048,7 @@ class HouseplanCard extends LitElement {
const cy = b.top + b.height / 2;
const d0 = Math.max(8, Math.hypot(ev.clientX - cx, ev.clientY - cy));
this._rlResize = { id: 'rl_' + (r.id || ''), space: spaceId, k0: this._labelScale(r), cx, cy, d0 };
(ev.target as HTMLElement).setPointerCapture(ev.pointerId);
capturePointer(ev);
}
private _rlResizeMove(ev: PointerEvent): void {
@@ -4602,7 +4724,7 @@ class HouseplanCard extends LitElement {
${d.pdfs && d.pdfs.length
? html`<div class="inforow"><span class="k">${this._t('info.manuals')}</span><span class="pdflist">
${d.pdfs.map(
(p) => html`<a class="pdf" href="${safeUrl(contentUrl(p.url)) || '#'}" target="_blank" rel="noreferrer noopener">
(p) => html`<a class="pdf" href="${safeUrl(this._display(p.url)) || '#'}" target="_blank" rel="noreferrer noopener">
<ha-icon icon="mdi:file-pdf-box"></ha-icon>${p.name}</a>`,
)}</span></div>`
: nothing}
@@ -4839,7 +4961,7 @@ class HouseplanCard extends LitElement {
<div class="pdfedit">
${d.pdfs.map(
(p) => html`<span class="pdftag"><ha-icon icon="mdi:file-pdf-box"></ha-icon>
<a href="${safeUrl(contentUrl(p.url)) || '#'}" target="_blank" rel="noreferrer noopener">${p.name}</a>
<a href="${safeUrl(this._display(p.url)) || '#'}" target="_blank" rel="noreferrer noopener">${p.name}</a>
<ha-icon class="x" icon="mdi:close" @click=${() => this._removeMarkerPdf(p.url)}></ha-icon></span>`,
)}
<label class="btn filebtn">
-1
View File
@@ -59,7 +59,6 @@
"markup.delete": "Delete",
"markup.hint_points": "points: {n} · Esc/Ctrl+Z — undo a dot · close the outline by clicking the first one",
"markup.hint_start": "click a grid dot to start the outline",
"tip.room": "room — open the area",
"tip.lqi": "average zigbee signal:",
"info.device_header": "Device on the plan",
"info.model": "Model",
-1
View File
@@ -59,7 +59,6 @@
"markup.delete": "Удалить",
"markup.hint_points": "точек: {n} · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой",
"markup.hint_start": "кликните точку сетки, чтобы начать контур",
"tip.room": "комната — открыть зону",
"tip.lqi": "средний сигнал zigbee:",
"info.device_header": "Устройство на плане",
"info.model": "Модель",
+2
View File
@@ -29,6 +29,8 @@ export const DEFAULT_ICON_RULES: IconRule[] = [
{ pattern: 'клапан|valve', icon: 'mdi:pipe-valve' },
{ pattern: 'дым|smoke', icon: 'mdi:smoke-detector' },
{ pattern: 'термоголов|trv|radiator', icon: 'mdi:radiator' },
{ pattern: 'чайник|kettle|термопот', icon: 'mdi:kettle' },
{ pattern: 'сауна|sauna|harvia|парная|парилк', icon: 'mdi:hot-tub' },
{ pattern: 'температ|temperature|climate sensor', icon: 'mdi:thermometer' },
{ pattern: 'qingping|air monitor|молекул|air quality', icon: 'mdi:air-filter' },
{ pattern: 'штор|curtain|blind|shade', icon: 'mdi:roller-shade' },
+23
View File
@@ -109,6 +109,8 @@ class HouseplanSpaceCard extends LitElement {
public getCardSize(): number {
const models = spaceModels(this._snap?.config || null);
// B1 follow-up: the plan <image> needs a signed url in a browser session
for (const m of models) if (m.bg?.href) this._signBg(m.bg);
const sp = models.find((s) => s.id === this._config?.space);
if (sp) {
const ratio = sp.vb[3] / sp.vb[2]; // h/w
@@ -121,6 +123,27 @@ class HouseplanSpaceCard extends LitElement {
return html`<ha-card><div class="hp-static-error">${msg}</div></ha-card>`;
}
private _signedBg: Record<string, string> = {};
private _signBgPending = new Set<string>();
/** Ask the backend for a signed content url and swap it in when it arrives. */
private _signBg(bg: { href: string }): void {
const raw = bg.href.split('?authSig=')[0];
if (!raw.startsWith('/api/houseplan/content/')) return;
if (this._signedBg[raw]) { bg.href = this._signedBg[raw]; return; }
if (this._signBgPending.has(raw) || !this.hass?.callWS) return;
this._signBgPending.add(raw);
this.hass
.callWS({ type: 'houseplan/content/sign', paths: [raw] })
.then((r: any) => {
const url = r?.urls?.[raw];
if (!url) return;
this._signedBg = { ...this._signedBg, [raw]: url };
this.requestUpdate();
})
.catch(() => undefined);
}
protected render(): TemplateResult | typeof nothing {
if (!this._config) return nothing;
const cfg = this._snap?.config;
+78 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue } from '../test-build/devices.js';
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue , areaClimate } from '../test-build/devices.js';
import { compileIconRules, iconFor } from '../test-build/rules.js';
/** Minimal fake hass around the pieces buildDevices reads. */
@@ -359,3 +359,80 @@ test('sourceValue: explicit entity and device sources (tier 3)', () => {
assert.equal(sourceValue(hass, '', 'temp'), null);
assert.equal(sourceValue(hass, 'garbage', 'temp'), null);
});
test('areaClimate: counts sensors that are NOT on the plan (field report)', () => {
const hass = {
devices: {
d1: { id: 'd1', name: 'Датчик температуры спальня', area_id: 'bed' },
d2: { id: 'd2', name: 'Холодильник', model: 'LG', area_id: 'bed' },
d3: { id: 'd3', name: 'Qingping air monitor', area_id: 'bed' },
d4: { id: 'd4', name: 'Датчик температуры кухня', area_id: 'kitchen' },
},
entities: {
'sensor.bed_t': { device_id: 'd1' },
'sensor.bed_h': { device_id: 'd1' },
'sensor.fridge_t': { device_id: 'd2' },
'sensor.air_t': { device_id: 'd3' },
'sensor.air_h': { device_id: 'd3' },
'sensor.kitchen_t': { device_id: 'd4' },
},
states: {
'sensor.bed_t': { state: '21.0', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
'sensor.bed_h': { state: '40', attributes: { device_class: 'humidity', unit_of_measurement: '%' } },
'sensor.fridge_t': { state: '4', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
'sensor.air_t': { state: '23.0', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
'sensor.air_h': { state: '50', attributes: { device_class: 'humidity', unit_of_measurement: '%' } },
'sensor.kitchen_t': { state: '30.0', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
},
};
// среднее по двум термометрам зоны; ни одно устройство не «размещено» на плане
assert.equal(areaClimate(hass, 'bed', 'temp'), 22);
assert.equal(areaClimate(hass, 'bed', 'hum'), 45);
// холодильник по-прежнему не считается климатом комнаты
assert.notEqual(areaClimate(hass, 'bed', 'temp'), (21 + 4 + 23) / 3);
// чужая зона не подмешивается
assert.equal(areaClimate(hass, 'kitchen', 'temp'), 30);
assert.equal(areaClimate(hass, 'nowhere', 'temp'), null);
// сущность со своей area_id учитывается, даже если устройство в другой зоне
const hass2 = {
...hass,
entities: { ...hass.entities, 'sensor.kitchen_t': { device_id: 'd4', area_id: 'bed' } },
};
assert.equal(areaClimate(hass2, 'kitchen', 'temp'), null);
});
test('areaClimate: only ROOM AIR counts (field question, 2026-07-27)', () => {
const hass = {
devices: {
good: { id: 'good', name: 'Датчик температуры спальня', area_id: 'bed' },
kettle: { id: 'kettle', name: 'Polaris PWK-1725CGLD', model: 'Kettle', area_id: 'bed' },
nas: { id: 'nas', name: 'System Monitor', area_id: 'bed' },
sauna: { id: 'sauna', name: 'Сауна Harvia', area_id: 'bed' },
trv: { id: 'trv', name: 'Термоголовка в спальне', area_id: 'bed' },
bt: { id: 'bt', name: 'Спальня better thermostat', area_id: 'bed' },
zb: { id: 'zb', name: 'SLZB-06MU', area_id: 'bed' },
},
entities: {
'sensor.good_t': { device_id: 'good', platform: 'mqtt' },
// вода в чайнике
'sensor.kettle_current_temperature': { device_id: 'kettle', platform: 'syncleo_kettle' },
// температура процессора: и diagnostic, и исключённая интеграция
'sensor.nas_processor_temperature': { device_id: 'nas', platform: 'systemmonitor', entity_category: 'diagnostic' },
'sensor.sauna_temperature': { device_id: 'sauna', platform: 'harvia_sauna' },
'sensor.trv_local_temperature': { device_id: 'trv', platform: 'mqtt' },
'sensor.bt_temperature': { device_id: 'bt', platform: 'better_thermostat' },
'sensor.zb_core_chip_temp': { device_id: 'zb', platform: 'smlight', entity_category: 'diagnostic' },
},
states: {
'sensor.good_t': { state: '22.0', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
'sensor.kettle_current_temperature': { state: '95', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
'sensor.nas_processor_temperature': { state: '61', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
'sensor.sauna_temperature': { state: '90', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
'sensor.trv_local_temperature': { state: '24', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
'sensor.bt_temperature': { state: '22.0', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
'sensor.zb_core_chip_temp': { state: '48', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
},
};
// остаётся ровно один настоящий датчик воздуха
assert.equal(areaClimate(hass, 'bed', 'temp'), 22);
});
+44
View File
@@ -177,3 +177,47 @@ async def test_files_migrate_copies_and_reports_mapping(
resp2 = await client.receive_json()
assert resp2["success"] and resp2["result"]["removed"] is True
assert not await hass.async_add_executor_job(lambda: os.path.isdir(src))
async def test_content_signed_path_opens_without_a_bearer_header(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_client_no_auth
) -> None:
"""B1 follow-up: a browser <image>/<a> sends no Authorization header.
The unsigned url must be refused and the signed one must work — otherwise
plan backgrounds and PDF links 401 on a real dashboard (reproduced live,
2026-07-27).
"""
import os
from custom_components.houseplan.const import CONTENT_URL, PLANS_DIR
await _setup(hass)
plans = hass.config.path(PLANS_DIR)
def _write() -> None:
os.makedirs(plans, exist_ok=True)
with open(os.path.join(plans, "s1.png"), "wb") as fh:
fh.write(b"PNGDATA")
await hass.async_add_executor_job(_write)
path = f"{CONTENT_URL}/plans/_/s1.png"
client = await hass_ws_client(hass)
await client.send_json_auto_id({"type": "houseplan/content/sign", "paths": [path]})
resp = await client.receive_json()
assert resp["success"], resp
signed = resp["result"]["urls"][path]
assert "authSig=" in signed
http = await hass_client_no_auth()
assert (await http.get(path)).status == 401 # unsigned: refused
ok = await http.get(signed)
assert ok.status == 200 and await ok.read() == b"PNGDATA"
# only our own endpoint may be signed
await client.send_json_auto_id(
{"type": "houseplan/content/sign", "paths": ["/api/other/secret"]}
)
resp2 = await client.receive_json()
assert resp2["success"] and resp2["result"]["urls"] == {}
+33
View File
@@ -140,3 +140,36 @@ def test_collection_caps():
big = {f"d{i}": {"x": 0.1, "y": 0.1} for i in range(v.MAX_LAYOUT + 1)}
with pytest.raises(vol.Invalid):
v.LAYOUT_SCHEMA(big)
def test_finite_on_every_coordinate():
"""audit follow-up B5: NaN/Infinity must be refused everywhere, not only in layout."""
base = {"id": "s1", "title": "S", "aspect": 1.0, "view_box": [0, 0, 100, 100], "rooms": []}
# view_box
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [{**base, "view_box": [0, 0, "NaN", 100]}]})
# room rect coordinates
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
{"id": "r", "name": "R", "x": "Infinity", "y": 0, "w": 1, "h": 1}]}]})
# polygon vertices
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
{"id": "r", "name": "R", "poly": [[0, 0], [1, "NaN"], [1, 1]]}]}]})
# opening coordinates
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [{**base, "openings": [
{"id": "o", "type": "door", "x": "NaN", "y": 0.5, "angle": 0, "length": 0.1}]}]})
# a sane config still validates
assert v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
{"id": "r", "name": "R", "poly": [[0, 0], [1, 0], [1, 1]]}]}]})
def test_openings_cap_enforced():
"""audit follow-up B5: MAX_OPENINGS was defined but never wired in."""
many = [{"id": f"o{i}", "type": "door", "x": 0.1, "y": 0.1, "angle": 0, "length": 0.1}
for i in range(v.MAX_OPENINGS + 1)]
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [{"id": "s1", "title": "S", "aspect": 1.0,
"view_box": [0, 0, 100, 100], "rooms": [],
"openings": many}]})