mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 19:28:46 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7fe63776a | ||
|
|
01bc4f9711 | ||
|
|
85491d0fea | ||
|
|
d37a67c29f | ||
|
|
a66272c6f4 | ||
|
|
f4af2fe508 | ||
|
|
8e07e3c958 | ||
|
|
9868f1035f | ||
|
|
f2c9b07cc1 | ||
|
|
2c7a2f849d | ||
|
|
33e71ca96c | ||
|
|
7128ab504d | ||
|
|
f953a3c286 | ||
|
|
ef270d11b7 | ||
|
|
dc24390222 | ||
|
|
254354bf56 | ||
|
|
c9a60a110d | ||
|
|
75279308c1 | ||
|
|
b7ae3e7adf | ||
|
|
379fb68db2 | ||
|
|
96a70495d3 | ||
|
|
d3db9e30e6 | ||
|
|
2e731debd9 | ||
|
|
a49b5e6d2e | ||
|
|
4418312b0b | ||
|
|
3f719cc32a | ||
|
|
260615a63f | ||
|
|
e4e300adaa | ||
|
|
96d387ff1d | ||
|
|
8b531db3f5 | ||
|
|
68aa1f04ba | ||
|
|
3d41fe16b8 | ||
|
|
ac734688d4 | ||
|
|
2e2d353b04 | ||
|
|
f8c8cb4eeb | ||
|
|
c749b52a0d | ||
|
|
15e5dd7392 | ||
|
|
f1b501a956 | ||
|
|
5d2dbb1009 | ||
|
|
40cb0302e3 | ||
|
|
14cc4df4bd | ||
|
|
ead56dd9b6 | ||
|
|
018b37940f | ||
|
|
ebeaa5c0c6 | ||
|
|
02ba18dc7b | ||
|
|
715a93ec61 | ||
|
|
09b0ba41a5 | ||
|
|
0467cee98a | ||
|
|
c0653dfc73 | ||
|
|
946e7543ad | ||
|
|
641c61dc19 | ||
|
|
ae9168f6ec | ||
|
|
45c863138a | ||
|
|
e04ef2f2e6 | ||
|
|
a841d17543 |
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 💬 Telegram chat (@ha_houseplan)
|
||||
url: https://t.me/ha_houseplan
|
||||
about: Questions, setup help, ideas and screenshots — the fastest way to get an answer.
|
||||
- name: 💡 GitHub discussions
|
||||
url: https://github.com/Matysh/houseplan-card/discussions
|
||||
about: Longer-form ideas and show-and-tell.
|
||||
@@ -3,6 +3,19 @@
|
||||
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
|
||||
writing code? The **[Telegram chat @ha_houseplan](https://t.me/ha_houseplan)**
|
||||
is the quickest route to the author and other users. Bugs and concrete feature
|
||||
requests still belong in [issues](https://github.com/Matysh/houseplan-card/issues).
|
||||
|
||||
## Five-minute setup
|
||||
|
||||
```bash
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
[](https://github.com/Matysh/houseplan-card/stargazers)
|
||||
[](https://github.com/Matysh/houseplan-card/actions)
|
||||
[](LICENSE)
|
||||
[](https://t.me/ha_houseplan)
|
||||
|
||||
**Turn Home Assistant into a live, interactive map of your home.** Upload or draw
|
||||
a floor plan, outline the rooms with your mouse — and every smart device appears
|
||||
@@ -15,7 +16,7 @@ right on your Lovelace dashboard.
|
||||
|
||||

|
||||
|
||||
🇷🇺 [Документация на русском](README.ru.md)
|
||||
🇷🇺 [Документация на русском](README.ru.md) · 💬 [Telegram chat: **@ha_houseplan**](https://t.me/ha_houseplan)
|
||||
|
||||
**Feature highlights**
|
||||
|
||||
@@ -258,6 +259,24 @@ turned the way it is mounted.
|
||||
|
||||
---
|
||||
|
||||
## Getting help & sharing your plan
|
||||
|
||||
- 💬 **[Telegram chat — @ha_houseplan](https://t.me/ha_houseplan)** — questions,
|
||||
setup help, feature ideas, and screenshots of your plans. The fastest way to
|
||||
reach the author and other users.
|
||||
- 🐞 [GitHub issues](https://github.com/Matysh/houseplan-card/issues) — bug
|
||||
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 &
|
||||
Services → House Plan**.
|
||||
|
||||
---
|
||||
|
||||
## Frequently asked questions
|
||||
|
||||
**Do I need to write anything in YAML?** No. The only line is adding the card to the dashboard; everything else is done with the mouse.
|
||||
|
||||
+19
-1
@@ -3,6 +3,7 @@
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/Matysh/houseplan-card/releases)
|
||||
[](https://github.com/Matysh/houseplan-card/stargazers)
|
||||
[](https://t.me/ha_houseplan)
|
||||
|
||||
**Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или
|
||||
нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на
|
||||
@@ -13,7 +14,7 @@
|
||||
|
||||

|
||||
|
||||
🇬🇧 [Documentation in English](README.md)
|
||||
🇬🇧 [Documentation in English](README.md) · 💬 [Чат в Telegram: **@ha_houseplan**](https://t.me/ha_houseplan)
|
||||
|
||||
**Главное**
|
||||
|
||||
@@ -261,6 +262,23 @@ title: План дома
|
||||
|
||||
---
|
||||
|
||||
## Помощь и обмен опытом
|
||||
|
||||
- 💬 **[Чат в Telegram — @ha_houseplan](https://t.me/ha_houseplan)** — вопросы,
|
||||
помощь с настройкой, идеи и скриншоты ваших планов. Самый быстрый способ
|
||||
связаться с автором и другими пользователями.
|
||||
- 🐞 [Issues на GitHub](https://github.com/Matysh/houseplan-card/issues) — баги
|
||||
и запросы фич (пожалуйста, указывайте версию House Plan).
|
||||
- 💡 [Discussions](https://github.com/Matysh/houseplan-card/discussions) — для
|
||||
развёрнутых обсуждений.
|
||||
- 📜 [История изменений](docs/CHANGELOG.ru.md) — что менялось в каждой версии.
|
||||
|
||||
Версия видна в консоли браузера при загрузке (`HOUSEPLAN-CARD vX.Y.Z`) и в
|
||||
**Настройки → Устройства и службы → House Plan** — с ней разбираться сильно
|
||||
быстрее.
|
||||
|
||||
---
|
||||
|
||||
## Часто задаваемые вопросы
|
||||
|
||||
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.components.frontend import add_extra_js_url
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
from . import websocket_api as hp_ws
|
||||
from .const import (
|
||||
@@ -18,6 +20,7 @@ from .const import (
|
||||
PLANS_URL,
|
||||
VERSION,
|
||||
)
|
||||
from .plans import collect_attachments, collect_plans, sweep_upload_temps
|
||||
from .repairs import async_check_plan_files
|
||||
from .store import HouseplanConfigEntry, create_data
|
||||
|
||||
@@ -97,6 +100,55 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
)
|
||||
|
||||
await async_check_plan_files(hass, entry)
|
||||
|
||||
# Scheduled collection of everything nobody ended up referencing.
|
||||
#
|
||||
# A commit collects what that commit superseded, which is the right rule for
|
||||
# a commit — but it only ever runs when somebody saves. Cancel a dialog
|
||||
# after the file has already uploaded, lose the connection after the upload
|
||||
# succeeded, or call the API directly, and the file is unreferenced with no
|
||||
# future write to notice it (HP-1461-01). The earlier version of this sweep
|
||||
# only removed streaming temporaries, which are a different, narrower case.
|
||||
#
|
||||
# Passing the CURRENT configuration as both sides means "nothing was
|
||||
# superseded": every referenced file is preserved and only unreferenced ones
|
||||
# past PLAN_ORPHAN_TTL_S go. It runs under the same lock as a config write,
|
||||
# so it cannot decide from a snapshot that a commit is about to replace.
|
||||
async def _sweep(_now=None) -> None:
|
||||
files_dir = Path(hass.config.path(FILES_DIR))
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
try:
|
||||
# `data` from the closure, NOT get_data(hass): during
|
||||
# async_setup_entry the entry is still SETUP_IN_PROGRESS, so
|
||||
# async_loaded_entries() does not list it and the lookup returned
|
||||
# None. The startup pass then silently degraded to removing
|
||||
# streaming temporaries only, and the real collection waited a full
|
||||
# day — restarting more often than that meant it never ran at all
|
||||
# (HP-1462-01). The callback is unregistered with the entry, so
|
||||
# closing over its runtime data matches the lifecycle exactly.
|
||||
async with data.write_lock:
|
||||
stored = await data.config_store.async_load() or {}
|
||||
cfg = stored.get("config") or {}
|
||||
|
||||
def _collect() -> int:
|
||||
n = sweep_upload_temps(files_dir)
|
||||
# same config on both sides: nothing is superseded, so this
|
||||
# only ever collects what the shared rules call abandoned
|
||||
n += collect_attachments(files_dir, cfg, cfg)
|
||||
n += collect_plans(plans_dir, cfg, cfg)
|
||||
return n
|
||||
|
||||
n = await hass.async_add_executor_job(_collect)
|
||||
if n:
|
||||
_LOGGER.info("House Plan: removed %s unreferenced file(s)", n)
|
||||
except Exception: # noqa: BLE001 — housekeeping must never fail a setup
|
||||
_LOGGER.exception("House Plan: sweeping unreferenced files failed")
|
||||
|
||||
data.sweep = _sweep
|
||||
await _sweep()
|
||||
entry.async_on_unload(
|
||||
async_track_time_interval(hass, _sweep, timedelta(hours=24))
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -11,9 +11,27 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
||||
FILES_URL = "/houseplan_files/files"
|
||||
# authenticated read path (audit B1): /api/houseplan/content/<plans|files>/<sub>/<name>
|
||||
CONTENT_URL = "/api/houseplan/content"
|
||||
|
||||
# How many paths one houseplan/content/sign call may carry. The card batches to
|
||||
# the same number; a client that sends more used to get a partial answer with no
|
||||
# way to tell which paths were dropped (review R2-2).
|
||||
MAX_SIGN_PATHS = 200
|
||||
|
||||
# An uploaded plan that no accepted configuration references is collected only
|
||||
# once it is this old. Age is a race guard, not a policy: a plan uploaded
|
||||
# seconds ago may belong to another client's transaction that has not written
|
||||
# its configuration yet (review R3-1).
|
||||
PLAN_ORPHAN_TTL_S = 3600
|
||||
|
||||
# Kept for compatibility with anything reading it; the collectors no longer use
|
||||
# a long grace at all. Every attempt to age files out ended badly — first by
|
||||
# deleting detached plans, then by racing the save that was about to reference a
|
||||
# retried upload. What is left is deliberately simple: files go when the user's
|
||||
# action says so, plus staging folders after PLAN_ORPHAN_TTL_S.
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.43.2"
|
||||
VERSION = "1.47.0"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -6,6 +6,8 @@ breaks the connection on a large PDF) but via a plain multipart POST — like me
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
@@ -19,7 +21,8 @@ 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 .plans import TMP_PREFIX, reserve_filename
|
||||
from .validation import (
|
||||
FILE_EXTENSIONS,
|
||||
MAX_FILE_BYTES,
|
||||
@@ -31,6 +34,8 @@ from .validation import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_CHUNK = 64 * 1024
|
||||
# batch disk writes: one executor job per megabyte instead of per chunk
|
||||
_FLUSH_AT = 1024 * 1024
|
||||
|
||||
_MIME = {
|
||||
".pdf": "application/pdf",
|
||||
@@ -73,17 +78,35 @@ class HouseplanContentView(HomeAssistantView):
|
||||
if not str(path).startswith(str(base)):
|
||||
return web.Response(status=404)
|
||||
|
||||
def _read() -> bytes | None:
|
||||
return path.read_bytes() if path.is_file() else None
|
||||
|
||||
blob = await hass.async_add_executor_job(_read)
|
||||
if blob is None:
|
||||
if not await hass.async_add_executor_job(path.is_file):
|
||||
return web.Response(status=404)
|
||||
return web.Response(
|
||||
body=blob,
|
||||
content_type=_MIME.get(path.suffix.lower(), "application/octet-stream"),
|
||||
headers={"Cache-Control": "private, max-age=3600"},
|
||||
)
|
||||
suffix = path.suffix.lower()
|
||||
headers = {
|
||||
"Cache-Control": "private, max-age=3600",
|
||||
"Content-Type": _MIME.get(suffix, "application/octet-stream"),
|
||||
}
|
||||
if suffix == ".svg":
|
||||
# An uploaded SVG is user content served from Home Assistant's own
|
||||
# origin. Inside the card it is referenced by <image>, where scripts
|
||||
# never run — but the same url opened as a top-level document is a
|
||||
# live document of this origin, and a <script> in it reaches the
|
||||
# session's localStorage and API (HP-1454-01, 2026-07-28: uploading
|
||||
# needs write access, which by default every authenticated user has,
|
||||
# and the signed url is easy to hand to an admin).
|
||||
#
|
||||
# `sandbox` with no allow-* tokens drops the document into an opaque
|
||||
# origin: no scripts, no same-origin access, no forms. The explicit
|
||||
# directives below are belt and braces for older engines. Only SVG
|
||||
# gets this — a CSP on a PDF response can break the browser's built-in
|
||||
# viewer, and a raster image cannot execute anything in the first place.
|
||||
headers["Content-Security-Policy"] = (
|
||||
"sandbox; default-src 'none'; script-src 'none'; object-src 'none'; "
|
||||
"base-uri 'none'; form-action 'none'; style-src 'unsafe-inline'; img-src data:"
|
||||
)
|
||||
# FileResponse streams from disk: a 50 MB manual used to be read whole
|
||||
# into memory and copied into the response body, so a couple of parallel
|
||||
# downloads could push a small Home Assistant host into swap (HP-1454-06).
|
||||
return web.FileResponse(path, chunk_size=_CHUNK, headers=headers)
|
||||
|
||||
|
||||
class HouseplanUploadView(HomeAssistantView):
|
||||
@@ -95,62 +118,125 @@ 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)
|
||||
|
||||
files_root = Path(hass.config.path(FILES_DIR))
|
||||
marker_id = "misc"
|
||||
filename: str | None = None
|
||||
blob: bytes | None = None
|
||||
too_large = False
|
||||
# Every temporary file this request creates, promoted or not. The outer
|
||||
# `finally` removes whatever is left: a dropped connection, a second
|
||||
# `file` part or a failure while promoting used to leave a `.upload-*`
|
||||
# behind for good, and the collector only ever walks marker folders, so
|
||||
# nothing would have picked it up (HP-1460-02).
|
||||
temps: list[Path] = []
|
||||
error: tuple[dict, int] | None = None
|
||||
|
||||
def _new_tmp() -> Path:
|
||||
files_root.mkdir(parents=True, exist_ok=True)
|
||||
fd, name = tempfile.mkstemp(prefix=TMP_PREFIX, dir=str(files_root))
|
||||
os.close(fd)
|
||||
return Path(name)
|
||||
|
||||
def _flush(target: Path, blocks: list[bytes]) -> None:
|
||||
with open(target, "ab") as fh:
|
||||
for block in blocks:
|
||||
fh.write(block)
|
||||
|
||||
def _cleanup(paths: list[Path]) -> None:
|
||||
for path in paths:
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
async for part in reader:
|
||||
if part.name == "marker_id":
|
||||
marker_id = sanitize_marker_id(await part.text())
|
||||
elif part.name == "file":
|
||||
filename = part.filename or "file"
|
||||
# read in chunks, aborting at the limit, instead of loading the whole file into memory
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
while chunk := await part.read_chunk(_CHUNK):
|
||||
size += len(chunk)
|
||||
if size > MAX_FILE_BYTES:
|
||||
too_large = True
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
async for part in reader:
|
||||
if part.name == "marker_id":
|
||||
marker_id = sanitize_marker_id(await part.text())
|
||||
elif part.name == "file":
|
||||
if filename is not None:
|
||||
# one upload per request: a second part would strand
|
||||
# the first temporary file and make the response
|
||||
# ambiguous about which url was returned
|
||||
error = ({"error": "one_file_only"}, 400)
|
||||
break
|
||||
chunks.append(chunk)
|
||||
if too_large:
|
||||
break
|
||||
blob = b"".join(chunks)
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.warning("House Plan upload: multipart read error: %s", err)
|
||||
return web.json_response({"error": "bad_request"}, status=400)
|
||||
filename = part.filename or "file"
|
||||
if file_ext(filename) not in FILE_EXTENSIONS:
|
||||
error = ({"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, 400)
|
||||
break
|
||||
# Stream to a temporary file instead of collecting the
|
||||
# whole upload in memory and copying it again into one
|
||||
# buffer: a 50 MB manual used to cost ~100 MB of RSS
|
||||
# mid-request (HP-1454-06). Blocks are batched so this
|
||||
# is one executor job per megabyte, not per 64 KB.
|
||||
tmp = await hass.async_add_executor_job(_new_tmp)
|
||||
temps.append(tmp)
|
||||
size = 0
|
||||
pending: list[bytes] = []
|
||||
buffered = 0
|
||||
while chunk := await part.read_chunk(_CHUNK):
|
||||
size += len(chunk)
|
||||
if size > MAX_FILE_BYTES:
|
||||
error = (
|
||||
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024},
|
||||
413,
|
||||
)
|
||||
break
|
||||
pending.append(chunk)
|
||||
buffered += len(chunk)
|
||||
if buffered >= _FLUSH_AT:
|
||||
await hass.async_add_executor_job(_flush, tmp, pending)
|
||||
pending, buffered = [], 0
|
||||
if error:
|
||||
break
|
||||
if pending:
|
||||
await hass.async_add_executor_job(_flush, tmp, pending)
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.warning("House Plan upload: multipart read error: %s", err)
|
||||
error = ({"error": "bad_request"}, 400)
|
||||
|
||||
if too_large:
|
||||
if error:
|
||||
return web.json_response(error[0], status=error[1])
|
||||
if not temps or not filename:
|
||||
return web.json_response({"error": "no_file"}, status=400)
|
||||
|
||||
tmp_path = temps[0]
|
||||
target_dir = files_root / marker_id
|
||||
safe_name = filename
|
||||
|
||||
def _promote() -> str:
|
||||
"""Claim a free name, then move the finished upload onto it.
|
||||
|
||||
Never overwrite an existing attachment: its bytes may be
|
||||
referenced by the stored configuration, and this upload is not
|
||||
part of that transaction — a cancelled dialog or a rejected save
|
||||
would leave the old url serving the new content (HP-1454-02).
|
||||
The name is reserved atomically, so two uploads racing on the
|
||||
same filename cannot agree on it (HP-1460-01).
|
||||
"""
|
||||
name = reserve_filename(target_dir, safe_name)
|
||||
try:
|
||||
os.replace(tmp_path, target_dir / name)
|
||||
except OSError:
|
||||
(target_dir / name).unlink(missing_ok=True)
|
||||
raise
|
||||
return name
|
||||
|
||||
try:
|
||||
name = await hass.async_add_executor_job(_promote)
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan upload: could not store the file: %s", err)
|
||||
return web.json_response({"error": "io_error"}, status=500)
|
||||
temps.remove(tmp_path) # it is the attachment now, not a temporary
|
||||
return web.json_response(
|
||||
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024}, status=413
|
||||
{"ok": True, "url": f"{CONTENT_URL}/files/{marker_id}/{name}", "name": filename}
|
||||
)
|
||||
if blob is None or not filename:
|
||||
return web.json_response({"error": "no_file"}, status=400)
|
||||
ext = file_ext(filename)
|
||||
if ext not in FILE_EXTENSIONS:
|
||||
return web.json_response(
|
||||
{"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, status=400
|
||||
)
|
||||
|
||||
safe_name = sanitize_filename(filename)
|
||||
target_dir = Path(hass.config.path(FILES_DIR)) / marker_id
|
||||
path = target_dir / safe_name
|
||||
|
||||
def _write() -> int:
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(blob)
|
||||
return int(path.stat().st_mtime)
|
||||
|
||||
mtime = await hass.async_add_executor_job(_write)
|
||||
return web.json_response(
|
||||
{"ok": True, "url": f"{CONTENT_URL}/files/{marker_id}/{safe_name}?v={mtime}", "name": filename}
|
||||
)
|
||||
finally:
|
||||
# BaseException too: cancelling the request task raises
|
||||
# asyncio.CancelledError, which an `except Exception` never saw —
|
||||
# an aborted large upload leaked its temporary file every time
|
||||
if temps:
|
||||
await hass.async_add_executor_job(_cleanup, list(temps))
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.43.2"
|
||||
"version": "1.47.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Blob lifecycle — pure, so it is unit-testable without Home Assistant.
|
||||
|
||||
The file system is not part of the configuration store's transaction, so who
|
||||
may write or delete a plan or an attachment, and when, is a correctness
|
||||
question rather than housekeeping. It lives here, apart from the WebSocket and
|
||||
HTTP plumbing, precisely because it is the part that has to be reasoned about
|
||||
and tested.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .const import PLAN_ORPHAN_TTL_S
|
||||
from .validation import MAX_FILENAME, PLAN_EXTENSIONS, sanitize_filename
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Streaming uploads land here first. The prefix is a dot so the name can never
|
||||
# collide with an attachment (sanitize_filename strips leading dots) and is easy
|
||||
# to sweep.
|
||||
TMP_PREFIX = ".upload-"
|
||||
|
||||
|
||||
def reserve_filename(directory: Path, name: str) -> str:
|
||||
"""Atomically claim a free name inside `directory` and return it.
|
||||
|
||||
Creates the file, empty, with `O_CREAT | O_EXCL`, so the name is *taken* the
|
||||
moment it is chosen. The previous version asked `exists()` and returned a
|
||||
string; two uploads racing between the check and the write agreed on the
|
||||
same name and one silently overwrote the other, both reporting success
|
||||
(HP-1460-01). The caller writes the real bytes over the placeholder — it
|
||||
owns the name by then — and must remove it if it never gets that far.
|
||||
|
||||
The result is guaranteed to satisfy `sanitize_filename(result) == result`:
|
||||
the content view sanitises the name in the request too, so a name it would
|
||||
shorten or rewrite is a file that is written and then never served.
|
||||
"""
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
# Split the extension off the RAW name: sanitize_filename() truncates to
|
||||
# MAX_FILENAME, so sanitising first would cut ".pdf" off a long name and the
|
||||
# attachment would be stored — and served — without its type.
|
||||
base = name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
stem, dot, suffix = base.rpartition(".")
|
||||
if not dot:
|
||||
stem, suffix = base, ""
|
||||
stem = sanitize_filename(stem)
|
||||
ext = f".{sanitize_filename(suffix)[:16]}" if suffix else ""
|
||||
i = 1
|
||||
while True:
|
||||
tag = "" if i == 1 else f"-{i}"
|
||||
# budget the stem so the WHOLE name fits, including the collision tag —
|
||||
# appending "-2" to an already maximal name produced a url the view
|
||||
# truncated back to something else, i.e. a permanent 404
|
||||
room = MAX_FILENAME - len(ext) - len(tag)
|
||||
candidate = (stem[:room] if room > 0 else "f") + tag + ext
|
||||
candidate = sanitize_filename(candidate)
|
||||
if candidate.startswith("."): # a name that is only an extension
|
||||
candidate = "file" + candidate
|
||||
try:
|
||||
fd = os.open(directory / candidate, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
||||
except FileExistsError:
|
||||
i += 1
|
||||
if i > 10000: # pathological directory; do not spin forever
|
||||
raise
|
||||
continue
|
||||
os.close(fd)
|
||||
return candidate
|
||||
|
||||
|
||||
def attachment_refs(cfg: dict[str, Any] | None) -> set[str]:
|
||||
""""<marker>/<file>" for every attachment a configuration references."""
|
||||
out: set[str] = set()
|
||||
for m in (cfg or {}).get("markers") or []:
|
||||
for pdf in m.get("pdfs") or []:
|
||||
url = pdf.get("url") if isinstance(pdf, dict) else None
|
||||
if not isinstance(url, str) or "/files/" not in url:
|
||||
continue
|
||||
rel = url.split("?", 1)[0].split("/files/", 1)[1]
|
||||
if rel.count("/") == 1:
|
||||
out.add(rel)
|
||||
return out
|
||||
|
||||
|
||||
def sweep_upload_temps(files_dir: Path, now: float | None = None) -> int:
|
||||
"""Remove abandoned streaming temporaries (HP-1460-02).
|
||||
|
||||
The request itself deletes its own, but a hard kill — a restart mid-upload,
|
||||
an OOM — leaves one behind, and the attachment collector only walks marker
|
||||
folders, so it would never be seen. Age-gated for the same reason as the
|
||||
rest: a fresh one belongs to a request still in flight.
|
||||
"""
|
||||
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
|
||||
removed = 0
|
||||
try:
|
||||
items = [p for p in files_dir.iterdir() if p.is_file()] if files_dir.is_dir() else []
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not list %s: %s", files_dir, err)
|
||||
return 0
|
||||
for item in items:
|
||||
if not item.name.startswith(TMP_PREFIX):
|
||||
continue
|
||||
try:
|
||||
if item.stat().st_mtime >= cutoff:
|
||||
continue
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError:
|
||||
continue
|
||||
return removed
|
||||
|
||||
|
||||
def collect_attachments(
|
||||
files_dir: Path,
|
||||
old_cfg: dict[str, Any] | None,
|
||||
new_cfg: dict[str, Any],
|
||||
now: float | None = None,
|
||||
) -> int:
|
||||
"""The same commit-scoped rule as `collect_plans`, for marker attachments.
|
||||
|
||||
A file the old revision referenced and the new one does not, whose marker
|
||||
still exists, was removed on purpose — the dialog has a trash button and
|
||||
promises nothing. It goes. Everything else is kept, except a staging folder
|
||||
(`up_*`), which by construction only ever holds an upload from a dialog that
|
||||
was never saved: those go after PLAN_ORPHAN_TTL_S. Never raises: it runs
|
||||
behind a durable write.
|
||||
"""
|
||||
new_refs = attachment_refs(new_cfg)
|
||||
old_refs = attachment_refs(old_cfg)
|
||||
# Removing an attachment from a device that still exists is the user saying
|
||||
# "drop this one" — a trash button, no promise that anything is kept. A
|
||||
# device that is GONE is a different transition, and its files follow the
|
||||
# same rule as a deleted space's plan: kept.
|
||||
live_markers = {str(m.get("id")) for m in (new_cfg or {}).get("markers") or []}
|
||||
# Same distinction as for plans. A staging folder (`up_*`) is different: it
|
||||
# only ever holds an upload from a dialog that was never saved, so the short
|
||||
# rule is exactly right there even on the timer.
|
||||
now_s = time.time() if now is None else now
|
||||
staging_cutoff = now_s - PLAN_ORPHAN_TTL_S
|
||||
removed = 0
|
||||
try:
|
||||
folders = sorted(p for p in files_dir.iterdir() if p.is_dir()) if files_dir.is_dir() else []
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not list %s: %s", files_dir, err)
|
||||
return 0
|
||||
removed += sweep_upload_temps(files_dir, now)
|
||||
for folder in folders:
|
||||
# A staging folder only ever holds an upload from a dialog that was never
|
||||
# saved — unambiguous, so an hour is right, and no device owns it.
|
||||
staging = folder.name.startswith("up_")
|
||||
try:
|
||||
items = sorted(p for p in folder.iterdir() if p.is_file())
|
||||
except OSError:
|
||||
continue
|
||||
for item in items:
|
||||
rel = f"{folder.name}/{item.name}"
|
||||
if rel in new_refs:
|
||||
continue
|
||||
dropped = rel in old_refs and folder.name in live_markers
|
||||
if not dropped:
|
||||
if not staging:
|
||||
# Same rule as for plans: not asked for, so kept. A file in
|
||||
# a device's folder that the device does not list is an
|
||||
# upload whose save was rejected — and ageing those out
|
||||
# raced the retry that was about to reference them.
|
||||
continue
|
||||
try:
|
||||
if item.stat().st_mtime >= staging_cutoff:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not remove the attachment %s: %s", item, err)
|
||||
try:
|
||||
next(folder.iterdir())
|
||||
except StopIteration:
|
||||
try:
|
||||
folder.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
return removed
|
||||
|
||||
|
||||
def plan_basename(url: Any) -> str:
|
||||
"""File name a stored plan_url points at ('' when there is none)."""
|
||||
if not isinstance(url, str) or not url:
|
||||
return ""
|
||||
return url.split("?", 1)[0].rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def plan_refs(cfg: dict[str, Any] | None) -> set[str]:
|
||||
"""Plan file names a configuration references."""
|
||||
out: set[str] = set()
|
||||
for sp in (cfg or {}).get("spaces") or []:
|
||||
name = plan_basename(sp.get("plan_url"))
|
||||
if name:
|
||||
out.add(name)
|
||||
return out
|
||||
|
||||
|
||||
def plan_by_space(cfg: dict[str, Any] | None) -> dict[str, str]:
|
||||
"""space id -> the plan file it references ('' when it has none)."""
|
||||
return {
|
||||
str(sp.get("id")): plan_basename(sp.get("plan_url"))
|
||||
for sp in (cfg or {}).get("spaces") or []
|
||||
}
|
||||
|
||||
|
||||
def is_plan_file(name: str) -> bool:
|
||||
"""Does this look like a plan we wrote: <space>.<ext> or <space>.<token>.<ext>?"""
|
||||
parts = name.split(".")
|
||||
return len(parts) in (2, 3) and parts[-1].lower() in PLAN_EXTENSIONS
|
||||
|
||||
|
||||
def collect_plans(
|
||||
plans_dir: Path,
|
||||
old_cfg: dict[str, Any] | None,
|
||||
new_cfg: dict[str, Any],
|
||||
now: float | None = None,
|
||||
) -> int:
|
||||
"""Drop plan files the accepted configuration made obsolete (review R3-1).
|
||||
|
||||
Called inside the config write lock, right after the new revision is
|
||||
stored, so it decides from the two configurations that actually bracket the
|
||||
commit instead of trusting a client to say what may be deleted. The earlier
|
||||
design — a `plan/cleanup` command carrying `keep` — could not be ordered
|
||||
against another client's commit: a delayed call removed the file that
|
||||
client had just saved, leaving the accepted configuration pointing at
|
||||
nothing, which is the damage copy-on-write was introduced to prevent.
|
||||
|
||||
Two rules, both conservative:
|
||||
* a file the OLD configuration referenced and the new one does not was
|
||||
authoritative and has been superseded — remove it;
|
||||
* any other unreferenced plan file is a rejected or abandoned upload, and
|
||||
is removed only once PLAN_ORPHAN_TTL_S has passed: a fresh one may
|
||||
belong to a transaction that has not committed yet.
|
||||
|
||||
Never raises: the configuration is already stored by the time this runs, so
|
||||
a file-system problem must not turn a durable commit into a failed call.
|
||||
"""
|
||||
new_refs = plan_refs(new_cfg)
|
||||
old_refs = plan_refs(old_cfg)
|
||||
# A commit knows what it superseded. The timer only knows what nothing
|
||||
# points at *right now*, and for a plan that is a reversible state: the
|
||||
# editor detaches the image when a space switches to "draw" and says the
|
||||
# file stays on disk. So the scheduled pass keeps anything belonging to a
|
||||
# space that still exists, and waits a month for the rest.
|
||||
# A space with NO plan_url has had its image detached — reversible, and the
|
||||
# editor promises the file stays. A space that HAS one is different: any
|
||||
# other file of its own is a superseded or rejected upload, so the short
|
||||
# rule is right for those. Getting this distinction wrong (protecting
|
||||
# nothing) destroyed two detached plans on 2026-07-28.
|
||||
# The short rule fits exactly one case: a space that HAS a plan, where any
|
||||
# other file of its own can only be a superseded or rejected upload.
|
||||
old_by_space = plan_by_space(old_cfg)
|
||||
new_by_space = plan_by_space(new_cfg)
|
||||
# A file that left the configuration tells us nothing on its own: replacing a
|
||||
# plan, detaching one and deleting a space all look identical from
|
||||
# `old_refs - new_refs`. Only the first is a deletion the user asked for
|
||||
# (HP-1465-01 — the guards below were written and then never reached,
|
||||
# because the code decided "superseded" before asking why).
|
||||
replaced = {
|
||||
name for space, name in old_by_space.items()
|
||||
if new_by_space.get(space) and new_by_space[space] != name
|
||||
}
|
||||
removed = 0
|
||||
try:
|
||||
items = sorted(plans_dir.iterdir()) if plans_dir.is_dir() else []
|
||||
except OSError as err:
|
||||
# The directory can vanish or turn unreadable between the check and the
|
||||
# walk. This is housekeeping running behind a commit that is already
|
||||
# durable, so it reports "nothing collected" instead of failing (R4-1).
|
||||
_LOGGER.warning("House Plan: could not list %s: %s", plans_dir, err)
|
||||
return 0
|
||||
for item in items:
|
||||
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
|
||||
continue
|
||||
if item.name not in replaced:
|
||||
# PRODUCT RULE (owner's decision, 2026-07-28): a plan file we were
|
||||
# not told to delete is kept, however long it sits there. Detaching
|
||||
# is one click to undo and the editor says the image stays; deleting
|
||||
# a space is deliberate but the image was imported and may be
|
||||
# nowhere else. The errors are not symmetrical — unnecessary
|
||||
# megabytes can be removed by hand, a deleted file cannot be
|
||||
# brought back.
|
||||
#
|
||||
# There is deliberately no age rule here. An earlier version aged
|
||||
# out "rejected uploads" — a file of a space that has a plan, which
|
||||
# was never the plan — and that raced a save: the sweep deleted the
|
||||
# upload from the failed attempt while a retry was committing a
|
||||
# reference to it. A rule that can delete a file somebody is about
|
||||
# to point at is not worth the disk it reclaims.
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not remove the old plan %s: %s", item, err)
|
||||
return removed
|
||||
@@ -51,8 +51,17 @@ async def async_check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntr
|
||||
translation_key="broken_plan",
|
||||
translation_placeholders={"space": space_id, "file": fname},
|
||||
)
|
||||
# clear stale issues for spaces that are fine again (or gone)
|
||||
for sp in spaces:
|
||||
sid = sp.get("id", "?")
|
||||
if sid not in broken:
|
||||
ir.async_delete_issue(hass, DOMAIN, f"broken_plan_{sid}")
|
||||
# Clear stale issues. Iterating the CURRENT spaces could only ever clear
|
||||
# issues for spaces that still exist, so deleting or renaming a space with a
|
||||
# missing plan left its warning in Repairs forever, with nothing left to fix
|
||||
# it (HP-1454-09). Enumerate what we actually published instead.
|
||||
registry = ir.async_get(hass)
|
||||
stale = [
|
||||
issue_id
|
||||
for (domain, issue_id) in list(registry.issues)
|
||||
if domain == DOMAIN
|
||||
and issue_id.startswith("broken_plan_")
|
||||
and issue_id[len("broken_plan_") :] not in broken
|
||||
]
|
||||
for issue_id in stale:
|
||||
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
@@ -42,6 +43,11 @@ class HouseplanData:
|
||||
# One lock for every load→modify→save cycle of both stores: prevents
|
||||
# lost updates from concurrent WS calls and makes the rev check atomic.
|
||||
write_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
# Collect files nothing references any more. Set during setup, which also
|
||||
# runs it once and schedules it daily. Exposed so it can be invoked
|
||||
# directly — a test that fakes a 24 h jump proves the timer fires, not that
|
||||
# the work happens, and those are different claims.
|
||||
sweep: Callable[[], Awaitable[None]] | None = None
|
||||
|
||||
|
||||
HouseplanConfigEntry = ConfigEntry[HouseplanData]
|
||||
|
||||
@@ -16,6 +16,9 @@ MAX_FILE_BYTES = 50 * 1024 * 1024
|
||||
|
||||
SPACE_ID_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
|
||||
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
# The name length the content view will accept back in a request. Anything a
|
||||
# generated name must fit inside, collision tag included (HP-1460-01).
|
||||
MAX_FILENAME = 120
|
||||
|
||||
# ---------- sanitizers ----------
|
||||
|
||||
@@ -33,7 +36,7 @@ def sanitize_marker_id(value: str) -> str:
|
||||
def sanitize_filename(value: str) -> str:
|
||||
"""Drop the path and leading dots, keep a safe file name."""
|
||||
raw = value.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
return _SAFE_NAME_RE.sub("_", raw).lstrip(".")[:120] or "file"
|
||||
return _SAFE_NAME_RE.sub("_", raw).lstrip(".")[:MAX_FILENAME] or "file"
|
||||
|
||||
|
||||
def file_ext(filename: str) -> str:
|
||||
@@ -66,6 +69,31 @@ MAX_MARKERS = 2000
|
||||
MAX_OPENINGS = 500
|
||||
MAX_DECOR = 1000
|
||||
MAX_LAYOUT = 5000
|
||||
# Inner limits (HP-1454-05). The outer collections were capped, the collections
|
||||
# INSIDE them were not: a 150 000-point polygon or a 100 000-entry known_devices
|
||||
# list passed validation, then made the card build gigantic SVG attributes and
|
||||
# walk them on every render. Any authenticated writer could store one, and with
|
||||
# `admin_only` off that is every user. These are product limits, not guesses: a
|
||||
# hand-drawn room does not need 500 vertices, and no home has 200 lights behind
|
||||
# one switch.
|
||||
MAX_POLY_POINTS = 500
|
||||
MAX_OPEN_TO = 50
|
||||
MAX_CONTROLS = 200
|
||||
MAX_PDFS = 50
|
||||
MAX_KNOWN_DEVICES = 20000
|
||||
MAX_TEXT = 500 # names, models, ids
|
||||
MAX_DESCRIPTION = 4000
|
||||
MAX_URL = 2000
|
||||
# Comfortably below the WebSocket frame limit (aiohttp's default is 4 MB): a
|
||||
# payload larger than the frame never reaches the handler at all — the socket
|
||||
# closes with 1009 and the user sees a dropped connection instead of an error
|
||||
# they can act on. For scale, a real three-floor home with ~200 devices stores
|
||||
# about 70 KB, so this is ~30x headroom.
|
||||
MAX_CONFIG_BYTES = 2 * 1024 * 1024
|
||||
|
||||
_TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
|
||||
_TEXT_OR_NONE = vol.Any(None, _TEXT)
|
||||
_URL = vol.All(str, vol.Length(max=MAX_URL))
|
||||
|
||||
POS_SCHEMA = vol.Schema(
|
||||
{vol.Required("x"): _finite, vol.Required("y"): _finite},
|
||||
@@ -73,7 +101,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:
|
||||
@@ -85,10 +113,10 @@ def _require_geometry(room: dict) -> dict:
|
||||
ROOM_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("name"): str,
|
||||
vol.Optional("area"): vol.Any(str, None),
|
||||
vol.Optional("open_to"): [str],
|
||||
vol.Required("id"): _TEXT,
|
||||
vol.Required("name"): _TEXT,
|
||||
vol.Optional("area"): _TEXT_OR_NONE,
|
||||
vol.Optional("open_to"): vol.All([_TEXT], vol.Length(max=MAX_OPEN_TO)),
|
||||
vol.Optional("settings"): vol.Any(
|
||||
None,
|
||||
vol.Schema(
|
||||
@@ -102,11 +130,11 @@ 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("poly"): vol.All([POINT], vol.Length(min=3)),
|
||||
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, max=MAX_POLY_POINTS)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
@@ -161,17 +189,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,11 +209,14 @@ 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.
|
||||
vol.Optional("segments"): [vol.All([vol.Coerce(float)], vol.Length(min=4, max=4))],
|
||||
# Accepted so a stale browser tab cannot fail a save, then DROPPED here
|
||||
# (HP-1454-05): relying on a modern client to strip an unbounded legacy
|
||||
# list is not a limit, it is a hope. `Remove` returns the key stripped.
|
||||
vol.Remove("segments"): object,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
@@ -197,23 +228,27 @@ MARKER_SCHEMA = vol.Schema(
|
||||
vol.Optional("space"): vol.Any(str, None),
|
||||
vol.Optional("area"): vol.Any(str, None),
|
||||
vol.Optional("hidden"): bool,
|
||||
vol.Optional("name"): vol.Any(str, None),
|
||||
vol.Optional("icon"): vol.Any(str, None),
|
||||
vol.Optional("model"): vol.Any(str, None),
|
||||
vol.Optional("link"): vol.Any(str, None),
|
||||
vol.Optional("description"): vol.Any(str, None),
|
||||
vol.Optional("name"): _TEXT_OR_NONE,
|
||||
vol.Optional("icon"): _TEXT_OR_NONE,
|
||||
vol.Optional("model"): _TEXT_OR_NONE,
|
||||
vol.Optional("link"): vol.Any(None, _URL),
|
||||
vol.Optional("description"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DESCRIPTION))),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
|
||||
vol.Optional("controls"): vol.Any([str], None),
|
||||
vol.Optional("controls"): vol.Any(None, vol.All([_TEXT], vol.Length(max=MAX_CONTROLS))),
|
||||
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
|
||||
vol.Optional("is_light"): vol.Any(bool, None),
|
||||
vol.Optional("room_id"): vol.Any(str, None),
|
||||
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", None),
|
||||
# keep in sync with DISPLAY_MODES in src/logic.ts — a cross-language test
|
||||
# asserts every option the editor offers is accepted here (issue #3)
|
||||
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", "value", None),
|
||||
vol.Optional("ripple_color"): vol.Any(str, None),
|
||||
vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None),
|
||||
vol.Optional("size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0.2, max=6)), None),
|
||||
vol.Optional("angle"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-360, max=360)), None),
|
||||
vol.Optional("pdfs"): [
|
||||
vol.Schema({vol.Required("name"): str, vol.Required("url"): str}, extra=vol.ALLOW_EXTRA)
|
||||
],
|
||||
vol.Optional("pdfs"): vol.All(
|
||||
[vol.Schema({vol.Required("name"): _TEXT, vol.Required("url"): _URL}, extra=vol.ALLOW_EXTRA)],
|
||||
vol.Length(max=MAX_PDFS),
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
@@ -224,8 +259,8 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
vol.Optional("settings", default=dict): vol.Schema(
|
||||
{
|
||||
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
|
||||
vol.Optional("known_devices"): [str],
|
||||
vol.Optional("new_device_ids"): [str],
|
||||
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
vol.Optional("fill_colors"): vol.Schema(
|
||||
{
|
||||
str: vol.Schema(
|
||||
|
||||
@@ -5,6 +5,8 @@ import logging
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -15,12 +17,17 @@ from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import (
|
||||
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
|
||||
CONTENT_URL, PLANS_DIR, PLANS_URL,
|
||||
CONTENT_URL, FILES_DIR, MAX_SIGN_PATHS, PLANS_DIR, PLANS_URL,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .plans import (
|
||||
collect_attachments, collect_plans, is_plan_file, plan_basename, plan_refs,
|
||||
reserve_filename,
|
||||
)
|
||||
from .store import HouseplanData, get_data, get_entry
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, valid_space_id,
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, sanitize_filename, valid_space_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -37,7 +44,11 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_config_get)
|
||||
websocket_api.async_register_command(hass, ws_config_set)
|
||||
websocket_api.async_register_command(hass, ws_plan_set)
|
||||
websocket_api.async_register_command(hass, ws_plans_list)
|
||||
websocket_api.async_register_command(hass, ws_plans_delete)
|
||||
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:
|
||||
@@ -54,18 +65,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 ----------------
|
||||
@@ -79,7 +80,9 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
if rt is None:
|
||||
return
|
||||
data = await rt.store.async_load() or {}
|
||||
connection.send_result(msg["id"], {"layout": data.get("layout", {})})
|
||||
connection.send_result(
|
||||
msg["id"], {"layout": data.get("layout", {}), "rev": int(data.get("rev", 0))}
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -112,8 +115,10 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
msg["id"], "conflict", f"Layout changed elsewhere (rev {current_rev})"
|
||||
)
|
||||
return
|
||||
await rt.store.async_save({"layout": msg["layout"], "rev": current_rev + 1})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": current_rev + 1})
|
||||
new_rev = current_rev + 1
|
||||
await rt.store.async_save({"layout": msg["layout"], "rev": new_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -136,8 +141,13 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
data = await rt.store.async_load() or {}
|
||||
layout = data.get("layout", {})
|
||||
layout[msg["device_id"]] = msg["pos"]
|
||||
await rt.store.async_save({"layout": layout})
|
||||
connection.send_result(msg["id"], {"ok": True})
|
||||
# keep the revision: a point-wise write used to drop it, which made the
|
||||
# optimistic locking on layout/set meaningless — every drag reset the
|
||||
# counter to 0 (HP-1454-08)
|
||||
new_rev = int(data.get("rev", 0)) + 1
|
||||
await rt.store.async_save({"layout": layout, "rev": new_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -149,17 +159,24 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Move a marker's uploaded files to its new id (rebinding changes the id).
|
||||
"""COPY a marker's uploaded files to its new id and report the exact mapping.
|
||||
|
||||
Without this the PDF urls keep pointing at the OLD id's folder, which then
|
||||
looks orphaned and is one cleanup away from deletion — the exact way the
|
||||
owner lost the sauna heater manuals (field incident, 2026-07-26).
|
||||
Rebinding changes the marker id, so the files must follow (that is how the
|
||||
owner lost a set of manuals, 2026-07-26). This used to MOVE them before the
|
||||
revision-checked config save: when that save was rejected, the server kept
|
||||
the old urls while the files had already left the old folder — a permanent
|
||||
broken link (review CR-2, 2026-07-27).
|
||||
|
||||
Now it copies, never overwrites, and returns {src: dst} for every file so
|
||||
the client can rewrite EXACTLY the urls that made it (review CR-3). The old
|
||||
folder is removed later by houseplan/files/cleanup, once the config is
|
||||
safely committed.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit files")
|
||||
return
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from .const import FILES_DIR
|
||||
from .validation import sanitize_marker_id
|
||||
@@ -167,32 +184,246 @@ async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
src_id = sanitize_marker_id(msg["from_id"])
|
||||
dst_id = sanitize_marker_id(msg["to_id"])
|
||||
if not src_id or not dst_id or src_id == dst_id:
|
||||
connection.send_result(msg["id"], {"ok": True, "moved": 0})
|
||||
connection.send_result(msg["id"], {"ok": True, "mapping": {}, "copied": 0})
|
||||
return
|
||||
base = Path(hass.config.path(FILES_DIR))
|
||||
src = base / src_id
|
||||
dst = base / dst_id
|
||||
|
||||
def _move() -> int:
|
||||
def _copy() -> dict[str, str]:
|
||||
if not src.is_dir():
|
||||
return 0
|
||||
return {}
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
n = 0
|
||||
for f in src.iterdir():
|
||||
mapping: dict[str, str] = {}
|
||||
for f in sorted(src.iterdir()):
|
||||
if not f.is_file():
|
||||
continue
|
||||
target = dst / f.name
|
||||
if not target.exists():
|
||||
shutil.move(str(f), str(target))
|
||||
n += 1
|
||||
try:
|
||||
src.rmdir() # only when empty
|
||||
except OSError:
|
||||
pass
|
||||
return n
|
||||
# a different file may already own this name — do NOT silently point
|
||||
# the url at it. The shared helper CLAIMS a free one atomically, so
|
||||
# a concurrent migrate or upload cannot pick the same one, and the
|
||||
# name it returns is one the content view accepts back in a request.
|
||||
name = reserve_filename(dst, f.name)
|
||||
target = dst / name
|
||||
try:
|
||||
shutil.copy2(str(f), str(target))
|
||||
except OSError:
|
||||
target.unlink(missing_ok=True) # never leave an empty placeholder
|
||||
raise
|
||||
mapping[f.name] = name
|
||||
return mapping
|
||||
|
||||
moved = await hass.async_add_executor_job(_move)
|
||||
connection.send_result(msg["id"], {"ok": True, "moved": moved})
|
||||
try:
|
||||
mapping = await hass.async_add_executor_job(_copy)
|
||||
except OSError as err:
|
||||
connection.send_error(msg["id"], "io_error", f"Could not copy marker files: {err}")
|
||||
return
|
||||
connection.send_result(msg["id"], {"ok": True, "mapping": mapping, "copied": len(mapping)})
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/plans/list"})
|
||||
@websocket_api.async_response
|
||||
async def ws_plans_list(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Plan images on the server, with what still uses them.
|
||||
|
||||
Files are never removed for being unreferenced (docs/SCOPE.md), which only
|
||||
works as a policy if the user can see them: detaching a plan keeps the
|
||||
image, and this is how it gets picked up again — or deleted on purpose.
|
||||
"""
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
cfg = stored.get("config") or {}
|
||||
used: dict[str, list[str]] = {}
|
||||
for space in cfg.get("spaces") or []:
|
||||
name = plan_basename(space.get("plan_url"))
|
||||
if name:
|
||||
used.setdefault(name, []).append(space.get("title") or space.get("id") or "?")
|
||||
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
|
||||
def _scan() -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
if not plans_dir.is_dir():
|
||||
return out
|
||||
for item in sorted(plans_dir.iterdir()):
|
||||
if not item.is_file() or not is_plan_file(item.name):
|
||||
continue
|
||||
try:
|
||||
st = item.stat()
|
||||
except OSError:
|
||||
continue
|
||||
out.append({
|
||||
"name": item.name,
|
||||
"url": f"{CONTENT_URL}/plans/_/{item.name}",
|
||||
"size": st.st_size,
|
||||
"modified": int(st.st_mtime),
|
||||
"used_by": used.get(item.name, []),
|
||||
})
|
||||
out.sort(key=lambda x: -x["modified"])
|
||||
return out
|
||||
|
||||
connection.send_result(msg["id"], {"plans": await hass.async_add_executor_job(_scan)})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/plans/delete",
|
||||
vol.Required("name"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_plans_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Delete a plan image because the user asked — the only way one goes.
|
||||
|
||||
Refuses while a space still references it: the answer to "can I delete this"
|
||||
is the stored configuration's, not the client's.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may delete plans")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
name = sanitize_filename(msg["name"])
|
||||
if not is_plan_file(name):
|
||||
connection.send_error(msg["id"], "invalid_name", "Not a plan file")
|
||||
return
|
||||
|
||||
async with rt.write_lock:
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
cfg = stored.get("config") or {}
|
||||
if name in plan_refs(cfg):
|
||||
connection.send_error(
|
||||
msg["id"], "in_use", "A space still uses this plan — detach it first"
|
||||
)
|
||||
return
|
||||
path = Path(hass.config.path(PLANS_DIR)) / name
|
||||
|
||||
def _rm() -> bool:
|
||||
try:
|
||||
path.unlink()
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not delete %s: %s", path, err)
|
||||
return False
|
||||
|
||||
removed = await hass.async_add_executor_job(_rm)
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed})
|
||||
|
||||
|
||||
@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"][:MAX_SIGN_PATHS]:
|
||||
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",
|
||||
vol.Required("marker_id"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_files_cleanup(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Drop a marker folder's leftovers after its files moved elsewhere.
|
||||
|
||||
Called after a rebind: the files were copied to the new marker id and the
|
||||
config that references them is committed, so the source folder is spent.
|
||||
|
||||
It used to `rmtree` the folder on the client's word alone. Two ways that
|
||||
ends badly: a partial copy leaves some urls still pointing INTO this folder
|
||||
(the migration deliberately does not rewrite those), and a wrong or stale
|
||||
id from any client deletes a live marker's attachments outright. So the
|
||||
server checks for itself — under the config lock — and removes only files
|
||||
the stored configuration does not reference. Same principle as the
|
||||
collector: a client may say what it no longer needs, never what may go.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit files")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
from .const import FILES_DIR
|
||||
from .plans import attachment_refs
|
||||
from .validation import sanitize_marker_id
|
||||
|
||||
mid = sanitize_marker_id(msg["marker_id"])
|
||||
base = Path(hass.config.path(FILES_DIR)).resolve()
|
||||
target = (base / mid).resolve() if mid else base
|
||||
if not mid or not str(target).startswith(str(base)) or target == base:
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": 0, "kept": 0})
|
||||
return
|
||||
|
||||
async with rt.write_lock:
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
refs = attachment_refs(stored.get("config") or {})
|
||||
|
||||
def _rm() -> tuple[int, int]:
|
||||
if not target.is_dir():
|
||||
return 0, 0
|
||||
removed = kept = 0
|
||||
for item in sorted(target.iterdir()):
|
||||
if not item.is_file():
|
||||
continue
|
||||
if f"{mid}/{item.name}" in refs:
|
||||
kept += 1
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not remove %s: %s", item, err)
|
||||
if not kept:
|
||||
try:
|
||||
target.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
return removed, kept
|
||||
|
||||
removed, kept = await hass.async_add_executor_job(_rm)
|
||||
if kept:
|
||||
_LOGGER.info(
|
||||
"House Plan: kept %s file(s) in %s — the configuration still references them", kept, mid
|
||||
)
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed, "kept": kept})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -210,13 +441,17 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
new_rev: int | None = None
|
||||
async with rt.write_lock:
|
||||
data = await rt.store.async_load() or {}
|
||||
layout = data.get("layout", {})
|
||||
if msg["device_id"] in layout:
|
||||
del layout[msg["device_id"]]
|
||||
await rt.store.async_save({"layout": layout})
|
||||
connection.send_result(msg["id"], {"ok": True})
|
||||
new_rev = int(data.get("rev", 0)) + 1
|
||||
await rt.store.async_save({"layout": layout, "rev": new_rev})
|
||||
if new_rev is not None:
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
|
||||
|
||||
# ---------------- space configuration ----------------
|
||||
@@ -234,6 +469,7 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
connection.send_result(msg["id"], {"config": config, "rev": data.get("rev", 0)})
|
||||
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/config/set",
|
||||
@@ -255,6 +491,16 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
# Per-field limits bound each list; this bounds their product (HP-1454-05).
|
||||
# Everything below the caps can still add up to something no dashboard can
|
||||
# render, and the store writes it to disk on every save.
|
||||
size = len(json.dumps(msg["config"], separators=(",", ":")))
|
||||
if size > MAX_CONFIG_BYTES:
|
||||
connection.send_error(
|
||||
msg["id"], "too_large",
|
||||
f"Configuration is {size // 1024} KB, the limit is {MAX_CONFIG_BYTES // 1024} KB",
|
||||
)
|
||||
return
|
||||
async with rt.write_lock:
|
||||
data = await rt.config_store.async_load() or {}
|
||||
current_rev = data.get("rev", 0)
|
||||
@@ -275,6 +521,20 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
|
||||
# Still holding the lock: the file system is not part of the store's
|
||||
# transaction, so collection has to be pinned to this commit (R3-1).
|
||||
# It is best-effort housekeeping behind an already durable write — a
|
||||
# failure here must not withhold the event and the success response,
|
||||
# or the client retries an edit the server has already accepted and
|
||||
# gets a conflict for its trouble (R4-1).
|
||||
def _collect() -> None:
|
||||
collect_plans(Path(hass.config.path(PLANS_DIR)), data.get("config"), msg["config"])
|
||||
collect_attachments(Path(hass.config.path(FILES_DIR)), data.get("config"), msg["config"])
|
||||
|
||||
try:
|
||||
await hass.async_add_executor_job(_collect)
|
||||
except Exception: # noqa: BLE001 — see above: the commit stands regardless
|
||||
_LOGGER.exception("House Plan: collecting superseded files failed")
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
|
||||
# refresh repair issues (broken plan references) without waiting for a restart
|
||||
entry = get_entry(hass)
|
||||
@@ -315,20 +575,26 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
|
||||
connection.send_error(msg["id"], "too_large", f"Plan is larger than {MAX_PLAN_BYTES // 1024 // 1024} MB")
|
||||
return
|
||||
|
||||
# Copy-on-write: a plan is written under a NEW unique name and nothing is
|
||||
# deleted here (review R2-1). The old name stays readable, so a config write
|
||||
# that is later rejected — revision conflict, validation, lost connection —
|
||||
# leaves the stored plan exactly as it was. The card calls
|
||||
# nothing here; the file a commit REPLACES is collected by `config/set`
|
||||
# itself, inside the write lock (review R3-1). An upload that never gets
|
||||
# committed is not collected at all — it is offered back in the space
|
||||
# dialog's "already uploaded" list, where the user can attach or delete it.
|
||||
# Every attempt to age these out ended in data loss or a race (v1.46.4-6).
|
||||
#
|
||||
# `.` separates the id from the token because a space id cannot contain one
|
||||
# (SPACE_ID_RE), so "<space>.<token>.<ext>" can never be confused with the
|
||||
# files of a differently named space.
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
path = plans_dir / f"{space_id}.{msg['ext']}"
|
||||
name = f"{space_id}.{secrets.token_hex(4)}.{msg['ext']}"
|
||||
path = plans_dir / name
|
||||
|
||||
def _write() -> int:
|
||||
def _write() -> None:
|
||||
plans_dir.mkdir(parents=True, exist_ok=True)
|
||||
# remove old variants with a different extension
|
||||
for old_ext in PLAN_EXTENSIONS:
|
||||
old = plans_dir / f"{space_id}.{old_ext}"
|
||||
if old_ext != msg["ext"] and old.exists():
|
||||
old.unlink()
|
||||
path.write_bytes(raw)
|
||||
return int(path.stat().st_mtime)
|
||||
|
||||
mtime = await hass.async_add_executor_job(_write)
|
||||
connection.send_result(
|
||||
msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{space_id}.{msg['ext']}?v={mtime}"}
|
||||
)
|
||||
await hass.async_add_executor_job(_write)
|
||||
connection.send_result(msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{name}"})
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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 calls = [];
|
||||
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push([d, s, data.entity_id]); return Promise.resolve(); } };
|
||||
await c.updateComplete;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
// устройство с управляемой сущностью
|
||||
const dev = c._devices.find((d) => d.entities?.some((e) => e.startsWith('light.') || e.startsWith('switch.')));
|
||||
out.hasDev = !!dev;
|
||||
c._infoCard = dev; await c.updateComplete;
|
||||
// 1) блок сущностей идёт ПЕРВЫМ, до модели/ссылок
|
||||
const body = sr().querySelector('.dialog .body');
|
||||
out.entListFirst = body.firstElementChild?.classList.contains('entlist')
|
||||
|| body.querySelector('.entlist') === body.children[0];
|
||||
const rows = [...sr().querySelectorAll('.entrow')];
|
||||
out.rows = rows.length > 0;
|
||||
// 2) у переключаемой сущности — кнопка с крупной зоной нажатия
|
||||
const btn = sr().querySelector('.entbtn');
|
||||
out.hasButton = !!btn;
|
||||
const box = btn?.getBoundingClientRect();
|
||||
out.tapTarget = box ? box.height >= 30 && box.width >= 60 : null;
|
||||
// 3) кнопка реально переключает
|
||||
const before = calls.length;
|
||||
btn.click(); await c.updateComplete;
|
||||
out.toggles = calls.length > before && calls.at(-1)[1] === 'toggle';
|
||||
// 4) замок никогда не переключается из карточки
|
||||
const n = calls.length;
|
||||
c._cardToggle('lock.front_door');
|
||||
out.lockNeverToggles = calls.length === n;
|
||||
// 5) диагностические/конфиг-сущности не засоряют список
|
||||
const ents = c._cardEntities(dev).map((e) => e.eid);
|
||||
out.noConfigEntities = ents.every((e) => {
|
||||
const cat = c.hass.entities[e]?.entity_category;
|
||||
return cat !== 'config' && cat !== 'diagnostic';
|
||||
});
|
||||
c._infoCard = null; await c.updateComplete;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,76 @@
|
||||
// Ревью R2-3: климат комнат считался отдельным обходом реестра на каждую
|
||||
// комнату и каждую величину — 60 комнат × 2000 сущностей съедали кадр на
|
||||
// перечитывании метаданных, которые не менялись. Карта строится один раз на
|
||||
// снимок hass; при этом новые состояния датчиков обязаны попадать в неё сразу.
|
||||
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;
|
||||
|
||||
// считаем обходы реестра через ownKeys — именно его дёргает Object.entries
|
||||
let scans = 0;
|
||||
const wrap = (h) => {
|
||||
const ents = h.entities;
|
||||
const traced = new Proxy(ents, { ownKeys(t) { scans++; return Reflect.ownKeys(t); } });
|
||||
return { ...h, entities: traced };
|
||||
};
|
||||
const fresh = () => wrap(window.__mkHass());
|
||||
|
||||
// включаем и заливку по температуре, и подписи — два потребителя климата
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : {
|
||||
...s, settings: { ...(s.settings || {}), show_names: true, fill_mode: 'temp', label_temp: true, label_hum: true },
|
||||
})};
|
||||
c._cfgEpoch++;
|
||||
c.hass = fresh(); await c.updateComplete;
|
||||
|
||||
scans = 0;
|
||||
c.hass = fresh(); await c.updateComplete;
|
||||
const fewRooms = scans;
|
||||
|
||||
// повторные рендеры на том же снимке hass реестр не трогают
|
||||
scans = 0;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.scansOnRerender = scans;
|
||||
|
||||
// главный инвариант: обходов НЕ становится больше от числа комнат
|
||||
const f1 = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
const extra = [];
|
||||
for (let i = 0; i < 40; i++) {
|
||||
extra.push({ id: 'gen' + i, name: 'R' + i, area: 'living_room',
|
||||
poly: [[0.01, 0.01], [0.02, 0.01], [0.02, 0.02], [0.01, 0.02]] });
|
||||
}
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) =>
|
||||
s.id !== 'f1' ? s : { ...s, rooms: [...s.rooms, ...extra] }) };
|
||||
c._cfgEpoch++;
|
||||
c.hass = fresh(); await c.updateComplete;
|
||||
scans = 0;
|
||||
c.hass = fresh(); await c.updateComplete;
|
||||
out.roomCount = c._spaceModel('f1').rooms.length;
|
||||
out.scansSameWith44Rooms = scans === fewRooms;
|
||||
out.scansPerUpdate = scans;
|
||||
|
||||
// при этом новое состояние датчика обязано быть видно, а не взято из кэша
|
||||
out.tempBefore = c._climate().get('living_room')?.temp;
|
||||
const h = fresh();
|
||||
h.states = { ...h.states, 'sensor.living_temp': { ...h.states['sensor.living_temp'], state: '33.3' } };
|
||||
c.hass = h; await c.updateComplete;
|
||||
out.tempAfter = c._climate().get('living_room')?.temp;
|
||||
out.climateIsMap = c._climate() instanceof Map;
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.45.0 и сверено с кодом.
|
||||
// scansPerUpdate = 2: один обход у areaClimateMap, один у buildDevices. Важно
|
||||
// не само число, а что оно не растёт вместе с числом комнат.
|
||||
checkAll(res, {
|
||||
scansOnRerender: 0,
|
||||
roomCount: 44,
|
||||
scansSameWith44Rooms: true,
|
||||
scansPerUpdate: 2,
|
||||
tempBefore: 22.4,
|
||||
tempAfter: 33.3,
|
||||
climateIsMap: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,67 @@
|
||||
// HP-1454-03: две локальные правки уходили с одной ревизией, вторая терялась.
|
||||
// Debounce разносил только СТАРТЫ. Если первый config/set отвечал дольше 500 мс,
|
||||
// вторая правка уходила с тем же expected_rev, сервер принимал первую и
|
||||
// отклонял вторую как conflict — а обработчик конфликта перечитывал серверную
|
||||
// копию поверх локальной. Правка исчезала, и тост винил «другое окно».
|
||||
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;
|
||||
const writes = [];
|
||||
let rev = 10;
|
||||
let releaseFirst;
|
||||
const firstGate = new Promise((r) => { releaseFirst = r; });
|
||||
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/set') {
|
||||
const n = writes.length + 1;
|
||||
writes.push({ expected: m.expected_rev, titles: m.config.spaces.map((s) => s.title) });
|
||||
if (n === 1) await firstGate; // первый ответ задержан
|
||||
if (m.expected_rev !== rev) { const e = new Error('conflict'); e.code = 'conflict'; throw e; }
|
||||
rev += 1;
|
||||
return { ok: true, rev };
|
||||
}
|
||||
if (m.type === 'houseplan/config/get') {
|
||||
const r = await base(m);
|
||||
return { config: JSON.parse(JSON.stringify(r.config)), rev };
|
||||
}
|
||||
return base(m);
|
||||
} };
|
||||
c._cfgRev = rev;
|
||||
|
||||
// правка №1 и, пока первая запись висит, правка №2
|
||||
c._serverCfg.spaces[0].title = 'FIRST';
|
||||
c._saveConfig();
|
||||
c._saveConfigDebounced.flush();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
out.oneInFlight = writes.length === 1;
|
||||
|
||||
c._serverCfg.spaces[0].title = 'SECOND';
|
||||
c._saveConfig();
|
||||
c._saveConfigDebounced.flush();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
out.stillOneInFlight = writes.length === 1; // вторая ждёт очереди, не летит параллельно
|
||||
|
||||
releaseFirst();
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
|
||||
out.writes = writes.length;
|
||||
out.revisions = writes.map((w) => w.expected); // вторая обязана взять новую ревизию
|
||||
out.secondCarriedTheEdit = writes[1]?.titles[0] === 'SECOND';
|
||||
out.editSurvived = c._serverCfg.spaces[0].title === 'SECOND';
|
||||
out.noConflictToast = !(c._toast || '').length;
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.46.0 и сверено с кодом
|
||||
checkAll(res, {
|
||||
oneInFlight: true,
|
||||
stillOneInFlight: true,
|
||||
writes: 2,
|
||||
revisions: [10, 11],
|
||||
secondCarriedTheEdit: true,
|
||||
editSurvived: true,
|
||||
noConflictToast: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,49 @@
|
||||
import { launch, check, 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;
|
||||
// 1) кнопка настроек комнаты в редакторе плана: заметная, фиксированного размера
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
const btn = sr().querySelector('.rlgearbtn');
|
||||
out.gearButtonShown = !!btn;
|
||||
const cs = btn ? getComputedStyle(btn) : null;
|
||||
out.gearReadable = cs ? parseFloat(cs.fontSize) >= 10 && cs.pointerEvents === 'auto' : null;
|
||||
out.gearHasLabel = btn ? btn.textContent.trim().length > 0 : null;
|
||||
const box = btn?.getBoundingClientRect();
|
||||
out.gearTapTarget = box ? box.height >= 18 && box.width >= 40 : null;
|
||||
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await c.updateComplete;
|
||||
out.gearOpensDialog = c._roomDialog === true && !!c._roomEditId;
|
||||
c._roomDialogCancel(); await c.updateComplete;
|
||||
// 2) комната без имени тоже получает кнопку (её там и называют)
|
||||
const room = c._curSpaceCfg.rooms[0];
|
||||
const savedName = room.name;
|
||||
room.name = '';
|
||||
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||
out.unnamedStillHasGear = sr().querySelectorAll('.rlgearbtn').length >= 1;
|
||||
room.name = savedName; c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||
// 3) метрики стали крупнее: 0.75em вместо 0.62em
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
|
||||
...s, settings: { ...(s.settings || {}), show_names: true, label_temp: true } })) };
|
||||
c._setMode('view'); c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
const lbl = [...sr().querySelectorAll('.roomlabel')].find((l) => l.querySelector('.rlmetrics'));
|
||||
if (lbl) {
|
||||
const nameSz = parseFloat(getComputedStyle(lbl.querySelector('.rlname')).fontSize);
|
||||
const metaSz = parseFloat(getComputedStyle(lbl.querySelector('.rlmetrics')).fontSize);
|
||||
out.metricsRatio = Math.round((metaSz / nameSz) * 100) / 100;
|
||||
} else out.metricsRatio = 'no-metrics';
|
||||
// 4) касание помечает сессию как тач и гасит тултип
|
||||
c._tip = { x: 1, y: 1, title: 't', meta: 'm' };
|
||||
c._notePointer(new PointerEvent('pointerdown', { pointerType: 'touch' }));
|
||||
out.touchClearsTip = c._tip === null;
|
||||
c._showTip(new MouseEvent('mousemove', { clientX: 5, clientY: 5 }), 'x', 'y');
|
||||
out.noTipAfterTouch = !c._tip;
|
||||
return out;
|
||||
});
|
||||
check('metricsRatio 0.75', res.metricsRatio, 0.75);
|
||||
delete res.metricsRatio;
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -93,6 +93,22 @@ const res = await page.evaluate(async () => {
|
||||
out.perSourceRadius = Math.abs(rOwn - c._cmToUnits(150)) < 0.5;
|
||||
c._serverCfg = { ...c._serverCfg, markers: (c._serverCfg.markers || []).filter((m) => m.id !== litMarkerId) };
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
// 6в) флаг «источник света»: умный выключатель с обычными светильниками
|
||||
const swDev = c._devices.find((d) => d.space === spId && d.entities.some((e) => e.startsWith('switch.')));
|
||||
if (swDev) {
|
||||
const swEid = swDev.entities.find((e) => e.startsWith('switch.'));
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, [swEid]: { ...c.hass.states[swEid], state: 'on' } } };
|
||||
const spotsBefore = sr().querySelectorAll('.glowlayer circle').length;
|
||||
c._serverCfg = { ...c._serverCfg, markers: [
|
||||
...(c._serverCfg.markers || []).filter((m) => m.id !== swDev.id),
|
||||
{ id: swDev.id, binding: swDev.bindingKind + ':' + swDev.bindingRef, is_light: true },
|
||||
] };
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||
out.switchGlows = sr().querySelectorAll('.glowlayer circle').length === spotsBefore + 1;
|
||||
c._serverCfg = { ...c._serverCfg, markers: (c._serverCfg.markers || []).filter((m) => m.id !== swDev.id) };
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||
out.switchGlowsOffByDefault = sr().querySelectorAll('.glowlayer circle').length === spotsBefore;
|
||||
} else { out.switchGlows = 'no-switch'; out.switchGlowsOffByDefault = 'no-switch'; }
|
||||
// 7) радиус из настроек: 600 см против 300 см — вдвое больше
|
||||
const r600 = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r'));
|
||||
c._serverCfg = { ...c._serverCfg, settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 300 } };
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// HP-1460-03: позиции — отдельное состояние. В v1.46.0 событие layout_updated
|
||||
// научилась слушать статическая карточка, а полная — нет, поэтому две полные
|
||||
// карточки рядом расходились до перезагрузки. Проверяем и обратное: приход
|
||||
// чужой ревизии не должен затирать перетаскивание, которое ещё не улетело.
|
||||
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;
|
||||
|
||||
// общий «сервер»: layout с ревизией и подписчики на событие
|
||||
let rev = 5;
|
||||
let layout = { dev_a: { x: 10, y: 10 }, dev_b: { x: 20, y: 20 } };
|
||||
const subs = [];
|
||||
let gets = 0;
|
||||
const hass = { ...c.hass,
|
||||
callWS: async (m) => {
|
||||
if (m.type === 'houseplan/layout/get') { gets++; return { layout: JSON.parse(JSON.stringify(layout)), rev }; }
|
||||
if (m.type === 'houseplan/layout/update') {
|
||||
layout = { ...layout, [m.device_id]: m.pos }; rev += 1;
|
||||
subs.forEach((f) => f({ data: { rev } }));
|
||||
return { ok: true, rev };
|
||||
}
|
||||
return base(m);
|
||||
},
|
||||
connection: { subscribeEvents: async (cb, ev) => {
|
||||
if (ev === 'houseplan_layout_updated') { subs.push(cb); return () => {}; }
|
||||
return () => {};
|
||||
} },
|
||||
};
|
||||
c.hass = hass;
|
||||
c._serverStorage = true;
|
||||
c._layout = JSON.parse(JSON.stringify(layout));
|
||||
c._layoutRev = rev;
|
||||
c._unsubLayout = await hass.connection.subscribeEvents(
|
||||
(e) => c._onLayoutEvent(Number(e?.data?.rev ?? -1)),
|
||||
'houseplan_layout_updated',
|
||||
);
|
||||
out.subscribed = subs.length === 1;
|
||||
|
||||
// 1) чужая карточка подвинула иконку — наша обязана подхватить без перезагрузки
|
||||
layout = { ...layout, dev_a: { x: 77, y: 88 } }; rev += 1;
|
||||
subs.forEach((f) => f({ data: { rev } }));
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
out.adoptedRemoteMove = JSON.stringify(c._layout.dev_a) === JSON.stringify({ x: 77, y: 88 });
|
||||
out.revFollowed = c._layoutRev === rev;
|
||||
|
||||
// 2) собственная запись не вызывает лишнего перечитывания
|
||||
const before = gets;
|
||||
c._layout = { ...c._layout, dev_b: { x: 31, y: 32 } };
|
||||
c._dirtyPos.add('dev_b');
|
||||
c._persistLayout();
|
||||
c._persistLayout.flush();
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
out.ownWriteNoReload = gets === before;
|
||||
out.ownWriteKept = JSON.stringify(c._layout.dev_b) === JSON.stringify({ x: 31, y: 32 });
|
||||
|
||||
// 3) НАСТОЯЩЕЕ перетаскивание: debounce запланирован, запись задержана, а
|
||||
// layout/get отвечает мгновенно. Именно этот порядок и терял позицию:
|
||||
// flush() внутри перечитывания опустошал _dirtyPos ДО снятия снимка.
|
||||
let releaseUpdate;
|
||||
const updateGate = new Promise((r) => { releaseUpdate = r; });
|
||||
let delayUpdate = true;
|
||||
const plain = hass.callWS;
|
||||
c.hass = { ...hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/layout/update' && delayUpdate) {
|
||||
delayUpdate = false;
|
||||
await updateGate;
|
||||
}
|
||||
return plain(m);
|
||||
} };
|
||||
|
||||
c._layout = { ...c._layout, dev_a: { x: 5, y: 6 } };
|
||||
c._dirtyPos.add('dev_a');
|
||||
c._persistLayout(); // debounce запланирован, не сброшен вручную
|
||||
layout = { ...layout, dev_b: { x: 99, y: 99 } }; rev += 1;
|
||||
subs.forEach((f) => f({ data: { rev } }));
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
out.dragKeptWhileWriteInFlight = JSON.stringify(c._layout.dev_a) === JSON.stringify({ x: 5, y: 6 });
|
||||
out.remoteChangeApplied = JSON.stringify(c._layout.dev_b) === JSON.stringify({ x: 99, y: 99 });
|
||||
|
||||
releaseUpdate();
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
out.localDragSurvived = JSON.stringify(c._layout.dev_a) === JSON.stringify({ x: 5, y: 6 });
|
||||
out.serverAgrees = JSON.stringify(layout.dev_a) === JSON.stringify({ x: 5, y: 6 });
|
||||
out.sentPosDrained = c._sentPos.size === 0;
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.46.1 и сверено с кодом
|
||||
checkAll(res, {
|
||||
subscribed: true,
|
||||
adoptedRemoteMove: true,
|
||||
revFollowed: true,
|
||||
ownWriteNoReload: true,
|
||||
ownWriteKept: true,
|
||||
dragKeptWhileWriteInFlight: true,
|
||||
remoteChangeApplied: true,
|
||||
localDragSurvived: true,
|
||||
serverAgrees: true,
|
||||
sentPosDrained: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -5,6 +5,7 @@ const res = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const calls = [];
|
||||
window.confirm = () => true; // review CR-1: unlocking now confirms
|
||||
c.hass = { ...c.hass, callService: (d, s, data) => calls.push([d, s, data.entity_id]) };
|
||||
await c.updateComplete;
|
||||
// добавить дверь с замком на f1
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// review CR-1: exercise EVERY actuation path and prove locks/alarms are safe
|
||||
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 calls = [];
|
||||
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push(`${d}.${s}:${data.entity_id}`); return Promise.resolve(); },
|
||||
states: { ...c.hass.states,
|
||||
'lock.front_door': { state: 'locked', attributes: { friendly_name: 'Front door' } },
|
||||
'alarm_control_panel.home': { state: 'armed_away', attributes: {} } } };
|
||||
await c.updateComplete;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
const lockCalls = () => calls.filter((x) => x.includes('lock.') || x.includes('alarm_control_panel.'));
|
||||
// 1) тап по значку устройства с замком
|
||||
const lockDev = c._devices.find((d) => d.entities?.some((e) => e.startsWith('lock.'))) || c._devices[0];
|
||||
const fake = { ...lockDev, primary: 'lock.front_door', tapAction: 'toggle',
|
||||
marker: { ...(lockDev.marker || {}), tap_action: 'toggle' } };
|
||||
c._clickDevice(new MouseEvent('click'), fake);
|
||||
out.iconTapSafe = lockCalls().length === 0;
|
||||
// 2) controls[] с замком внутри
|
||||
const withControls = { ...fake, tapAction: 'toggle',
|
||||
marker: { controls: ['lock.front_door', 'alarm_control_panel.home'], tap_action: 'toggle' } };
|
||||
c._clickDevice(new MouseEvent('click'), withControls);
|
||||
out.controlsSafe = lockCalls().length === 0;
|
||||
// 3) карточка устройства: замок отдаётся в more-info, а не тумблером
|
||||
const kinds = c._cardEntities({ ...fake, entities: ['lock.front_door', 'alarm_control_panel.home'] });
|
||||
out.cardNoToggleForLocks = kinds.every((k) => k.kind !== 'toggle');
|
||||
c._cardToggle('lock.front_door');
|
||||
c._cardToggle('alarm_control_panel.home');
|
||||
out.cardToggleRefuses = lockCalls().length === 0;
|
||||
// 4) кнопка в карточке двери — единственная разрешённая поверхность, и спрашивает подтверждение
|
||||
let asked = null;
|
||||
window.confirm = (msg) => { asked = msg; return false; };
|
||||
c._lockAction('lock.front_door', 'unlock');
|
||||
out.unlockAsksConfirm = asked !== null && lockCalls().length === 0;
|
||||
window.confirm = () => true;
|
||||
c._lockAction('lock.front_door', 'unlock');
|
||||
out.unlockAfterConfirm = calls.at(-1) === 'lock.unlock:lock.front_door';
|
||||
// запирание не спрашивает
|
||||
asked = null;
|
||||
window.confirm = (m) => { asked = m; return true; };
|
||||
c._lockAction('lock.front_door', 'lock');
|
||||
out.lockNoConfirm = asked === null && calls.at(-1) === 'lock.lock:lock.front_door';
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,71 @@
|
||||
// Граница транзакции загрузки подложки (ревью R2-1, уточнено в R3-1).
|
||||
// Файл плана пишется на диск ДО проверки ревизии конфига, поэтому отвергнутое
|
||||
// сохранение не имеет права трогать сохранённый план. Со стороны карточки
|
||||
// контракт теперь такой: она НЕ управляет удалением файлов вообще — уборку
|
||||
// делает сам config/set под блокировкой (клиент не может упорядочить свою
|
||||
// уборку относительно чужого коммита, R3-1). Здесь проверяем, что карточка
|
||||
// не отправляет никаких команд удаления и корректно ведёт себя при отказе.
|
||||
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 uploads = 0;
|
||||
const cleanups = [];
|
||||
let rejectSave = true;
|
||||
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/plan/set') {
|
||||
uploads++;
|
||||
return { ok: true, url: '/api/houseplan/content/plans/_/' + m.space_id + '.tok' + uploads + '.png' };
|
||||
}
|
||||
// любая команда удаления файлов от клиента — нарушение контракта R3-1
|
||||
if (m.type === 'houseplan/plan/cleanup' || m.type === 'houseplan/plan/delete') { cleanups.push(m); return { ok: true }; }
|
||||
if (m.type === 'houseplan/config/set') {
|
||||
if (rejectSave) { const e = new Error('conflict'); e.code = 'conflict'; throw e; }
|
||||
c.__sent = m.config; return { ok: true, rev: 77 };
|
||||
}
|
||||
if (m.type === 'houseplan/config/get') {
|
||||
const r = await base(m);
|
||||
return { ...r, config: JSON.parse(JSON.stringify(r.config)) };
|
||||
}
|
||||
return base(m);
|
||||
} };
|
||||
|
||||
const attach = async () => {
|
||||
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;
|
||||
};
|
||||
|
||||
// 1) конфиг отвергнут → файл загружен, но чистить старый план нельзя
|
||||
await attach();
|
||||
out.uploadedOnReject = uploads === 1;
|
||||
out.cleanupsAfterReject = cleanups.length;
|
||||
out.dialogStaysOpenOnReject = c._spaceDialog !== null;
|
||||
|
||||
// 2) конфиг принят → чистка уходит, и ровно на тот файл, что записан в конфиг
|
||||
rejectSave = false;
|
||||
c._spaceDialog = null; await c.updateComplete;
|
||||
await attach();
|
||||
out.cleanupsAfterAccept = cleanups.length;
|
||||
const f1 = (c.__sent?.spaces || []).find((s) => s.id === 'f1');
|
||||
out.savedPlanUrl = f1?.plan_url;
|
||||
out.dialogClosedOnAccept = c._spaceDialog === null;
|
||||
// вторая загрузка не переиспользует имя первой: старый файл жив до коммита
|
||||
out.versionedNames = uploads === 2;
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.45.0 и сверено с кодом
|
||||
checkAll(res, {
|
||||
uploadedOnReject: true,
|
||||
cleanupsAfterReject: 0,
|
||||
dialogStaysOpenOnReject: true,
|
||||
cleanupsAfterAccept: 0,
|
||||
savedPlanUrl: '/api/houseplan/content/plans/_/f1.tok2.png',
|
||||
dialogClosedOnAccept: true,
|
||||
versionedNames: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,59 @@
|
||||
// HP-1454-07: статическая карточка строит модель другой функцией, и room.settings
|
||||
// в неё не переносились — переопределение заливки на уровне комнаты она
|
||||
// игнорировала и красила комнату, которую полная карточка оставляет прозрачной.
|
||||
// Плюс HP-1454-08: layout-события должны доходить до статической карточки без
|
||||
// перезагрузки страницы.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const main = window.__card;
|
||||
|
||||
// заливка по свету на пространстве, у первой комнаты — переопределение "none"
|
||||
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
|
||||
const f1 = cfg.spaces.find((s) => s.id === 'f1');
|
||||
f1.settings = { ...(f1.settings || {}), show_borders: true, show_names: true, fill_mode: 'light' };
|
||||
f1.rooms[0].settings = { fill_mode: 'none' };
|
||||
const hass = { ...main.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {}, rev: 1 };
|
||||
return { ok: true };
|
||||
} };
|
||||
|
||||
main._serverCfg = cfg;
|
||||
main._cfgEpoch++;
|
||||
main.requestUpdate(); await main.updateComplete;
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
card.hass = hass;
|
||||
host.appendChild(card);
|
||||
const t0 = Date.now();
|
||||
while (!card.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
await card.updateComplete;
|
||||
|
||||
const overridden = (root) => {
|
||||
const rooms = [...root.querySelectorAll('.room')];
|
||||
return rooms.length ? ((rooms[0].getAttribute('style') || '').match(/--room-fill:([^;]+)/) || [])[1] || null : 'missing';
|
||||
};
|
||||
|
||||
out.fullCardRoom0 = overridden(main.shadowRoot || main.renderRoot);
|
||||
out.staticCardRoom0 = overridden(card.renderRoot);
|
||||
out.parity = out.fullCardRoom0 === out.staticCardRoom0;
|
||||
out.overrideRespected = out.staticCardRoom0 === 'transparent';
|
||||
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.46.0 и сверено с кодом
|
||||
checkAll(res, {
|
||||
fullCardRoom0: 'transparent',
|
||||
staticCardRoom0: 'transparent',
|
||||
parity: true,
|
||||
overrideRespected: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -10,7 +10,7 @@ const res = await page.evaluate(async () => {
|
||||
...s, settings: { ...(s.settings || {}), show_names: true, fill_mode: 'temp', label_temp: true } })) };
|
||||
c._setMode('plan'); c.requestUpdate(); await c.updateComplete;
|
||||
// 1) шестерёнка на карточке комнаты в редакторе плана
|
||||
const gear = sr().querySelector('.rlgear');
|
||||
const gear = sr().querySelector('.rlgearbtn');
|
||||
out.gearShown = !!gear;
|
||||
gear.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await c.updateComplete;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// «Уже загруженные»: план, который не удаляется за ненадобностью, обязан быть
|
||||
// находимым. Иначе обещание «отцепил — файл остался» неполноценно: вернуть его
|
||||
// из карточки было нельзя, старый URL нигде не хранится (HP-1466-02).
|
||||
// Заодно это единственный способ удалить план — явным действием.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 1000 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const base = c.hass.callWS;
|
||||
|
||||
let serverPlans = [
|
||||
{ name: 'f1.aaa.png', url: '/api/houseplan/content/plans/_/f1.aaa.png', size: 121335, modified: 2, used_by: [] },
|
||||
{ name: 'f2.bbb.png', url: '/api/houseplan/content/plans/_/f2.bbb.png', size: 26931, modified: 1, used_by: ['2 этаж'] },
|
||||
];
|
||||
const deleted = [];
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/plans/list') return { plans: serverPlans };
|
||||
if (m.type === 'houseplan/plans/delete') {
|
||||
const p = serverPlans.find((x) => x.name === m.name);
|
||||
if (p?.used_by.length) { const e = new Error('in_use'); e.code = 'in_use'; throw e; }
|
||||
deleted.push(m.name);
|
||||
serverPlans = serverPlans.filter((x) => x.name !== m.name);
|
||||
return { ok: true, removed: true };
|
||||
}
|
||||
if (m.type === 'houseplan/content/sign') {
|
||||
const urls = {}; for (const p of m.paths) urls[p] = p + '?authSig=X'; return { urls };
|
||||
}
|
||||
return base(m);
|
||||
} };
|
||||
window.confirm = () => true;
|
||||
|
||||
// пространство без плана — как после отцепления
|
||||
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, source: 'file', planUrl: null, planFile: null };
|
||||
await c.updateComplete;
|
||||
out.saveBlockedWithoutPlan = !!sr().querySelector('.dialog .btn.on[disabled]');
|
||||
|
||||
// открываем список сохранённых
|
||||
await c._toggleServerPlans();
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
await c.updateComplete;
|
||||
const rows = [...sr().querySelectorAll('.savedplan')];
|
||||
out.listed = rows.length;
|
||||
out.showsUsage = (rows[1]?.textContent || '').includes('2 этаж');
|
||||
out.deleteDisabledForUsed = !!rows[1]?.querySelector('.btn.danger[disabled]');
|
||||
out.deleteEnabledForFree = !rows[0]?.querySelector('.btn.danger[disabled]');
|
||||
out.thumbnailSigned = (rows[0]?.querySelector('img')?.getAttribute('src') || '').includes('authSig=');
|
||||
|
||||
// выбираем свободный план — он подставляется в диалог
|
||||
c._useServerPlan(serverPlans[0].url);
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
await c.updateComplete;
|
||||
out.picked = c._spaceDialog.planUrl === '/api/houseplan/content/plans/_/f1.aaa.png';
|
||||
out.listClosed = !c._spaceDialog.pickSaved;
|
||||
out.saveEnabledAfterPick = !sr().querySelector('.dialog .btn.on[disabled]');
|
||||
|
||||
// удаление: занятый нельзя, свободный можно
|
||||
await c._toggleServerPlans();
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
await c._deleteServerPlan('f2.bbb.png').catch(() => {});
|
||||
out.usedNotDeleted = !deleted.includes('f2.bbb.png');
|
||||
await c._deleteServerPlan('f1.aaa.png');
|
||||
await c.updateComplete;
|
||||
out.freeDeleted = deleted.includes('f1.aaa.png');
|
||||
out.rowGone = !(c._spaceDialog.saved || []).some((p) => p.name === 'f1.aaa.png');
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.47.0 и сверено с кодом
|
||||
checkAll(res, {
|
||||
saveBlockedWithoutPlan: true,
|
||||
listed: 2,
|
||||
showsUsage: true,
|
||||
deleteDisabledForUsed: true,
|
||||
deleteEnabledForFree: true,
|
||||
thumbnailSigned: true,
|
||||
picked: true,
|
||||
listClosed: true,
|
||||
saveEnabledAfterPick: true,
|
||||
usedNotDeleted: true,
|
||||
freeDeleted: true,
|
||||
rowGone: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,78 @@
|
||||
// Ревью R2-2: бэкенд подписывает не более MAX_SIGN_PATHS путей за вызов и
|
||||
// молча отбрасывает остальные. Карточка обязана бить запрос на батчи, помнить
|
||||
// возраст подписи и чистить кэш от ссылок, которых в конфиге больше нет —
|
||||
// иначе на настенном планшете «лишние» записи протухают навсегда.
|
||||
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;
|
||||
const batchSizes = [];
|
||||
let round = 0;
|
||||
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/content/sign') {
|
||||
batchSizes.push(m.paths.length);
|
||||
const urls = {};
|
||||
// как настоящий бэкенд: не больше 200 за раз, про остальные — молчание
|
||||
for (const p of m.paths.slice(0, 200)) urls[p] = p.split('?')[0] + '?authSig=R' + round;
|
||||
return { urls };
|
||||
}
|
||||
return base(m);
|
||||
} };
|
||||
|
||||
// 201 вложение, разложенное по маркерам: столько же подписанных ссылок
|
||||
const pdfs = [];
|
||||
for (let i = 0; i < 201; i++) pdfs.push({ name: 'm' + i, url: '/api/houseplan/content/files/m/doc' + i + '.pdf' });
|
||||
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'mk1', pdfs }] };
|
||||
c._cfgEpoch++;
|
||||
|
||||
round = 1;
|
||||
for (const p of pdfs) c._display(p.url);
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
out.firstBatches = [...batchSizes];
|
||||
out.signedAfterFirst = Object.keys(c._signer.entries).length;
|
||||
|
||||
// переподписывание: все 201, снова батчами, ни одна запись не остаётся старой
|
||||
batchSizes.length = 0;
|
||||
round = 2;
|
||||
c._resign();
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
out.resignBatches = [...batchSizes];
|
||||
const vals = Object.values(c._signer.entries).map((v) => v.url);
|
||||
out.allRefreshed = vals.length === 201 && vals.every((u) => u.endsWith('authSig=R2'));
|
||||
|
||||
// ссылка, исчезнувшая из конфига, выбывает из кэша и не занимает слот
|
||||
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'mk1', pdfs: pdfs.slice(0, 5) }] };
|
||||
c._cfgEpoch++;
|
||||
batchSizes.length = 0;
|
||||
round = 3;
|
||||
c._resign();
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
out.prunedTo = Object.keys(c._signer.entries).length;
|
||||
out.pruneBatches = [...batchSizes];
|
||||
|
||||
// протухшая подпись не отдаётся: она вернула бы 401 и «попытку входа»
|
||||
const one = pdfs[0].url;
|
||||
c._signer.entries[one] = { url: one + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 };
|
||||
out.expiredNotServed = c._display(one) === '';
|
||||
out.expiredDropped = c._signer.entries[one] === undefined;
|
||||
// а стареющая, но ещё живая — отдаётся, пока едет замена
|
||||
c._signer.entries[one] = { url: one + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000 };
|
||||
out.agingStillServed = c._display(one) === one + '?authSig=AGING';
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.45.0 и сверено с кодом
|
||||
checkAll(res, {
|
||||
firstBatches: [200, 1],
|
||||
signedAfterFirst: 201,
|
||||
resignBatches: [200, 1],
|
||||
allRefreshed: true,
|
||||
prunedTo: 5,
|
||||
pruneBatches: [5],
|
||||
expiredNotServed: true,
|
||||
expiredDropped: true,
|
||||
agingStillServed: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,99 @@
|
||||
// Ревью R3-2: houseplan-space-card подписывала URL подложки и выбрасывала
|
||||
// результат — getCardSize() правил временную модель, а render() строил свою
|
||||
// заново из конфига, поэтому <image> запрашивал сырой requires_auth-путь и на
|
||||
// каждом рендере получал 401. Проверяем весь контракт подписи для этой карточки.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const main = window.__card;
|
||||
const raw = '/api/houseplan/content/plans/_/f1.tok.svg';
|
||||
|
||||
// подложка на защищённом эндпоинте + управляемый ответ на подпись
|
||||
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
|
||||
cfg.spaces = cfg.spaces.map((s) => (s.id === 'f1' ? { ...s, plan_url: raw } : s));
|
||||
let signCalls = 0;
|
||||
let failFirst = true;
|
||||
const requestedHrefs = [];
|
||||
const hass = { ...main.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {} };
|
||||
if (m.type === 'houseplan/content/sign') {
|
||||
signCalls++;
|
||||
if (failFirst && signCalls === 1) throw new Error('ws down');
|
||||
const urls = {};
|
||||
for (const p of m.paths) urls[p] = p + '?authSig=SIG' + signCalls;
|
||||
return { urls };
|
||||
}
|
||||
return { ok: true };
|
||||
} };
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
card.hass = hass;
|
||||
host.appendChild(card);
|
||||
|
||||
const stage = async () => {
|
||||
const t0 = Date.now();
|
||||
while (!card.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
await card.updateComplete;
|
||||
return card.renderRoot.querySelector('.hp-static-stage svg image');
|
||||
};
|
||||
const href = async () => { const im = await stage(); return im ? im.getAttribute('href') : null; };
|
||||
|
||||
// 1) первая подпись упала → сырой URL в DOM не попадает (иначе 401)
|
||||
await stage();
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
out.hrefAfterFailedSign = await href();
|
||||
|
||||
// 2) сразу повтора нет: после ошибки подпись уходит в backoff (ревью R4-2),
|
||||
// иначе нестабильный сокет получал бы по запросу на каждый рендер
|
||||
for (let i = 0; i < 5; i++) { card.requestUpdate(); await card.updateComplete; }
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
out.noRetryStorm = signCalls === 1;
|
||||
|
||||
// 3) после выдержки повтор проходит
|
||||
await new Promise((r) => setTimeout(r, 2100));
|
||||
card.requestUpdate(); await card.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
out.hrefAfterRetry = await href();
|
||||
out.retried = signCalls === 2;
|
||||
|
||||
// 4) повторный рендер не теряет подпись и не просит её заново
|
||||
const before = signCalls;
|
||||
card.requestUpdate(); await card.updateComplete;
|
||||
out.hrefStable = await href();
|
||||
out.noExtraSignOnRerender = signCalls === before;
|
||||
|
||||
// 5) протухшая подпись не отдаётся, стареющая — отдаётся, пока едет замена
|
||||
const ent = card._signer.entries;
|
||||
ent[raw] = { url: raw + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 };
|
||||
card.requestUpdate(); await card.updateComplete;
|
||||
out.hrefWhenExpired = await href();
|
||||
ent[raw] = { url: raw + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000 };
|
||||
card.requestUpdate(); await card.updateComplete;
|
||||
out.hrefWhenAging = await href();
|
||||
|
||||
// ни один сырой (неподписанный) путь не должен уходить в сеть
|
||||
for (const im of card.renderRoot.querySelectorAll('image')) requestedHrefs.push(im.getAttribute('href'));
|
||||
out.noRawHrefEver = !requestedHrefs.includes(raw);
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.45.1 и сверено с кодом
|
||||
checkAll(res, {
|
||||
hrefAfterFailedSign: null,
|
||||
noRetryStorm: true,
|
||||
hrefAfterRetry: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
|
||||
retried: true,
|
||||
hrefStable: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
|
||||
noExtraSignOnRerender: true,
|
||||
hrefWhenExpired: null,
|
||||
hrefWhenAging: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=AGING',
|
||||
noRawHrefEver: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,78 @@
|
||||
// HP-1454-01: загруженный SVG — пользовательский контент, который Home Assistant
|
||||
// отдаёт со своего origin. Внутри карточки он подключён через <image>, где
|
||||
// скрипты не выполняются, но тот же URL, открытый как отдельный документ,
|
||||
// становится живым документом этого origin: <script> в нём получает доступ к
|
||||
// localStorage сессии и к API. Проверяем, что заголовок sandbox это снимает,
|
||||
// и что обычный SVG при этом продолжает отображаться.
|
||||
import { chromium } from 'playwright';
|
||||
import { check, finish } from './serve.mjs';
|
||||
|
||||
const EVIL = `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
|
||||
<rect width="100" height="100" fill="#eee"/>
|
||||
<script>
|
||||
document.title = 'HOUSEPLAN_XSS_EXECUTED';
|
||||
try { localStorage.setItem('hp_xss', 'executed'); } catch (e) {}
|
||||
</script>
|
||||
</svg>`;
|
||||
|
||||
// ровно тот набор, который отдаёт HouseplanContentView для .svg
|
||||
const CSP = "sandbox; default-src 'none'; script-src 'none'; object-src 'none'; "
|
||||
+ "base-uri 'none'; form-action 'none'; style-src 'unsafe-inline'; img-src data:";
|
||||
|
||||
const browser = await chromium.launch({ args: ['--no-sandbox'] });
|
||||
const ctx = await browser.newContext();
|
||||
|
||||
async function serve(page, { csp }) {
|
||||
await page.route('**/*', (route) => {
|
||||
const url = route.request().url();
|
||||
if (url.endsWith('/evil.svg')) {
|
||||
const headers = { 'Content-Type': 'image/svg+xml', 'X-Content-Type-Options': 'nosniff' };
|
||||
if (csp) headers['Content-Security-Policy'] = CSP;
|
||||
return route.fulfill({ status: 200, headers, body: EVIL });
|
||||
}
|
||||
return route.fulfill({ status: 200, contentType: 'text/html', body: '<html><body>host</body></html>' });
|
||||
});
|
||||
}
|
||||
|
||||
// 1) как было до фикса: скрипт исполняется в origin Home Assistant
|
||||
const before = await ctx.newPage();
|
||||
await serve(before, { csp: false });
|
||||
await before.goto('https://ha.example/api/houseplan/content/plans/_/evil.svg');
|
||||
await before.waitForTimeout(200);
|
||||
const noCsp = await before.evaluate(() => ({
|
||||
title: document.title,
|
||||
storage: (() => { try { return localStorage.getItem('hp_xss'); } catch (e) { return 'blocked'; } })(),
|
||||
}));
|
||||
|
||||
// 2) с заголовком: opaque origin, скрипт не выполняется, storage недоступен
|
||||
const after = await ctx.newPage();
|
||||
await serve(after, { csp: true });
|
||||
await after.goto('https://ha.example/api/houseplan/content/plans/_/evil.svg');
|
||||
await after.waitForTimeout(200);
|
||||
const withCsp = await after.evaluate(() => ({
|
||||
title: document.title,
|
||||
storage: (() => { try { return localStorage.getItem('hp_xss'); } catch (e) { return 'blocked'; } })(),
|
||||
}));
|
||||
|
||||
// 3) тот же файл как <image> внутри страницы — рисуется и без скрипта
|
||||
const card = await ctx.newPage();
|
||||
await serve(card, { csp: true });
|
||||
await card.goto('https://ha.example/');
|
||||
const drawn = await card.evaluate(async () => {
|
||||
const img = new Image();
|
||||
const ok = await new Promise((res) => {
|
||||
img.onload = () => res(true);
|
||||
img.onerror = () => res(false);
|
||||
img.src = '/api/houseplan/content/plans/_/evil.svg';
|
||||
});
|
||||
return { loaded: ok, width: img.naturalWidth, title: document.title };
|
||||
});
|
||||
|
||||
check('без CSP скрипт выполняется (иначе тест ничего не доказывает)', noCsp.title, 'HOUSEPLAN_XSS_EXECUTED');
|
||||
check('без CSP скрипт пишет в storage origin', noCsp.storage, 'executed');
|
||||
check('с CSP скрипт не выполняется', withCsp.title !== 'HOUSEPLAN_XSS_EXECUTED', true);
|
||||
check('с CSP storage origin недоступен', withCsp.storage !== 'executed', true);
|
||||
check('SVG по-прежнему грузится как картинка', drawn.loaded, true);
|
||||
check('и имеет размеры', drawn.width, 100);
|
||||
check('картинка ничего не выполнила на странице-хосте', drawn.title, '');
|
||||
await finish(browser);
|
||||
@@ -1,3 +1,5 @@
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 640, height: 980 }, 2);
|
||||
const res = await page.evaluate(async () => {
|
||||
@@ -33,7 +35,10 @@ const res = await page.evaluate(async () => {
|
||||
out.nanGuard = !Number.isFinite(n) && c._spaceDialog.tempMax === before;
|
||||
return out;
|
||||
});
|
||||
await page.screenshot({ path: '/tmp/ux_dialog.png' });
|
||||
// артефакт для глазами: путь берём у ОС, а не хардкодим unix-овый — на Windows
|
||||
// '/tmp/...' указывает в несуществующий C:\tmp и смоук падал, не дойдя до
|
||||
// ассертов (портируемость, ревью 2026-07-27)
|
||||
await page.screenshot({ path: join(tmpdir(), 'houseplan_ux_dialog.png') }).catch(() => {});
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"filledClass": 1,
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+178
-50
File diff suppressed because one or more lines are too long
+107
-4
@@ -172,14 +172,117 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
|
||||
|
||||
| Command | Parameters | Response |
|
||||
|---|---|---|
|
||||
| `houseplan/layout/get` | — | `{layout: {device_id: {x,y}}}` |
|
||||
| `houseplan/layout/set` | `layout` | `{ok}` (admin_only optional) |
|
||||
| `houseplan/layout/update` | `device_id`, `pos` | `{ok}` |
|
||||
| `houseplan/layout/get` | — | `{layout: {device_id: {x,y}}, rev}` |
|
||||
| `houseplan/layout/set` | `layout`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_layout_updated` |
|
||||
| `houseplan/layout/update` | `device_id`, `pos` | `{ok, rev}`; event `houseplan_layout_updated` |
|
||||
| `houseplan/config/get` | — | `{config, rev}` |
|
||||
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
|
||||
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` |
|
||||
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` — writes `<space>.<token>.<ext>`, deletes nothing |
|
||||
| `houseplan/plans/list` | — | `{plans: [{name, url, size, modified, used_by}]}` |
|
||||
| `houseplan/plans/delete` | `name` | `{ok, removed}` / err `in_use` |
|
||||
| `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) |
|
||||
|
||||
**User content is served inert** (HP-1454-01). An uploaded SVG is the only
|
||||
thing here that a browser will happily treat as a *document* rather than an
|
||||
image, and it would be a document of Home Assistant's own origin. Inside the
|
||||
card that never matters — `<image>` does not run scripts — but the url is
|
||||
reachable directly, and uploading needs only write access, which by default
|
||||
every user has. `HouseplanContentView` therefore sends a `sandbox` CSP with SVG
|
||||
and only with SVG: a CSP on a PDF response can break the browser's built-in
|
||||
viewer, and a raster image has no execution model to disable.
|
||||
|
||||
**Attachments follow the same commit-scoped lifecycle as plans** (HP-1454-02).
|
||||
An upload takes a free name and never overwrites, because the bytes under an
|
||||
existing name may be referenced by the stored configuration and an upload is
|
||||
not part of that transaction. `reserve_filename` *claims* the name as it picks
|
||||
it (`O_CREAT | O_EXCL`) — asking `exists()` and returning a string let two
|
||||
uploads agree on one name and quietly overwrite each other. It also budgets the
|
||||
length so the result survives the sanitiser the content view applies to the
|
||||
request, since a name the view rewrites is a file written and never served.
|
||||
Streaming temporaries live in the files root under `.upload-`, are removed on
|
||||
every exit path of the request (including cancellation, which is a
|
||||
BaseException and slips past `except Exception`), and are swept at startup and
|
||||
daily. That scheduled pass also runs the two collectors with the stored
|
||||
configuration as *both* sides — nothing superseded, so every referenced file is
|
||||
kept and only aged unreferenced ones go. Without it, collection would only ever
|
||||
happen when somebody saves, and a file uploaded into a dialog that was then
|
||||
cancelled would wait for a write that may never come. A new icon has no id yet, so its
|
||||
files go to a per-dialog staging folder and move to the real id once the config
|
||||
write is accepted — the same copy → save → cleanup order as a rebind.
|
||||
`config/set` collects what its commit superseded — that much a commit knows for
|
||||
certain. *Unreferenced* is a far weaker signal, and the policy follows from one
|
||||
asymmetry: **a few unnecessary megabytes can always be removed by hand; a file
|
||||
we should not have removed cannot be brought back.** When the evidence is weak,
|
||||
keep the file. Owner's decision, 2026-07-28, after the one-hour rule applied to
|
||||
every unreferenced file destroyed two detached plans.
|
||||
|
||||
The classification is by **owner**, not by "is it referenced". A file leaving
|
||||
the configuration looks identical whether the plan was replaced, detached, or
|
||||
its space deleted — and only the first is a deletion the user asked for. Reading
|
||||
`old_refs - new_refs` and calling it "superseded" deleted a plan the moment it
|
||||
was detached, under documentation promising the opposite (HP-1465-01).
|
||||
|
||||
| Case | What it means | Rule |
|
||||
|---|---|---|
|
||||
| Space in both, plan A → plan B | the user picked another image | removed immediately |
|
||||
| Space in both, plan → none | detached; one click undoes it | **kept** |
|
||||
| Space gone | deliberate, but the image was imported and may be nowhere else | **kept** |
|
||||
| Space has a plan, plus another file of its own | an upload whose save was rejected | **kept** — ageing these out raced the retry that referenced them |
|
||||
| Marker in both, attachment dropped from its list | a trash button, promising nothing | removed immediately |
|
||||
| Marker gone | same call as a deleted space's plan | **kept** |
|
||||
| Attachment in `up_*` | a dialog that was never saved; no device owns it | `PLAN_ORPHAN_TTL_S` (1 h) |
|
||||
| Marker there, file it never listed | a rejected upload | **kept**, same reason |
|
||||
|
||||
Nothing is deleted for being old, with one exception: a per-dialog staging
|
||||
folder (`up_*`), which by construction can only hold an upload from a dialog
|
||||
that was never saved. The disk therefore stays bounded by the user, not by a
|
||||
timer — `houseplan/plans/list` shows every stored plan with its size and which
|
||||
space uses it, and `houseplan/plans/delete` removes one on request, refusing
|
||||
while a space still references it. That listing is what makes "we never delete"
|
||||
livable: a detached plan is not lost, it is one click away in the space dialog.
|
||||
|
||||
**Config writes are serialized** (HP-1454-03). `_writeConfig()` chains onto a
|
||||
single promise: one `config/set` in flight, each carrying the revision the
|
||||
previous one returned. The debounce still spaces out *when* a write starts;
|
||||
what it cannot do — and used to be relied on for — is keep two writes from
|
||||
overlapping, which produced a self-inflicted conflict and lost the newer edit.
|
||||
|
||||
**Plan uploads are copy-on-write, and collection belongs to the commit**
|
||||
(reviews R2-1, R3-1). The file system is not part of the config's
|
||||
optimistic-locking transaction, so nothing referenced may be overwritten or
|
||||
deleted before the CAS succeeds: the upload writes a new versioned name and
|
||||
removes nothing. Deciding what may then go is *not* a client's call — a cleanup
|
||||
request cannot be ordered against another client's commit, and a delayed one
|
||||
deletes a plan that was just saved. So `config/set` collects itself, inside its
|
||||
write lock, from the pair of configurations that bracket the commit
|
||||
(`plans.collect_plans`): superseded files go immediately, other unreferenced
|
||||
uploads only once `PLAN_ORPHAN_TTL_S` has passed, since a fresh one may belong
|
||||
to a transaction still in flight. The `.` between id and token is load-bearing —
|
||||
a space id cannot contain one, so `<space>.<token>.<ext>` can never be confused
|
||||
with the files of a space whose name merely starts the same way.
|
||||
|
||||
**Signed content urls are batched, aged and deduplicated** (reviews R2-2, R3-2, R4-2). `ContentSigner`
|
||||
in `src/signing.ts` is the single implementation, used by both cards; the
|
||||
duplicate inside houseplan-space-card signed correctly and never handed the
|
||||
result to its renderer, which is the failure mode a second copy invites. `MAX_SIGN_PATHS`
|
||||
(200) is a shared contract between `logic.ts` and `const.py`: the backend caps a
|
||||
request there and says nothing about the rest, so the card must chunk. Cached
|
||||
signatures carry the time they were issued — an aging one keeps rendering while
|
||||
its replacement is fetched, an expired one is dropped rather than served (it
|
||||
would 401 and raise a failed-login warning). The cache is pruned to the urls the
|
||||
live config references, so it cannot grow past the cap through history alone.
|
||||
Queued and in-flight are distinct states: a render happening while a request is
|
||||
out must not queue the same url again, a failure backs off rather than retrying
|
||||
on the next frame, and an in-flight entry expires after `SIGN_INFLIGHT_MS` so a
|
||||
promise that never settles cannot block retries forever.
|
||||
|
||||
**Room climate is one pass per hass snapshot** (review R2-3). `areaClimateMap()`
|
||||
classifies the whole registry once and returns `Map<area, {temp, hum}>`; the
|
||||
card memoizes it on `hass` identity, which Home Assistant replaces on every
|
||||
state change. Per-room lookups are O(1). `areaClimate()` survives as a
|
||||
single-area wrapper for tests — using it in a render reintroduces the
|
||||
O(rooms × entities) cost it was extracted from.
|
||||
|
||||
**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/`.
|
||||
|
||||
|
||||
@@ -1,5 +1,496 @@
|
||||
# Changelog
|
||||
|
||||
## v1.47.0 — 2026-07-28 (pick a plan you already uploaded)
|
||||
- **The space dialog can now show the plans stored on the server.** Detaching a
|
||||
plan keeps the image on disk — that has been the rule since v1.46.4, but until
|
||||
now the only way back was to find the original file on your computer and
|
||||
upload it again. "Already uploaded" lists what is there, with a thumbnail, the
|
||||
file size and which space uses it. One click attaches it; the aspect ratio is
|
||||
read from the image, exactly as on upload.
|
||||
- **And it is where you delete one.** A plan file is never removed automatically
|
||||
— not for being detached, not for being old — which is only a sensible policy
|
||||
if you can see what is being kept and get rid of it deliberately. The trash
|
||||
button does that, and refuses while a space still uses the plan: the answer to
|
||||
"may this go" comes from the stored configuration, not from the browser.
|
||||
- Documentation caught up with the code: several comments still described the
|
||||
age-based collection that v1.46.6 removed.
|
||||
|
||||
## v1.46.6 — 2026-07-28 (the detach promise, actually kept this time)
|
||||
- **Switching a space to "draw" no longer deletes its image.** v1.46.4 and
|
||||
v1.46.5 said it did not, and the scheduled cleanup indeed left detached plans
|
||||
alone — but the save itself deleted the file the moment the reference was
|
||||
cleared, before any of those guards were reached. The cause: a file that left
|
||||
the configuration was called "superseded", and from that difference alone
|
||||
replacing a plan, detaching one and deleting its space are indistinguishable.
|
||||
Only the first is a deletion anybody asked for. The transition is now
|
||||
classified by the space that owned the file, and the same distinction applies
|
||||
to attachments: dropping one from a device that still exists removes it,
|
||||
deleting the device keeps its manuals.
|
||||
- **A plan whose space was deleted is kept**, rather than the thirty days
|
||||
v1.46.5 promised — thirty days measured from the file's age is meaningless
|
||||
anyway, since it was usually uploaded months earlier.
|
||||
- **Nothing is deleted for being old any more**, except a per-dialog staging
|
||||
folder. The rule that aged out "rejected uploads" turned out to race a retry:
|
||||
the cleanup removed the file from a failed save while the next attempt was
|
||||
committing a reference to it. A rule that can delete a file somebody is about
|
||||
to point at is not worth the disk it reclaims. Files therefore go when an
|
||||
action says so, and otherwise stay.
|
||||
|
||||
## v1.46.5 — 2026-07-28 (audit of every automatic deletion)
|
||||
- **A detached plan is never deleted, at any age.** v1.46.4 gave it a month;
|
||||
this makes it permanent and writes the reason down where the next change will
|
||||
see it. The rule, now in docs/SCOPE.md: the component may delete a file only
|
||||
when a user action says so — replacing a plan, removing an attachment,
|
||||
deleting a device. "Nothing points at this any more" is not such an action.
|
||||
The errors are not symmetrical: wasted disk is visible, cheap and reversible;
|
||||
a deleted file is none of those.
|
||||
- **`houseplan/files/cleanup` no longer takes a folder on the client's word.**
|
||||
After a device is rebound its files are copied to the new id and the old
|
||||
folder is dropped — with `rmtree`, on whatever id the card sent. Two ways that
|
||||
ends badly: a partial copy leaves some urls still pointing into that folder
|
||||
(the migration deliberately does not rewrite those, so they were live links to
|
||||
files being deleted), and a wrong or stale id from any client would destroy a
|
||||
live device's manuals. The server now checks the stored configuration itself,
|
||||
under the config lock, and removes only files nothing references.
|
||||
- **A plan of a space that was deleted waits thirty days instead of an hour.**
|
||||
Deleting a space is deliberate, but an hour is a short window in which to
|
||||
notice it was a misclick.
|
||||
|
||||
## v1.46.4 — 2026-07-28 (data loss: detached plans were collected as garbage)
|
||||
- **A plan you detach is no longer deleted an hour later.** Switching a space to
|
||||
"draw" clears the reference and, as the editor has always said, leaves the
|
||||
image on disk so you can put it back. The collection added in v1.46.0 did not
|
||||
make that distinction: it treated "nothing points at this right now" as
|
||||
abandoned and applied a one-hour rule. On the author's own instance the
|
||||
scheduled pass then removed two floor plans that had been detached weeks
|
||||
earlier, with no way to get them back. If you have detached a plan since
|
||||
v1.46.0 and your instance restarted or ran for a day, check
|
||||
`config/houseplan/plans/` before updating anything else — and please report it
|
||||
in the Telegram chat if a file is missing.
|
||||
The rule now: **a commit still removes exactly what it replaced**, because
|
||||
that it knows for certain. Beyond that the question is whether "unreferenced"
|
||||
means "abandoned", and the answer depends on the case. A space with no plan at
|
||||
all has had one detached and may want it back — its files are never collected.
|
||||
A space that does have a plan can only be holding rejected uploads of its own,
|
||||
so those still go after an hour. Attachments outside a per-dialog staging
|
||||
folder wait a month; a staging folder, which by construction only ever holds
|
||||
an upload from a dialog that was never saved, keeps the one-hour rule.
|
||||
|
||||
## v1.46.3 — 2026-07-28 (re-check of v1.46.2: HP-1462-01)
|
||||
- **The cleanup at startup now actually cleans up.** It looked its own runtime
|
||||
data up by domain, and during startup Home Assistant does not yet consider
|
||||
the integration loaded — so the lookup came back empty and the pass quietly
|
||||
degraded to removing half-finished transfers, leaving the real work to a timer
|
||||
24 hours away. Restart more often than that and it never ran at all. It uses
|
||||
the object it was given at startup now.
|
||||
- **The test that was supposed to prove this was passing for the wrong
|
||||
reason.** It created the stray files *before* saving the configuration — and
|
||||
saving collects too, so everything was already gone by the time the restart
|
||||
happened. Rewritten to seed after the save, plus a second test that fires the
|
||||
scheduled timer on its own, and a third that runs a restart and a save at the
|
||||
same time and asserts the accepted configuration never points at a file the
|
||||
cleanup removed.
|
||||
|
||||
## v1.46.2 — 2026-07-28 (re-check of v1.46.1: HP-1461-01, -02)
|
||||
- **A file nobody ended up using is now cleaned up even if nothing is ever
|
||||
saved again (HP-1461-01).** Collection is tied to a configuration write,
|
||||
which is right for what a write supersedes but leaves a gap: cancel a dialog
|
||||
after the file has already uploaded, lose the connection just after, or call
|
||||
the upload API directly, and nothing references the file and no future write
|
||||
notices it. The daily sweep added in v1.46.1 only removed half-finished
|
||||
transfers, so the promise that a cancelled attachment disappears after an hour
|
||||
did not hold on an instance nobody edits. The sweep now compares against the
|
||||
stored configuration — under the same lock a write uses — and collects aged
|
||||
unreferenced attachments and plans as well.
|
||||
- **A drag is no longer undone by someone else's move (HP-1461-02).** When the
|
||||
full card learned to follow position changes in v1.46.1, it protected the
|
||||
positions you had moved but not yet sent — except it read that list *after*
|
||||
flushing the pending write, and flushing empties it first. In a real drag,
|
||||
where a write is already scheduled, the list was therefore empty and the
|
||||
server's older position was painted over your move. The card now takes the
|
||||
snapshot before flushing and also holds on to positions that are sent but not
|
||||
yet acknowledged: until the server confirms a position, the card that moved it
|
||||
is the authority on it.
|
||||
- Two tests grew up to their docstrings: the upload test now actually cancels
|
||||
the request task instead of only exercising error paths, and the position-sync
|
||||
smoke schedules a real debounced write and delays it, which is the ordering
|
||||
that lost the drag.
|
||||
|
||||
## v1.46.1 — 2026-07-28 (re-check of v1.46.0: HP-1460-01 … -03)
|
||||
- **Two uploads of the same file name can no longer collide (HP-1460-01).**
|
||||
v1.46.0 stopped overwriting attachments, but choosing a free name and taking
|
||||
it were two steps: two uploads racing between them agreed on the same name,
|
||||
both reported success, and one set of bytes replaced the other. The name is
|
||||
now claimed atomically as it is chosen — twenty simultaneous uploads of
|
||||
`manual.pdf` produce twenty files. The same helper is used when rebinding
|
||||
moves files, which had the same gap.
|
||||
Also fixed there: a name at the length limit lost its extension, and the
|
||||
collision suffix pushed it past the limit, so the attachment was stored under
|
||||
a name the server would not serve back — a permanent 404 on a file the UI
|
||||
reported as attached.
|
||||
- **An interrupted upload no longer leaves a temporary file forever
|
||||
(HP-1460-02).** Cleanup ran in an `except Exception`, which a cancelled
|
||||
request walks straight past, and the collector only ever looks inside marker
|
||||
folders — so an aborted transfer left a `.upload-*` in place with nothing able
|
||||
to remove it. Every exit path now cleans up, a request carrying two files is
|
||||
refused outright, and abandoned temporaries are swept at startup and daily.
|
||||
Uploads also write in 1 MB batches instead of one disk task per 64 KB.
|
||||
- **Two full cards side by side keep the same positions (HP-1460-03).** v1.46.0
|
||||
taught the static card to follow position changes and left the full one
|
||||
behind, so dragging an icon in one window did not move it in another until a
|
||||
reload. It follows now, without disturbing a drag of its own: a revision
|
||||
arriving mid-drag is merged rather than applied over the top, and a card does
|
||||
not re-read what it just wrote itself.
|
||||
|
||||
## v1.46.0 — 2026-07-28 (full external audit of v1.45.4: HP-1454-01 … -10)
|
||||
|
||||
**Security**
|
||||
|
||||
- **An uploaded SVG plan is no longer a live document of your Home Assistant
|
||||
origin (HP-1454-01, high — release blocker).** Inside the card a plan is
|
||||
referenced by `<image>`, where scripts never run; but the same url opened
|
||||
directly became a top-level document of HA's own origin, and a `<script>` in
|
||||
it could read the session's `localStorage` and call the API. Uploading needs
|
||||
write access, which by default every authenticated user has, and a signed url
|
||||
is easy to hand to an administrator. Plan responses for SVG now carry a
|
||||
`sandbox` Content-Security-Policy, which drops the document into an opaque
|
||||
origin. Only SVG gets it — a CSP on a PDF can break the browser's built-in
|
||||
viewer, and a raster image cannot execute anything. Nothing changes for
|
||||
existing plans: the card renders them exactly as before.
|
||||
|
||||
**Data integrity**
|
||||
|
||||
- **A manual attached to a device no longer overwrites the previous one
|
||||
(HP-1454-02).** The upload wrote straight to `<marker>/<filename>`, outside
|
||||
the configuration transaction: cancelling the dialog, or a rejected save, left
|
||||
the stored url serving the new bytes. And every new icon uploaded into one
|
||||
shared folder, so two of them attaching `manual.pdf` ended up pointing at the
|
||||
same physical file. Uploads now take a free name and never overwrite, a new
|
||||
icon gets its own staging folder whose files move to the real icon when the
|
||||
save is accepted, and an upload nobody saved is collected an hour later. The
|
||||
name a collision falls back to changed from `manual (2).pdf` to `manual-2.pdf`
|
||||
— the old one was sanitised on the way back in, so a renamed attachment was
|
||||
written and then never served (found by the new test, and it applied to
|
||||
rebind collisions before this release too).
|
||||
- **Two quick edits can no longer lose the second one (HP-1454-03).** The
|
||||
debounce spaced out the starts of a save, not the saves themselves. If one
|
||||
took longer than half a second — a busy instance, a slow link — the next edit
|
||||
went out with the same revision, the server accepted the first and rejected
|
||||
the second, and the conflict handler reloaded the server copy over the local
|
||||
one. The edit was gone, with a message blaming another window when there was
|
||||
none. Writes are now serialized: one at a time, each carrying the revision the
|
||||
previous one returned.
|
||||
|
||||
**Correctness and limits**
|
||||
|
||||
- **Open boundaries follow the geometry again (HP-1454-04).** Their cache was
|
||||
keyed on room ids and links only, so changing a space's aspect ratio or
|
||||
dragging a vertex left the open boundaries — and the light spilling through
|
||||
them — at their old coordinates until a reload. The cache is now keyed on the
|
||||
rendered model itself, which is exactly what it is computed from.
|
||||
- **The configuration can no longer be made arbitrarily heavy (HP-1454-05).**
|
||||
The outer collections were capped; the ones inside them were not. A polygon
|
||||
with 150 000 points, or a list of 100 000 device ids, validated fine and then
|
||||
made every render walk it. There are now limits on polygon vertices, open-to
|
||||
links, controls, attachments, text and url lengths, plus a cap on the whole
|
||||
serialized configuration. The obsolete `segments` field is dropped by the
|
||||
server instead of trusting the card to strip it.
|
||||
- **Large files stream instead of being held in memory (HP-1454-06).** A 50 MB
|
||||
manual was read whole into memory on the way in and again on the way out; a
|
||||
couple of parallel downloads were real pressure on a small Home Assistant
|
||||
host. Uploads stream to a temporary file, downloads stream from disk.
|
||||
|
||||
**Consistency**
|
||||
|
||||
- **The static space card honours per-room fill settings (HP-1454-07).** It
|
||||
builds its model with a different function, and room settings were not carried
|
||||
into it, so a room you had set to "no fill" was still painted.
|
||||
- **Moving an icon updates the static card immediately (HP-1454-08).** Layout is
|
||||
separate state with no revision and no event: a drag on the full card left a
|
||||
static card next to it showing the old position until the configuration
|
||||
changed or the page was reloaded. Layout writes now keep a revision, return
|
||||
it, and announce themselves — which also makes the optimistic locking on a
|
||||
wholesale layout write mean something, since a point-wise write used to reset
|
||||
the counter.
|
||||
- **A repair warning about a missing plan disappears with its space
|
||||
(HP-1454-09).** The cleanup only looked at spaces that still exist, so
|
||||
deleting or renaming one left its warning in Repairs with nothing able to
|
||||
clear it.
|
||||
- Build chain: `serialize-javascript` pinned past two advisories (HP-1454-10).
|
||||
Not reachable at runtime and production dependencies were already clean, but
|
||||
it is one line.
|
||||
|
||||
## v1.45.4 — 2026-07-28 (review of v1.45.3: R5-1, R5-2)
|
||||
- **A partly successful signing answer no longer skips the backoff (R5-1).**
|
||||
The backend signs each path independently: one it cannot sign is logged,
|
||||
skipped, and the call still succeeds with the remaining urls. The card took
|
||||
any successful call as "the whole batch is done", cleared the backoff for
|
||||
every path in it, and then wrote only the urls that came back — so a path the
|
||||
backend kept skipping was requested again on every single render, which is
|
||||
exactly the amplification v1.45.2 added the backoff to prevent. A path is now
|
||||
counted as signed only if the answer actually carries a url for it; the rest
|
||||
back off individually, keys nobody asked for are ignored, and a re-render is
|
||||
only triggered when at least one new signature arrived.
|
||||
- **The status snapshot no longer contradicts the repository (R5-2).** It still
|
||||
described `main` as carrying releases up to v1.40.1 and quoted test counts
|
||||
from several releases back, while the version line right beside them was kept
|
||||
current — a maintainer or an agent reading it for handoff got a wrong branch
|
||||
model and a smaller picture of the coverage than exists. The branch roles are
|
||||
described accurately, and the counts are gone: `npm run inventory` prints them
|
||||
from the tree, so there is nothing left to go stale.
|
||||
|
||||
## v1.45.3 — 2026-07-27
|
||||
- **"Value instead of an icon" could not be saved (issue #3).** The option was
|
||||
added to the device editor in v1.26.0, but the server-side schema only ever
|
||||
accepted `badge`, `ripple` and `icon_ripple`. Choosing it produced
|
||||
`not a valid value for dictionary value @ data['config']['markers'][n]['display']`
|
||||
— and because a single rejected marker fails the whole configuration write,
|
||||
the plan could not be saved at all until the setting was undone. Thanks to
|
||||
@RemyRoux for the report and the exact error text.
|
||||
- **The option lists now live in one place and are checked across languages.**
|
||||
`DISPLAY_MODES`, `TAP_ACTIONS`, `SPACE_FILL_MODES` and `ROOM_FILL_MODES` are
|
||||
exported from the card and read by a backend test that asserts the schema
|
||||
accepts every value a user can actually pick. Adding an option to an editor
|
||||
and forgetting the schema now fails the test suite instead of surfacing
|
||||
through somebody's error message.
|
||||
|
||||
## v1.45.2 — 2026-07-27 (hardening from the v1.45.1 review: R4-1, R4-2)
|
||||
- **A failed cleanup no longer reports an accepted save as an error (R4-1).**
|
||||
Collecting superseded plan files runs after the configuration is already
|
||||
stored, but an error while listing the directory — it can vanish or turn
|
||||
unreadable between the check and the walk — propagated out of `config/set`.
|
||||
The client then saw a failure for a revision the server had committed, and its
|
||||
retry came back as a conflict. The collector now reports "nothing collected"
|
||||
instead of raising, and `config/set` logs and proceeds: the event fires and
|
||||
the new revision is returned.
|
||||
- **One signing request per url instead of one per render (R4-2).** The pending
|
||||
set was cleared when the batch went out rather than when it came back, so
|
||||
while a `content/sign` call was in flight every re-render queued another one —
|
||||
six calls where one was needed, and far worse on a socket that is slow rather
|
||||
than merely busy. Queued and in-flight are now separate states, a failure
|
||||
backs off (2 s doubling to 60 s) instead of retrying on the next frame, and a
|
||||
request that never settles stops blocking retries after 15 s. A late answer
|
||||
arriving after the card was torn down no longer triggers a render.
|
||||
- Tests: eight unit tests for the signer with hand-settled promises (four fail
|
||||
on v1.45.1), a backend test asserting a broken collector still yields a
|
||||
successful save with a usable revision, and the pure-collector test extended
|
||||
to a disappearing directory.
|
||||
|
||||
## v1.45.1 — 2026-07-27 (follow-up review of v1.45.0: R3-1, R3-2)
|
||||
- **Collecting old plan files moved into the config transaction (R3-1, high).**
|
||||
v1.45.0 made the upload safe but handed the deletion to the client: after a
|
||||
successful save the card asked the backend to remove everything except the
|
||||
file it had just committed. Two open editors could not be ordered — a delayed
|
||||
request from one client deleted the plan the other had just saved, and the
|
||||
accepted configuration was left pointing at nothing, which is the exact damage
|
||||
copy-on-write was added to prevent. The `houseplan/plan/cleanup` command is
|
||||
gone. `config/set` now collects inside its own write lock, comparing the
|
||||
configuration it replaced with the one it accepted: a file the old revision
|
||||
referenced and the new one does not is removed, and any other unreferenced
|
||||
upload is left alone until it is an hour old, because a fresh one may belong
|
||||
to a transaction that has not committed yet.
|
||||
- **The static space card shows its plan background again (R3-2).** It signed
|
||||
the url and then threw the result away — `getCardSize()` mutated a throwaway
|
||||
model while `render()` rebuilt its own from the config — so the `<image>` kept
|
||||
requesting the protected path and got a 401 on every render. Both cards now
|
||||
share one signer, which also gives the static card the batching, the
|
||||
expiry handling and the periodic re-signing the main card already had. Its
|
||||
pending set is released in `finally`, so a single failed request no longer
|
||||
wedges a url for the life of the page.
|
||||
- New tests: five backend cases for the two-client interleavings from the report
|
||||
(late commit, uncommitted upload, aged orphan, foreign files, rejected save),
|
||||
the collector extracted to a pure module and unit-tested, and
|
||||
`smoke_space_card_bg` for the signed background — it fails on v1.45.0 with the
|
||||
raw url in the DOM.
|
||||
|
||||
## v1.45.0 — 2026-07-27 (external review of v1.44.8: R2-1, R2-2, R2-3)
|
||||
- **A rejected save can no longer damage a working plan (R2-1, high).** The
|
||||
plan file was written to its final name — deleting the previous extension on
|
||||
the way — *before* the revision-checked config write. If that write was then
|
||||
rejected (revision conflict, validation, lost connection), the live plan had
|
||||
already been replaced, or the stored config was left pointing at a file that
|
||||
no longer existed. Uploads now go to a versioned name
|
||||
(`<space>.<token>.<ext>`) and nothing is deleted; the card asks the backend to
|
||||
drop the superseded files only after the config write is accepted. A crash in
|
||||
between leaves one orphan, which the next successful upload collects.
|
||||
- **Signed urls no longer expire for good on long-lived screens (R2-2).** The
|
||||
backend signs at most 200 paths per request and silently ignores the rest,
|
||||
while the card sent its whole cache in one call and treated any cached entry
|
||||
as valid forever. Past 200 attachments the later ones stopped being refreshed
|
||||
and, 24 hours in, quietly broke. Requests are now batched to the shared limit,
|
||||
entries carry their age (aging urls keep working while a replacement is
|
||||
fetched, expired ones are never served), and the cache is pruned to the urls
|
||||
the current config still references.
|
||||
- **Room climate is computed once per update instead of once per room (R2-3).**
|
||||
Each room asked for temperature and humidity separately, and every ask
|
||||
rescanned the entire entity registry: with 60 rooms and 2000 entities that is
|
||||
~120 traversals per render, enough to spend a whole frame on metadata that had
|
||||
not changed. One pass now builds a map for all areas, keyed on the Home
|
||||
Assistant snapshot, so fresh states are always observed while unrelated
|
||||
re-renders cost nothing. Measured in the smoke: 133 registry scans per update
|
||||
before, 2 after — and no longer growing with the number of rooms.
|
||||
- `smoke_ux_fixes` wrote its screenshot to a hard-coded `/tmp` path and could
|
||||
not run on Windows; it uses the OS temp directory now.
|
||||
- New tests: `smoke_plan_upload_reject` (cleanup happens only after an accepted
|
||||
save), `smoke_sign_cap` (201 urls, batching, pruning, expiry),
|
||||
`smoke_climate_once` (scan count does not grow with rooms), plus backend
|
||||
coverage for the versioned plan names and unit tests for the new helpers.
|
||||
|
||||
## 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
|
||||
addressed.
|
||||
|
||||
- **The lock invariant is now precise and enforced (CR-1).** The reviewer was
|
||||
right that "locks can never be actuated from the plan" was too absolute a
|
||||
claim: the door card's Unlock button does call the service. That button is a
|
||||
deliberate product decision, so the invariant is restated where it belongs
|
||||
("never by an accidental tap; exactly one labeled surface"), unlocking now
|
||||
**asks for confirmation**, and a new smoke exercises all five actuation paths
|
||||
to prove icons, `controls[]` and the device card still refuse locks outright.
|
||||
- **Attachment migration became transactional (CR-2).** Rebinding a marker used
|
||||
to MOVE its files before the revision-checked config save — if that save was
|
||||
rejected, the stored config kept the old urls while the files had already
|
||||
left. Now the server **copies**, the config is committed, and only then the
|
||||
old folder is removed (`houseplan/files/cleanup`).
|
||||
- **Failed or partial migrations no longer rewrite urls (CR-3).** The copy
|
||||
reports an exact `{source: written}` mapping; only confirmed copies are
|
||||
rewritten, name collisions get a unique name instead of silently linking a
|
||||
pre-existing file, and a failed migration surfaces as a toast with the links
|
||||
left pointing at the still-existing originals.
|
||||
|
||||
## v1.44.1 — 2026-07-27
|
||||
- Added the community chat everywhere users look: **https://t.me/ha_houseplan**
|
||||
(badge and header line in both READMEs, a "Getting help" section, the issue
|
||||
template contact links, CONTRIBUTING, STATUS and SCOPE).
|
||||
|
||||
## v1.44.0 — 2026-07-27 (user feedback: control first)
|
||||
|
||||
- **The device card is now a control surface.** It opens with the device's
|
||||
controllable entities: lights, switches and fans toggle straight from the
|
||||
card with finger-sized buttons, covers/locks/climate open Home Assistant's
|
||||
own more-info. Model, links and PDF manuals moved below — on a wall tablet
|
||||
this card is for running the home, not for reading documentation (field
|
||||
report). Config and diagnostic entities are not listed; locks still never
|
||||
toggle from a card tap.
|
||||
- **"This device is a light source"** — a new per-device flag. A smart switch
|
||||
driving ordinary (dumb) fixtures now casts a glow in the "Light sources"
|
||||
fill without inventing a light-group helper: the glow follows the switch, or
|
||||
the lights bound under "Controls light sources" when they are set.
|
||||
|
||||
## v1.43.3 — 2026-07-27 (user feedback: discoverability and touch)
|
||||
|
||||
- **Room settings were unfindable.** The gear added in v1.42.0 lived inside the
|
||||
room label at 0.9em of its font and 60% opacity — a few pale pixels on a
|
||||
normal plan. It is now a pill button "⚙ Room" of a fixed, readable size that
|
||||
does not shrink with the card font, and it appears on **unnamed rooms too**
|
||||
(that is where you name them). This also unblocks the font-size sliders,
|
||||
which nobody could reach.
|
||||
- **Metrics line enlarged** from 0.62 to 0.75 of the room name — the reporter
|
||||
could scale the name but the sensor line stayed unreadable on a tablet. The
|
||||
per-room and per-space multipliers still apply on top.
|
||||
- **Touch tooltips, take two.** The `(hover: none)` guard was not enough: some
|
||||
devices, skins, styluses and paired mice report `hover: hover`, so tips still
|
||||
stuck under the finger. The card now also latches on the first touch/pen
|
||||
pointer event and drops any open tooltip on touch.
|
||||
|
||||
## v1.43.2 — 2026-07-27 (external audit: the test layer)
|
||||
|
||||
- **The smoke suite can finally fail (T1).** All 48 headless-browser smokes used
|
||||
|
||||
Executable
+622
@@ -0,0 +1,622 @@
|
||||
# История изменений
|
||||
|
||||
> Русская версия [docs/CHANGELOG.md](CHANGELOG.md). Переведены записи начиная
|
||||
> с v1.42.0 (2026-07-26); более ранние доступны только в английском файле.
|
||||
>
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## v1.47.0 — 2026-07-28 (выбор из уже загруженных планов)
|
||||
- **Диалог пространства показывает планы, сохранённые на сервере.** Отцепление
|
||||
плана оставляет картинку на диске — так с v1.46.4, но вернуть её можно было
|
||||
только найдя исходный файл у себя и загрузив заново. «Уже загруженные»
|
||||
показывают, что есть: миниатюра, размер файла и то, какое пространство его
|
||||
использует. Клик прикрепляет, пропорции читаются из самой картинки — так же,
|
||||
как при загрузке.
|
||||
- **Там же план и удаляется.** Файл плана никогда не удаляется автоматически —
|
||||
ни за отцепление, ни за возраст, — и это разумная политика ровно до тех пор,
|
||||
пока видно, что именно хранится, и есть способ убрать это осознанно. Кнопка
|
||||
корзины делает это и отказывает, пока план используется пространством: ответ
|
||||
на вопрос «можно ли удалить» даёт сохранённая конфигурация, а не браузер.
|
||||
- Документация догнала код: несколько комментариев всё ещё описывали удаление по
|
||||
возрасту, убранное в v1.46.6.
|
||||
|
||||
## v1.46.6 — 2026-07-28 (обещание про отцепление, теперь выполненное)
|
||||
- **Переключение пространства в «нарисовать» больше не удаляет его картинку.**
|
||||
v1.46.4 и v1.46.5 утверждали, что не удаляет, и плановая уборка действительно
|
||||
отцеплённые планы не трогала — но само сохранение удаляло файл в тот момент,
|
||||
когда снималась ссылка, ещё до всех этих проверок. Причина: файл, покинувший
|
||||
конфигурацию, считался «заменённым», а по одной этой разнице замену плана,
|
||||
отцепление и удаление пространства различить невозможно. Удалением, о котором
|
||||
просили, является только первое. Теперь переход классифицируется по
|
||||
пространству-владельцу, и та же разница применяется к вложениям: убрали файл у
|
||||
существующего устройства — он удаляется, удалили устройство — его инструкции
|
||||
остаются.
|
||||
- **План удалённого пространства сохраняется**, а не тридцать дней, как обещала
|
||||
v1.46.5: тридцать дней по возрасту файла всё равно бессмысленны — обычно он
|
||||
загружен месяцы назад.
|
||||
- **По возрасту больше не удаляется ничего**, кроме промежуточной папки диалога.
|
||||
Правило, которое вычищало «отвергнутые загрузки», оказалось в гонке с
|
||||
повторной попыткой: уборка удаляла файл неудавшегося сохранения ровно тогда,
|
||||
когда следующая попытка коммитила ссылку на него. Правило, способное удалить
|
||||
файл, на который кто-то вот-вот сошлётся, не стоит освобождаемого места.
|
||||
Файлы уходят по действию, в остальных случаях остаются.
|
||||
|
||||
## v1.46.5 — 2026-07-28 (ревизия всех автоматических удалений)
|
||||
- **Отцеплённый план не удаляется никогда, ни в каком возрасте.** В v1.46.4 ему
|
||||
давался месяц; теперь это навсегда, и причина записана там, где её увидит
|
||||
следующая правка. Правило, оно же в docs/SCOPE.md: компонент вправе удалить
|
||||
файл только тогда, когда об этом говорит действие пользователя — замена
|
||||
плана, удаление вложения, удаление устройства. «На это больше никто не
|
||||
ссылается» таким действием не является. Ошибки несимметричны: занятое зря
|
||||
место видно, стоит копейки и обратимо; удалённый файл — ничего из этого.
|
||||
- **`houseplan/files/cleanup` больше не сносит папку по слову клиента.** После
|
||||
перепривязки устройства файлы копируются под новый id, а старая папка
|
||||
удалялась — через `rmtree`, по тому id, который прислала карточка. Два плохих
|
||||
исхода: при частичном копировании часть ссылок продолжает указывать внутрь
|
||||
этой папки (миграция намеренно их не переписывает — то есть это были живые
|
||||
ссылки на удаляемые файлы), а неверный или устаревший id от любого клиента
|
||||
уничтожил бы инструкции существующего устройства. Теперь сервер сам сверяется
|
||||
с сохранённой конфигурацией, под её блокировкой, и удаляет только то, на что
|
||||
никто не ссылается.
|
||||
- **План удалённого пространства ждёт тридцать дней вместо часа.** Удаление
|
||||
пространства осознанно, но час — короткое окно, чтобы заметить промах.
|
||||
|
||||
## v1.46.4 — 2026-07-28 (потеря данных: отцеплённые планы убирались как мусор)
|
||||
- **Отцеплённый план больше не удаляется через час.** Переключение пространства
|
||||
в режим «нарисовать» снимает ссылку и, как редактор всегда и говорил,
|
||||
оставляет картинку на диске, чтобы её можно было вернуть. Уборка, добавленная
|
||||
в v1.46.0, этой разницы не делала: считала «на файл сейчас никто не
|
||||
ссылается» синонимом «файл брошен» и применяла часовое правило. На установке
|
||||
автора плановый проход в итоге удалил два плана этажей, отцеплённых
|
||||
несколькими неделями раньше, — восстановить их было нечем. Если вы отцепляли
|
||||
план после v1.46.0 и инстанс перезапускался или проработал сутки — загляните в
|
||||
`config/houseplan/plans/` и напишите в Telegram-чат, если файла нет.
|
||||
Правило теперь такое: **коммит по-прежнему удаляет ровно то, что заменил**, —
|
||||
это он знает наверняка. Дальше вопрос в том, означает ли «непривязан»
|
||||
«брошен», и ответ зависит от случая. У пространства, у которого плана нет
|
||||
вовсе, его отцепили — и, возможно, вернут: его файлы не удаляются никогда. У
|
||||
пространства, у которого план есть, лишние файлы могут быть только его же
|
||||
отвергнутыми загрузками — они по-прежнему уходят через час. Вложения вне
|
||||
промежуточной папки диалога ждут месяц; сама промежуточная папка, где по
|
||||
построению лежит только загрузка из несохранённого диалога, сохраняет часовое
|
||||
правило.
|
||||
|
||||
## v1.46.3 — 2026-07-28 (перепроверка v1.46.2: HP-1462-01)
|
||||
- **Уборка при старте действительно убирает.** Она искала свои же runtime-данные
|
||||
по домену, а во время запуска Home Assistant ещё не считает интеграцию
|
||||
загруженной — поэтому поиск возвращал пустоту, и проход тихо вырождался в
|
||||
удаление незавершённых передач, оставляя настоящую работу таймеру через
|
||||
24 часа. При перезапусках чаще, чем раз в сутки, она не выполнялась вообще.
|
||||
Теперь используется объект, который у неё и так был на руках.
|
||||
- **Тест, который должен был это доказать, проходил по неверной причине.** Он
|
||||
создавал лишние файлы *до* сохранения конфигурации, а сохранение тоже
|
||||
собирает мусор — так что к моменту перезапуска убирать было уже нечего.
|
||||
Переписан: файлы создаются после сохранения; добавлен второй тест, который
|
||||
дёргает плановый таймер отдельно, и третий, который запускает перезапуск и
|
||||
сохранение одновременно и проверяет, что принятая конфигурация никогда не
|
||||
ссылается на удалённый уборкой файл.
|
||||
|
||||
## v1.46.2 — 2026-07-28 (перепроверка v1.46.1: HP-1461-01, -02)
|
||||
- **Файл, который в итоге никому не понадобился, убирается, даже если больше
|
||||
ничего не сохраняют (HP-1461-01).** Сборка привязана к записи конфигурации —
|
||||
это верно для того, что запись вытесняет, но оставляет зазор: отмените диалог
|
||||
после того, как файл уже загрузился, потеряйте связь сразу после, или
|
||||
вызовите API загрузки напрямую — и на файл никто не ссылается, а будущей
|
||||
записи, которая бы это заметила, нет. Добавленное в v1.46.1 ежедневное
|
||||
подметание убирало только незавершённые передачи, поэтому обещание «отменённое
|
||||
вложение исчезнет через час» не выполнялось там, где никто ничего не правит.
|
||||
Теперь подметание сверяется с сохранённой конфигурацией — под той же
|
||||
блокировкой, что и запись, — и собирает устаревшие непривязанные вложения и
|
||||
планы тоже.
|
||||
- **Перетаскивание больше не отменяется чужим перемещением (HP-1461-02).** Когда
|
||||
в v1.46.1 полная карточка научилась следить за позициями, она защищала те,
|
||||
что вы подвинули, но ещё не отправили, — вот только читала этот список *после*
|
||||
сброса отложенной записи, а сброс его первым делом опустошает. При настоящем
|
||||
перетаскивании, когда запись уже запланирована, список оказывался пустым, и
|
||||
старая серверная позиция закрашивала ваше движение. Теперь снимок снимается до
|
||||
сброса, и вдобавок удерживаются позиции, отправленные, но ещё не
|
||||
подтверждённые: пока сервер не подтвердил позицию, авторитет по ней — та
|
||||
карточка, которая её подвинула.
|
||||
- Два теста доросли до своих же описаний: тест загрузки теперь действительно
|
||||
отменяет задачу запроса, а не только проходит по путям ошибок, а смоук
|
||||
синхронизации позиций планирует настоящую отложенную запись и задерживает её —
|
||||
именно этот порядок и терял перетаскивание.
|
||||
|
||||
## v1.46.1 — 2026-07-28 (перепроверка v1.46.0: HP-1460-01 … -03)
|
||||
- **Две загрузки с одинаковым именем больше не сталкиваются (HP-1460-01).**
|
||||
v1.46.0 перестала затирать вложения, но выбор свободного имени и его занятие
|
||||
были двумя шагами: две загрузки, попавшие между ними, сходились на одном
|
||||
имени, обе рапортовали успех, и одни байты заменяли другие. Теперь имя
|
||||
занимается атомарно в момент выбора — двадцать одновременных загрузок
|
||||
`manual.pdf` дают двадцать файлов. Тот же механизм используется при переносе
|
||||
файлов на перепривязке, где был ровно такой же зазор.
|
||||
Заодно там же: имя предельной длины теряло расширение, а суффикс коллизии
|
||||
выталкивал его за предел, и вложение сохранялось под именем, которое сервер
|
||||
обратно не отдаёт — вечный 404 на файл, который интерфейс считал
|
||||
прикреплённым.
|
||||
- **Прерванная загрузка больше не оставляет временный файл навсегда
|
||||
(HP-1460-02).** Уборка стояла в `except Exception`, мимо которого отменённый
|
||||
запрос проходит насквозь, а сборщик заглядывает только в папки маркеров —
|
||||
поэтому оборванная передача оставляла `.upload-*`, и убрать его было некому.
|
||||
Теперь убирает любой путь выхода, запрос с двумя файлами отклоняется сразу, а
|
||||
брошенные временные подметаются при старте и раз в сутки. Запись идёт
|
||||
порциями по мегабайту, а не отдельной задачей на каждые 64 КБ.
|
||||
- **Две полные карточки рядом держат одинаковые позиции (HP-1460-03).** v1.46.0
|
||||
научила следить за перемещениями статическую карточку и оставила позади
|
||||
полную, поэтому перетаскивание иконки в одном окне не двигало её в другом до
|
||||
перезагрузки. Теперь следит — и не мешает собственному перетаскиванию: чужая
|
||||
ревизия, пришедшая в его разгар, сливается, а не накатывается сверху, и
|
||||
карточка не перечитывает то, что записала сама.
|
||||
|
||||
## v1.46.0 — 2026-07-28 (полный внешний аудит v1.45.4: HP-1454-01 … -10)
|
||||
|
||||
**Безопасность**
|
||||
|
||||
- **Загруженный SVG-план больше не является живым документом origin вашего
|
||||
Home Assistant (HP-1454-01, high — блокер релиза).** Внутри карточки план
|
||||
подключён через `<image>`, где скрипты не выполняются; но тот же URL,
|
||||
открытый напрямую, становился документом верхнего уровня в origin самого HA,
|
||||
и `<script>` в нём мог читать `localStorage` сессии и обращаться к API. Для
|
||||
загрузки нужно право записи, а оно по умолчанию есть у каждого
|
||||
аутентифицированного пользователя, и подписанную ссылку несложно передать
|
||||
администратору. Ответы с SVG теперь несут заголовок Content-Security-Policy
|
||||
`sandbox`, который помещает документ в opaque origin. Только SVG — CSP на PDF
|
||||
способен сломать встроенный просмотрщик браузера, а растровая картинка ничего
|
||||
выполнить не может. Для существующих планов ничего не меняется: карточка
|
||||
рисует их ровно как раньше.
|
||||
|
||||
**Целостность данных**
|
||||
|
||||
- **Инструкция, приложенная к устройству, больше не затирает предыдущую
|
||||
(HP-1454-02).** Загрузка писала прямо в `<маркер>/<имя файла>`, вне
|
||||
транзакции конфигурации: отмена диалога или отвергнутое сохранение оставляли
|
||||
сохранённую ссылку указывающей на новые байты. А все новые иконки грузили в
|
||||
одну общую папку, поэтому две с файлом `manual.pdf` начинали ссылаться на
|
||||
один физический файл. Теперь загрузка занимает свободное имя и никогда не
|
||||
перезаписывает, новая иконка получает собственную промежуточную папку, файлы
|
||||
из которой переезжают к настоящей иконке при принятом сохранении, а загрузка,
|
||||
которую никто не сохранил, убирается через час. Имя, на которое уходит
|
||||
коллизия, сменилось с `manual (2).pdf` на `manual-2.pdf`: старое санитайзилось
|
||||
на обратном пути, поэтому переименованное вложение записывалось и больше не
|
||||
отдавалось (нашёл новый тест; до этого релиза так же ломались коллизии при
|
||||
перепривязке).
|
||||
- **Две быстрые правки больше не теряют вторую (HP-1454-03).** Debounce
|
||||
разносил старты сохранения, а не сами сохранения. Если одно длилось дольше
|
||||
полусекунды — занятый сервер, медленная связь, — следующая правка уходила с
|
||||
той же ревизией, сервер принимал первую и отклонял вторую, а обработчик
|
||||
конфликта перечитывал серверную копию поверх локальной. Правка исчезала, и
|
||||
сообщение винило «другое окно», которого не было. Записи сериализованы: по
|
||||
одной за раз, каждая с ревизией, которую вернула предыдущая.
|
||||
|
||||
**Корректность и пределы**
|
||||
|
||||
- **Открытые границы снова следуют за геометрией (HP-1454-04).** Их кэш
|
||||
ключевался только по id комнат и связям, поэтому смена пропорций
|
||||
пространства или перетаскивание вершины оставляли открытые границы — и свет,
|
||||
который через них проходит, — в старых координатах до перезагрузки. Ключом
|
||||
стала сама отрисованная модель, из которой они и вычисляются.
|
||||
- **Конфигурацию больше нельзя сделать сколь угодно тяжёлой (HP-1454-05).**
|
||||
Внешние коллекции были ограничены, вложенные — нет. Полигон на 150 000 точек
|
||||
или список из 100 000 идентификаторов проходили валидацию, а потом каждый
|
||||
рендер по ним ходил. Появились пределы на вершины полигона, связи открытых
|
||||
границ, управляемые сущности, вложения, длину текста и ссылок, плюс общий
|
||||
предел размера сериализованной конфигурации. Устаревшее поле `segments`
|
||||
отбрасывает сервер, а не надежда на современный клиент.
|
||||
- **Большие файлы передаются потоком, а не через память (HP-1454-06).**
|
||||
Инструкция на 50 МБ целиком читалась в память на приёме и ещё раз на отдаче;
|
||||
пара параллельных скачиваний — заметная нагрузка на слабый хост Home
|
||||
Assistant. Загрузка пишется во временный файл потоком, отдача идёт с диска.
|
||||
|
||||
**Согласованность**
|
||||
|
||||
- **Статическая карточка пространства учитывает настройки заливки комнаты
|
||||
(HP-1454-07).** Она строит модель другой функцией, и настройки комнаты в неё
|
||||
не переносились — комната, которой вы выключили заливку, всё равно
|
||||
закрашивалась.
|
||||
- **Перемещение иконки сразу видно на статической карточке (HP-1454-08).**
|
||||
Layout — отдельное состояние без ревизии и без события: перетаскивание на
|
||||
полной карточке оставляло соседнюю статическую со старой позицией до
|
||||
изменения конфигурации или перезагрузки страницы. Теперь записи layout хранят
|
||||
ревизию, возвращают её и сообщают о себе — заодно оптимистическая блокировка
|
||||
на полной записи layout начала что-то значить, ведь точечная запись раньше
|
||||
сбрасывала счётчик.
|
||||
- **Предупреждение о пропавшем плане исчезает вместе с пространством
|
||||
(HP-1454-09).** Уборка смотрела только на существующие пространства, поэтому
|
||||
удаление или переименование оставляло предупреждение в «Ремонте» навсегда.
|
||||
- Сборка: `serialize-javascript` поднят выше двух advisory (HP-1454-10). В
|
||||
рантайме недостижим, production-зависимости и так были чисты, но это одна
|
||||
строка.
|
||||
|
||||
## v1.45.4 — 2026-07-28 (ревью v1.45.3: R5-1, R5-2)
|
||||
- **Частично успешный ответ на подпись больше не пропускает выдержку (R5-1).**
|
||||
Бэкенд подписывает каждый путь независимо: тот, что подписать не удалось,
|
||||
логируется, пропускается, и вызов всё равно завершается успешно с остальными
|
||||
ссылками. Карточка считала любой успешный вызов «весь батч готов», сбрасывала
|
||||
выдержку для всех путей в нём и записывала только вернувшиеся ссылки — и путь,
|
||||
который бэкенд стабильно пропускал, запрашивался заново на каждом рендере, то
|
||||
есть ровно то усиление, ради которого выдержка и вводилась в v1.45.2. Теперь
|
||||
путь считается подписанным, только если в ответе действительно есть ссылка на
|
||||
него; остальные уходят в выдержку по отдельности, ключи, которых не просили,
|
||||
игнорируются, а перерисовка запускается лишь при появлении хотя бы одной новой
|
||||
подписи.
|
||||
- **Снимок состояния больше не противоречит репозиторию (R5-2).** Там всё ещё
|
||||
было написано, что в `main` лежат релизы только до v1.40.1, и приводились
|
||||
счётчики тестов на несколько релизов назад — при том что строка версии рядом
|
||||
исправно обновлялась. Читающий его человек или агент получал неверную
|
||||
модель веток и заниженное представление о покрытии. Роли веток описаны точно,
|
||||
а счётчики убраны: `npm run inventory` печатает их из дерева, и устаревать
|
||||
больше нечему.
|
||||
|
||||
## v1.45.3 — 2026-07-27
|
||||
- **«Значение вместо иконки» невозможно было сохранить (issue #3).** Опция
|
||||
появилась в редакторе устройств ещё в v1.26.0, но серверная схема всё это
|
||||
время принимала только `badge`, `ripple` и `icon_ripple`. При её выборе
|
||||
сохранение падало с
|
||||
`not a valid value for dictionary value @ data['config']['markers'][n]['display']`,
|
||||
а поскольку один отвергнутый маркер валит всю запись конфигурации, план не
|
||||
сохранялся вообще, пока настройку не отменишь. Спасибо @RemyRoux за отчёт и
|
||||
точный текст ошибки.
|
||||
- **Списки опций теперь в одном месте и сверяются между языками.**
|
||||
`DISPLAY_MODES`, `TAP_ACTIONS`, `SPACE_FILL_MODES` и `ROOM_FILL_MODES`
|
||||
экспортируются из карточки, и backend-тест читает их, проверяя, что схема
|
||||
принимает каждое значение, которое пользователь реально может выбрать.
|
||||
Теперь добавить опцию в редактор и забыть про схему — значит уронить тесты, а
|
||||
не узнать об этом из чужого сообщения об ошибке.
|
||||
|
||||
## v1.45.2 — 2026-07-27 (закалка по ревью v1.45.1: R4-1, R4-2)
|
||||
- **Сбой уборки больше не превращает принятое сохранение в ошибку (R4-1).**
|
||||
Сборка вытесненных файлов плана идёт уже после того, как конфигурация
|
||||
сохранена, но ошибка при обходе каталога — он может исчезнуть или стать
|
||||
недоступным между проверкой и обходом — вылетала наружу из `config/set`.
|
||||
Клиент видел неудачу для ревизии, которую сервер закоммитил, а его повтор
|
||||
возвращался с конфликтом. Теперь сборщик сообщает «ничего не убрано» вместо
|
||||
исключения, а `config/set` пишет в лог и продолжает: событие уходит, новая
|
||||
ревизия возвращается.
|
||||
- **Один запрос подписи на ссылку вместо одного на рендер (R4-2).** Множество
|
||||
ожидающих очищалось в момент отправки батча, а не по возвращении, поэтому
|
||||
пока запрос `content/sign` был в полёте, каждая перерисовка ставила ещё
|
||||
один — шесть вызовов там, где нужен один, и куда хуже на медленном (а не
|
||||
просто занятом) сокете. Состояния «в очереди» и «в полёте» теперь разделены,
|
||||
после ошибки включается выдержка (2 с с удвоением до 60 с) вместо повтора на
|
||||
следующем кадре, а запрос, который так и не завершился, перестаёт блокировать
|
||||
повторы через 15 с. Поздний ответ, пришедший после размонтирования карточки,
|
||||
больше не вызывает перерисовку.
|
||||
- Тесты: восемь юнит-тестов подписывателя с ручным разрешением promise (четыре
|
||||
падают на v1.45.1), backend-тест на то, что сломанный сборщик оставляет
|
||||
сохранение успешным с рабочей ревизией, и проверка исчезнувшего каталога в
|
||||
тестах чистого сборщика.
|
||||
|
||||
## v1.45.1 — 2026-07-27 (повторное ревью v1.45.0: R3-1, R3-2)
|
||||
- **Уборка старых файлов плана перенесена внутрь транзакции конфига (R3-1,
|
||||
high).** v1.45.0 сделала загрузку безопасной, но отдала удаление клиенту:
|
||||
после успешного сохранения карточка просила бэкенд убрать всё, кроме только
|
||||
что закоммиченного файла. Два открытых редактора невозможно упорядочить —
|
||||
задержавшийся запрос одного клиента удалял план, который только что сохранил
|
||||
другой, и принятая конфигурация оставалась со ссылкой в пустоту, то есть
|
||||
ровно с тем ущербом, ради которого вводился copy-on-write. Команда
|
||||
`houseplan/plan/cleanup` убрана. Теперь `config/set` убирает сам, под своей
|
||||
блокировкой, сравнивая конфигурацию, которую заменил, с той, которую принял:
|
||||
файл, на который ссылалась старая ревизия и не ссылается новая, удаляется, а
|
||||
любая другая непривязанная загрузка не трогается, пока ей не исполнится час —
|
||||
свежая может принадлежать ещё не завершённой чужой транзакции.
|
||||
- **Статическая карточка пространства снова показывает подложку (R3-2).** Она
|
||||
подписывала URL и выбрасывала результат — `getCardSize()` правил временную
|
||||
модель, а `render()` строил свою заново из конфига, — поэтому `<image>`
|
||||
запрашивал защищённый путь и на каждом рендере получал 401. Обе карточки
|
||||
теперь используют один подписыватель, и статическая заодно получила батчи,
|
||||
учёт срока годности и периодическое переподписывание, которые были только у
|
||||
основной. Её множество ожидающих запросов освобождается в `finally`, так что
|
||||
одна неудача больше не блокирует ссылку до конца жизни страницы.
|
||||
- Новые тесты: пять backend-сценариев чередования двух клиентов из отчёта
|
||||
(поздний коммит, незакоммиченная загрузка, устаревший сирота, чужие файлы,
|
||||
отвергнутое сохранение), сборщик вынесен в чистый модуль и покрыт юнит-
|
||||
тестами, плюс `smoke_space_card_bg` на подписанный фон — он падает на
|
||||
v1.45.0, где в DOM попадает сырой URL.
|
||||
|
||||
## v1.45.0 — 2026-07-27 (внешнее ревью v1.44.8: R2-1, R2-2, R2-3)
|
||||
- **Отвергнутое сохранение больше не может испортить рабочий план (R2-1,
|
||||
high).** Файл плана записывался под финальным именем — попутно удаляя вариант
|
||||
с другим расширением — *до* проверки ревизии конфига. Если запись потом
|
||||
отвергалась (конфликт ревизий, валидация, обрыв связи), живой план оказывался
|
||||
уже подменён, а сохранённый конфиг мог ссылаться на удалённый файл. Теперь
|
||||
загрузка идёт в версионированное имя (`<space>.<токен>.<ext>`) и ничего не
|
||||
удаляется; карточка просит бэкенд убрать устаревшие файлы только после
|
||||
принятой записи конфига. Падение между шагами оставляет один осиротевший
|
||||
файл, который подберёт следующая успешная загрузка.
|
||||
- **Подписанные ссылки больше не протухают навсегда на долгоживущих экранах
|
||||
(R2-2).** Бэкенд подписывает не более 200 путей за запрос и молча отбрасывает
|
||||
остальные, а карточка отправляла весь кэш одним вызовом и считала любую
|
||||
запись годной вечно. Начиная с 201-го вложения поздние ссылки переставали
|
||||
обновляться и через 24 часа тихо ломались. Теперь запросы бьются на батчи по
|
||||
общему лимиту, записи помнят свой возраст (стареющая ссылка работает, пока
|
||||
едет замена, протухшая не отдаётся вовсе), а кэш чистится до ссылок, на
|
||||
которые конфиг всё ещё ссылается.
|
||||
- **Климат комнат считается один раз на обновление, а не на каждую комнату
|
||||
(R2-3).** Каждая комната запрашивала температуру и влажность по отдельности,
|
||||
и каждый запрос заново обходил весь реестр сущностей: 60 комнат и 2000
|
||||
сущностей — это ~120 обходов на рендер, целый кадр на метаданные, которые не
|
||||
менялись. Теперь один проход строит карту по всем зонам с привязкой к снимку
|
||||
Home Assistant: свежие состояния видны всегда, а посторонние перерисовки не
|
||||
стоят ничего. Замер в смоуке: 133 обхода реестра на обновление до, 2 после —
|
||||
и число больше не растёт с числом комнат.
|
||||
- `smoke_ux_fixes` писал скриншот по жёстко зашитому пути `/tmp` и не запускался
|
||||
на Windows — теперь берёт временную папку у ОС.
|
||||
- Новые тесты: `smoke_plan_upload_reject` (чистка только после принятого
|
||||
сохранения), `smoke_sign_cap` (201 ссылка, батчи, чистка, срок годности),
|
||||
`smoke_climate_once` (число обходов не растёт с числом комнат), плюс
|
||||
backend-покрытие версионированных имён и юнит-тесты новых хелперов.
|
||||
|
||||
## 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
@@ -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).
|
||||
|
||||
+27
-1
@@ -33,7 +33,7 @@ Editors are admin-only tools and must never leak interactions into View
|
||||
|---|---|---|
|
||||
| J1 | "Show the whole home and what's happening right now" — live spatial overview: device states, room fills (light/temp/LQI), values, multi-floor tabs | **Closed** |
|
||||
| J2 | "Something is wrong — show me *where*" — leak/smoke/gas pulse, open doors/windows, unlocked locks, red dot on devices HA added silently | **Closed** |
|
||||
| J3 | "Let me act on the obvious right from the plan" — tap-to-toggle for safe domains, info cards, guarded lock action (explicit button only, never a plan tap) | **Closed** |
|
||||
| J3 | "Let me act on the obvious right from the plan" — tap-to-toggle for safe domains, info cards, guarded lock action | **Closed** |
|
||||
| J4 | "From zero to a working plan in one evening, no Inkscape/YAML" — image/PDF/draw, floors-import wizard, room polygons bound to areas, curated auto-placement, editable icon rules | **Closed**; onboarding polish is *partial* (no registry-driven room suggestions) |
|
||||
| J5 | "Room climate at a glance" — per-room temperature/humidity, comfort-range fills, room-card metrics | **Closed** |
|
||||
| J6 | "Keep the plan true as the home evolves" — new-device flag, two editors, drag/resize, merge/split, multi-client live sync, optimistic locking | **Closed** |
|
||||
@@ -52,9 +52,32 @@ Editors are admin-only tools and must never leak interactions into View
|
||||
## Known gaps that fit the mission (build only on owner's request)
|
||||
|
||||
- Person/presence shown in rooms (classic floorplan ask; pure J1).
|
||||
### The lock invariant, stated precisely (review CR-1)
|
||||
|
||||
No lock or alarm panel is ever actuated **by a tap on the plan**: icons, lock
|
||||
badges, `marker.controls[]` and the device card all refuse (`resolveTapAction`
|
||||
+ `TOGGLE_FORBIDDEN_DOMAINS`, `isControllable`, `_cardToggle`). There is exactly
|
||||
**one** sanctioned actuation surface: the labeled Unlock/Lock button inside an
|
||||
opened door card, which additionally confirms before unlocking. That is a
|
||||
product decision (2026-07-22), not an oversight — but it means the invariant is
|
||||
"never by accident", not "never at all". Any new actuation path must either
|
||||
refuse locks or be added to this paragraph.
|
||||
|
||||
- Plan-level "security glance": one badge for "all locked / N open" (J2).
|
||||
- Threshold colouring for room-card metrics (J5).
|
||||
|
||||
## Standing rule: never delete a user's file on an inference
|
||||
|
||||
Fixed with the owner on 2026-07-28, after automatic collection removed two
|
||||
detached floor plans. The component may delete a file only when the user's
|
||||
action says so — replacing a plan, removing an attachment, deleting a device.
|
||||
"Nothing points at this any more" is not such an action: detaching a plan is one
|
||||
click and reversible, and the editor tells the user the file stays.
|
||||
|
||||
The asymmetry is the whole argument. Wasted disk is visible, cheap and
|
||||
reversible; a deleted file is none of those. Where the evidence is weak, keep
|
||||
the file — and if a future version wants to reclaim that space, it asks.
|
||||
|
||||
## Out of scope — never build, point users to the right tool
|
||||
|
||||
- Automations, scenes, scripts, notifications → HA core.
|
||||
@@ -91,6 +114,9 @@ Editors are admin-only tools and must never leak interactions into View
|
||||
unlocked/new device) · safe quick actions · per-room climate · Zigbee mesh
|
||||
health · zero-to-plan GUI onboarding · keeping the plan true over years.
|
||||
|
||||
**Where users are:** Telegram chat https://t.me/ha_houseplan (support, feature
|
||||
signals, screenshots) — treat it as the primary source of field feedback.
|
||||
|
||||
**Pains it removes:** hand-crafted SVG + YAML floorplans · entity-list
|
||||
dashboards that hide *where* things happen · silent device sprawl · accidental
|
||||
toggles of security devices · per-device dashboards that non-technical family
|
||||
|
||||
+9
-6
@@ -5,22 +5,25 @@
|
||||
> 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-28)
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.41.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Version | **v1.47.0** 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) |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). 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.47.0** 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 | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
|
||||
| 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 |
|
||||
|
||||
## Current feature surface (since the 2026-07-17 snapshot)
|
||||
|
||||
+184
@@ -20,6 +20,59 @@
|
||||
bundle. Sanity ritual: break one invariant on purpose (e.g. remove the
|
||||
kiosk editor guard) and confirm the matching smoke goes red [auto: CI job "smoke"]
|
||||
|
||||
- [ ] Room gear discoverability (v1.43.3, user feedback): in the Plan editor
|
||||
every room card carries a pill button "⚙ Room" of a FIXED readable size
|
||||
(independent of the card font) — including rooms without a name; it opens
|
||||
Room settings [auto: smoke_feedback_v2]
|
||||
- [ ] Metrics readability (v1.43.3): the metrics line is 0.75 of the room name
|
||||
(was 0.62 — unreadable on tablets); per-room sliders still apply on top [auto: smoke_feedback_v2]
|
||||
- [ ] Touch tooltips, take two (v1.43.3): a hover tooltip never appears after
|
||||
ANY touch/pen pointer event, even if the browser claims `hover: hover`
|
||||
(stylus, paired mouse, vendor skins) [auto: smoke_feedback_v2]
|
||||
|
||||
- [ ] Light-source flag (v1.44.0, user feedback): a smart SWITCH driving dumb
|
||||
fixtures glows in the "Light sources" fill once "This device is a light
|
||||
source" is ticked (its own entity or the lights bound under "Controls");
|
||||
unticked devices without a light entity never glow [auto: smoke_glow]
|
||||
- [ ] Device card controls (v1.44.0): the device card opens with its
|
||||
controllable entities FIRST — toggles right there (≥30 px tap targets),
|
||||
cover/lock/climate open HA more-info; model, links and manuals moved
|
||||
below; config/diagnostic entities are not listed; locks never toggle from
|
||||
the card [auto: smoke_card_controls]
|
||||
|
||||
- [ ] Lock invariant, all paths (v1.44.2, review CR-1): icon tap, controls[],
|
||||
device card and _cardToggle refuse locks/alarm panels entirely; the door
|
||||
card's Unlock asks for confirmation, Lock does not [auto: smoke_lock_invariant]
|
||||
- [ ] Attachment migration is transactional (v1.44.2, review CR-2/CR-3):
|
||||
rebinding COPIES files, saves the config, and only then deletes the old
|
||||
folder; a rejected save leaves the old files and urls intact; a name
|
||||
collision in the destination gets a unique name (the pre-existing file is
|
||||
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:
|
||||
@@ -181,6 +234,137 @@ 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]
|
||||
- [ ] Every editor option is storable (v1.45.3, issue #3): set a sensor to
|
||||
"value instead of an icon" and save — no validation error, the value shows
|
||||
on the plan after a reload. Same for each tap action and each fill mode
|
||||
[auto: backend test_every_display_mode_the_editor_offers_is_accepted and
|
||||
neighbours, test_a_marker_showing_its_value_can_be_saved]
|
||||
- [ ] Re-attaching a detached plan (v1.47.0): detach a plan, save, RELOAD THE
|
||||
PAGE, open space settings → "Already uploaded" → the image is listed with
|
||||
its size and no "in use" note → attach it → it renders. The one a space
|
||||
uses shows that space and cannot be deleted; a free one can, with a
|
||||
confirm, and disappears from the list
|
||||
[auto: smoke_saved_plans + backend test_stored_plans_can_be_listed_and_deleted_on_request]
|
||||
- [ ] Detaching a plan keeps the file (v1.46.6): switch a space to "draw" and
|
||||
SAVE — the image is still in `config/houseplan/plans/` right afterwards,
|
||||
and after a restart, and can be re-attached. Deleting the space keeps it
|
||||
too. Replacing a plan still removes the one it replaced, immediately.
|
||||
Check straight after the save: the earlier bug deleted the file at that
|
||||
moment, while every scheduled-pass test passed
|
||||
[auto: unit: test_plan_collection_matrix, test_attachment_collection_matrix,
|
||||
backend test_detaching_a_plan_keeps_the_file]
|
||||
- [ ] Rebinding a device does not eat its manuals (v1.46.5): attach two files to
|
||||
a device, rebind it to another HA device — both are readable afterwards.
|
||||
If a copy failed, the file it failed on is still there rather than deleted
|
||||
with the folder [auto: backend test_files_cleanup_keeps_referenced_files]
|
||||
- [ ] Nothing accumulates on an idle instance (v1.46.2/v1.46.3, HP-1461-01,
|
||||
HP-1462-01): attach a file, cancel the dialog, and do not save anything
|
||||
else — the file is gone after a restart AND after the daily pass, while
|
||||
every file the configuration still references is untouched. Seed the
|
||||
strays AFTER the last save, or `config/set` collects them and the check
|
||||
proves nothing
|
||||
[auto: backend test_startup_sweep_collects_what_no_commit_will,
|
||||
test_daily_sweep_callback_collects_too, test_sweep_and_a_config_write_do_not_race]
|
||||
- [ ] A drag wins over a concurrent remote move (v1.46.2, HP-1461-02): drag an
|
||||
icon and, while the save is still in flight, have another window move a
|
||||
different icon — your icon stays where you put it and the other one
|
||||
updates [auto: smoke_layout_sync]
|
||||
- [ ] Concurrent uploads of one name (v1.46.1, HP-1460-01): attach the same
|
||||
file from two browser tabs at once — two attachments, two sets of bytes,
|
||||
neither lost. A file whose name is at the length limit still downloads
|
||||
[auto: unit: test_reserve_filename_is_safe_under_concurrency and neighbours]
|
||||
- [ ] No temporary files survive (v1.46.1, HP-1460-02): abort a large upload
|
||||
mid-transfer, send two files in one request, make promotion fail — in each
|
||||
case the files folder holds no `.upload-*`. An old one is swept at startup
|
||||
[auto: backend test_upload_leaves_no_temporary_behind + unit: sweep_upload_temps]
|
||||
- [ ] Two full cards agree on positions (v1.46.1, HP-1460-03): open the plan in
|
||||
two windows, drag an icon in one — it moves in the other without a reload;
|
||||
a drag in progress in the second window is not thrown away
|
||||
[auto: smoke_layout_sync]
|
||||
- [ ] Uploaded SVG is inert as a document (v1.46.0, HP-1454-01): open a plan's
|
||||
signed url directly in a tab — a `<script>` inside it must not run and must
|
||||
not reach the HA session's localStorage; the same plan still renders in the
|
||||
card. PDFs still open in the browser viewer
|
||||
[auto: smoke_svg_sandbox + backend test_uploaded_svg_is_sandboxed_and_a_pdf_is_not]
|
||||
- [ ] An attachment never overwrites another (v1.46.0, HP-1454-02): attach a file,
|
||||
cancel the dialog — the previously stored file is byte-identical. Attach
|
||||
`manual.pdf` to two NEW icons — two independent files. A cancelled upload is
|
||||
gone an hour later
|
||||
[auto: backend test_upload_never_overwrites_an_existing_attachment + unit: collect_attachments]
|
||||
- [ ] Two quick edits both survive (v1.46.0, HP-1454-03): with a slow connection,
|
||||
make an edit and another one before the first save answers — both are in the
|
||||
stored config, only one write is ever in flight, and no conflict toast fires
|
||||
[auto: smoke_config_writer]
|
||||
- [ ] Open boundaries follow geometry (v1.46.0, HP-1454-04): change a space's
|
||||
aspect or drag a room vertex — the open boundary and the light through it
|
||||
move with the walls, without a reload [auto: smoke via model-identity key]
|
||||
- [ ] Inner limits (v1.46.0, HP-1454-05): max and max+1 for polygon points,
|
||||
open_to, controls, pdfs, text and url lengths; an oversized config as a
|
||||
whole is refused with `too_large`
|
||||
[auto: unit: test_inner_collection_limits + backend test_config_write_is_capped_by_total_size]
|
||||
- [ ] Big files stream (v1.46.0, HP-1454-06): upload a ~50 MB manual and download
|
||||
it twice in parallel — HA's memory does not grow by a file per transfer
|
||||
[manual]
|
||||
- [ ] Static card parity (v1.46.0, HP-1454-07): a room whose fill is set to "none"
|
||||
under a space filled by light is transparent on BOTH cards
|
||||
[auto: smoke_render_parity]
|
||||
- [ ] Layout reaches the static card (v1.46.0, HP-1454-08): drag an icon on the
|
||||
full card — a static card on the same dashboard moves it too, with no
|
||||
config write and no reload
|
||||
[auto: backend test_layout_keeps_its_revision_and_announces_changes + manual]
|
||||
- [ ] Repair issues are not immortal (v1.46.0, HP-1454-09): create a missing-plan
|
||||
warning, then delete the space — the warning disappears [manual]
|
||||
- [ ] A path the backend cannot sign does not become a request loop (v1.45.4,
|
||||
review R5-1): when `content/sign` answers successfully but omits a path,
|
||||
the card backs that path off individually and keeps the urls it did get;
|
||||
a re-render asks only for what is still missing, and only after the wait
|
||||
[auto: unit: signing.test + backend test_signing_one_path_may_fail_without_failing_the_request]
|
||||
- [ ] Signing does not amplify on a bad connection (v1.45.2, review R4-2): with
|
||||
the WebSocket slow or refusing, the card issues ONE sign request per url
|
||||
and backs off after a failure instead of asking again on every render; a
|
||||
request that never answers stops blocking retries after 15 s
|
||||
[auto: unit: signing.test + smoke_space_card_bg]
|
||||
- [ ] A broken plans directory does not fail a save (v1.45.2, review R4-1): make
|
||||
the plans folder unreadable and save the configuration — the save
|
||||
succeeds, the revision is usable, and the next save does not conflict
|
||||
[auto: backend test_a_failing_collector_does_not_undo_an_accepted_save]
|
||||
- [ ] Two editors, one plan (v1.45.1, review R3-1): with the same space open in
|
||||
two tabs, attach a background in each in turn — the plan last saved is the
|
||||
one served, and neither commit deletes the other's file. A rejected upload
|
||||
disappears on a later save, not immediately
|
||||
[auto: backend test_late_commit_of_one_client_never_deletes_another_client_s_plan,
|
||||
test_commit_does_not_collect_another_client_s_uncommitted_upload,
|
||||
test_abandoned_uploads_are_collected_once_old]
|
||||
- [ ] Static card background (v1.45.1, review R3-2): a houseplan-space-card on a
|
||||
dashboard shows the plan image, not an empty stage; the browser never
|
||||
requests the unsigned path and Home Assistant logs no failed login. A
|
||||
failed signing request is retried on the next render
|
||||
[auto: smoke_space_card_bg]
|
||||
- [ ] Rejected save leaves the plan intact (v1.45.0, review R2-1): attach a new
|
||||
background, make the config write fail (a second tab saving first is
|
||||
enough) — the previously stored plan is still served, with the same or a
|
||||
different extension; after a successful save the old files are gone
|
||||
[auto: smoke_plan_upload_reject + backend test_plan_upload_does_not_touch_the_previous_file]
|
||||
- [ ] Signature cache on a wall tablet (v1.45.0, review R2-2): with more than
|
||||
200 signed urls every one of them is refreshed (batched), entries for
|
||||
files no longer in the config are dropped, an expired signature is never
|
||||
served and an aging one keeps working while its replacement arrives
|
||||
[auto: smoke_sign_cap]
|
||||
- [ ] Climate cost does not grow with rooms (v1.45.0, review R2-3): on a plan
|
||||
with dozens of rooms an unrelated HA state update triggers ONE registry
|
||||
pass, repeated renders on the same snapshot trigger none, and a changed
|
||||
sensor value is still visible immediately [auto: smoke_climate_once]
|
||||
- [ ] 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]
|
||||
|
||||
Generated
+7
-38
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.41.2",
|
||||
"version": "1.45.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.41.2",
|
||||
"version": "1.45.4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3",
|
||||
@@ -817,16 +817,6 @@
|
||||
"splaytree-ts": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/randombytes": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
@@ -894,35 +884,14 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serialize-javascript": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
|
||||
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
|
||||
"version": "7.0.7",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.7.tgz",
|
||||
"integrity": "sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"randombytes": "^2.1.0"
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/smob": {
|
||||
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.43.2",
|
||||
"version": "1.47.0",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -8,7 +8,8 @@
|
||||
"build": "tsc --noEmit && rollup -c",
|
||||
"watch": "rollup -c --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/*.test.mjs"
|
||||
"test": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/*.test.mjs",
|
||||
"inventory": "node scripts/inventory.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
@@ -24,5 +25,8 @@
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3",
|
||||
"polyclip-ts": "^0.16.8"
|
||||
},
|
||||
"overrides": {
|
||||
"serialize-javascript": "^7.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Current test inventory, printed on demand.
|
||||
//
|
||||
// docs/STATUS.md used to carry these numbers inline; they went stale within a
|
||||
// couple of releases while the version line next to them was kept current,
|
||||
// which is worse than no number at all — a maintainer reading the snapshot
|
||||
// underestimates the coverage that exists (review R5-2). The counts live here
|
||||
// now, one command away, and STATUS.md describes the layers instead.
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
|
||||
const count = (dir, match, re) =>
|
||||
readdirSync(dir)
|
||||
.filter((f) => match.test(f))
|
||||
.reduce((n, f) => n + (readFileSync(`${dir}/${f}`, 'utf8').match(re) || []).length, 0);
|
||||
|
||||
const files = (dir, match) => readdirSync(dir).filter((f) => match.test(f)).length;
|
||||
|
||||
const rows = [
|
||||
['frontend unit (node:test)', count('test', /\.test\.mjs$/, /^test\(/gm)],
|
||||
['pure backend (pytest, no HA)', count('tests_backend', /^test_validation\.py$/, /^def test_/gm)],
|
||||
['HA-harness backend (CI, py3.13)', count('tests_backend', /^test_ha_.*\.py$/, /^async def test_|^def test_/gm)],
|
||||
['browser smokes (headless chromium)', files('demo', /^smoke_.*\.mjs$/)],
|
||||
];
|
||||
const w = Math.max(...rows.map(([n]) => n.length));
|
||||
for (const [name, n] of rows) console.log(`${name.padEnd(w)} ${n}`);
|
||||
+10
-4
@@ -47,11 +47,17 @@ async function fetchFresh(hass: any): Promise<HpConfigSnapshot> {
|
||||
};
|
||||
if (!subscribed && hass.connection?.subscribeEvents) {
|
||||
subscribed = true;
|
||||
const invalidate = () => {
|
||||
cache = null; // invalidate; listeners reload
|
||||
listeners.forEach((l) => l());
|
||||
};
|
||||
try {
|
||||
await hass.connection.subscribeEvents(() => {
|
||||
cache = null; // invalidate; listeners reload
|
||||
listeners.forEach((l) => l());
|
||||
}, 'houseplan_config_updated');
|
||||
await hass.connection.subscribeEvents(invalidate, 'houseplan_config_updated');
|
||||
// Layout is separate state: dragging an icon on the full card writes only
|
||||
// the layout, so a static card on the same dashboard kept showing the old
|
||||
// position until the config changed or the page was reloaded — possibly
|
||||
// forever on a wall tablet (HP-1454-08).
|
||||
await hass.connection.subscribeEvents(invalidate, 'houseplan_layout_updated');
|
||||
} catch {
|
||||
subscribed = false;
|
||||
}
|
||||
|
||||
+102
-2
@@ -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,106 @@ 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 interface AreaClimate { temp: number | null; hum: number | null }
|
||||
|
||||
/**
|
||||
* Climate for EVERY area in one registry pass (review R2-3).
|
||||
*
|
||||
* The per-area version below rescanned the whole registry for each room and
|
||||
* each measurement: with 60 rooms and 2000 entities that is 120 traversals per
|
||||
* render — an entire frame spent re-reading metadata that did not change. The
|
||||
* caller computes this map once per `hass` snapshot and looks rooms up in O(1).
|
||||
*/
|
||||
export function areaClimateMap(
|
||||
hass: any, rules?: CompiledIconRule[],
|
||||
): Map<string, AreaClimate> {
|
||||
const out = new Map<string, AreaClimate>();
|
||||
if (!hass?.entities) return out;
|
||||
// area -> device (or lone entity) -> the entities that belong to it
|
||||
const byArea = new Map<string, 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 area = reg.area_id || dev?.area_id || null;
|
||||
if (!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/...
|
||||
let groups = byArea.get(area);
|
||||
if (!groups) { groups = new Map(); byArea.set(area, groups); }
|
||||
const key = reg.device_id || eid;
|
||||
let g = groups.get(key);
|
||||
if (!g) {
|
||||
const st = hass.states?.[eid];
|
||||
g = {
|
||||
name: (dev ? dev.name_by_user || dev.name : reg.name || st?.attributes?.friendly_name || eid) || eid,
|
||||
model: dev?.model,
|
||||
ents: [],
|
||||
};
|
||||
groups.set(key, g);
|
||||
}
|
||||
g.ents.push(eid);
|
||||
}
|
||||
for (const [area, groups] of byArea) {
|
||||
const temps: number[] = [];
|
||||
const hums: number[] = [];
|
||||
for (const g of groups.values()) {
|
||||
const icon = resolveIcon(hass, g.name, g.model, g.ents, rules);
|
||||
const air = icon === 'mdi:thermometer' || icon === 'mdi:air-filter';
|
||||
if (air) {
|
||||
const t = tempFor(hass, g.ents);
|
||||
if (t != null) temps.push(t);
|
||||
}
|
||||
if (air || icon === 'mdi:water-percent') {
|
||||
const h = humFor(hass, g.ents);
|
||||
if (h != null) hums.push(h);
|
||||
}
|
||||
}
|
||||
if (!temps.length && !hums.length) continue;
|
||||
out.set(area, {
|
||||
temp: temps.length ? Math.round((temps.reduce((a, b) => a + b, 0) / temps.length) * 10) / 10 : null,
|
||||
hum: hums.length ? Math.round(hums.reduce((a, b) => a + b, 0) / hums.length) : null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One area's reading. Convenience wrapper — prefer the map for many areas. */
|
||||
export function areaClimate(
|
||||
hass: any, area: string, kind: 'temp' | 'hum', rules?: CompiledIconRule[],
|
||||
): number | null {
|
||||
if (!area) return null;
|
||||
return areaClimateMap(hass, rules).get(area)?.[kind] ?? null;
|
||||
}
|
||||
|
||||
/** How many of the area's lights are on: {on, total}, or null without lights. */
|
||||
export function areaLightStats(
|
||||
hass: any,
|
||||
|
||||
+558
-99
File diff suppressed because it is too large
Load Diff
+17
-2
@@ -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",
|
||||
@@ -323,5 +322,21 @@
|
||||
"room.name_scale": "Room name size",
|
||||
"room.label_scale": "Metrics size",
|
||||
"preview.room_name": "Living room",
|
||||
"toast.cfg_reload_failed": "Could not reload the plan from the server: {err}"
|
||||
"toast.cfg_reload_failed": "Could not reload the plan from the server: {err}",
|
||||
"room.settings_short": "Room",
|
||||
"room.unnamed": "Unnamed room",
|
||||
"marker.is_light": "This device is a light source",
|
||||
"marker.is_light_tip": "Makes the icon glow in the “Light sources” fill even without a light entity — for a smart switch driving ordinary fixtures. The glow follows the switch (or the lights bound above).",
|
||||
"confirm.unlock": "Unlock “{name}”?",
|
||||
"toast.files_migrate_failed": "Attachments could not be moved to the new binding, links keep pointing at the old files: {err}",
|
||||
"space.pick_saved": "Already uploaded",
|
||||
"space.pick_saved_hint": "Plans stored on the server, including ones you detached earlier",
|
||||
"space.no_saved": "No plans stored on the server yet.",
|
||||
"space.loading": "Loading…",
|
||||
"space.used_by": "in use: {list}",
|
||||
"space.in_use": "A space still uses this plan — detach it first",
|
||||
"btn.use": "Use",
|
||||
"confirm.delete_plan": "Delete the plan file \"{name}\" from the server? This cannot be undone.",
|
||||
"toast.plans_list_failed": "Could not list the stored plans: {err}",
|
||||
"toast.plan_delete_failed": "Could not delete the plan: {err}"
|
||||
}
|
||||
|
||||
+17
-2
@@ -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": "Модель",
|
||||
@@ -323,5 +322,21 @@
|
||||
"room.name_scale": "Размер названия",
|
||||
"room.label_scale": "Размер подписей",
|
||||
"preview.room_name": "Гостиная",
|
||||
"toast.cfg_reload_failed": "Не удалось перечитать план с сервера: {err}"
|
||||
"toast.cfg_reload_failed": "Не удалось перечитать план с сервера: {err}",
|
||||
"room.settings_short": "Комната",
|
||||
"room.unnamed": "Комната без имени",
|
||||
"marker.is_light": "Это устройство — источник света",
|
||||
"marker.is_light_tip": "Даёт ореол в заливке «Свет по источникам» даже без light-сущности — для умного выключателя с обычными светильниками. Ореол следует за выключателем (или за привязанными выше лампами).",
|
||||
"confirm.unlock": "Открыть замок «{name}»?",
|
||||
"toast.files_migrate_failed": "Не удалось перенести вложения к новой привязке, ссылки остались на старые файлы: {err}",
|
||||
"space.pick_saved": "Уже загруженные",
|
||||
"space.pick_saved_hint": "Планы, сохранённые на сервере, включая отцеплённые ранее",
|
||||
"space.no_saved": "На сервере пока нет сохранённых планов.",
|
||||
"space.loading": "Загрузка…",
|
||||
"space.used_by": "используется: {list}",
|
||||
"space.in_use": "План используется пространством — сначала отцепите его",
|
||||
"btn.use": "Выбрать",
|
||||
"confirm.delete_plan": "Удалить файл плана «{name}» с сервера? Действие необратимо.",
|
||||
"toast.plans_list_failed": "Не удалось получить список планов: {err}",
|
||||
"toast.plan_delete_failed": "Не удалось удалить план: {err}"
|
||||
}
|
||||
|
||||
+78
-2
@@ -531,6 +531,27 @@ export function safeUrl(url: string | null | undefined): string | null {
|
||||
export type TapAction = 'info' | 'more-info' | 'toggle';
|
||||
|
||||
/** Domains a card-wide `tap_action: toggle` may toggle (accidental-tap safe). */
|
||||
/**
|
||||
* The option lists the editors offer, in one place — and the reason they are
|
||||
* here rather than inline in the templates.
|
||||
*
|
||||
* `display` gained 'value' in v1.26.0 ("show the measurement instead of the
|
||||
* icon") but the backend schema still only accepted badge/ripple/icon_ripple,
|
||||
* so saving any marker configured that way was rejected outright — and since
|
||||
* one bad marker fails the whole config write, the plan could not be saved at
|
||||
* all. Shipped 2026-07-21, found by a user on 2026-07-27: six days, and only
|
||||
* because they pasted the error text. Nothing in the suite could have caught
|
||||
* it, because the option list and the schema that stores it were written in
|
||||
* two languages and never compared. They are exported here so a backend test
|
||||
* can read them and assert the schema accepts every value a user can pick.
|
||||
* Adding an option here and forgetting the schema now fails the test suite.
|
||||
*/
|
||||
export const DISPLAY_MODES = ['badge', 'ripple', 'icon_ripple', 'value'] as const;
|
||||
export const TAP_ACTIONS = ['info', 'more-info', 'toggle'] as const;
|
||||
/** Space-level fill: 'glow' is a whole-space light model, not a per-room one. */
|
||||
export const SPACE_FILL_MODES = ['none', 'lqi', 'light', 'temp', 'glow'] as const;
|
||||
export const ROOM_FILL_MODES = ['none', 'lqi', 'light', 'temp'] as const;
|
||||
|
||||
export const TOGGLE_SAFE_DOMAINS = new Set(['light', 'switch', 'fan', 'humidifier']);
|
||||
|
||||
/**
|
||||
@@ -1036,6 +1057,48 @@ export function outlineWithout(poly: number[][], cuts: number[][], eps = 1e-6):
|
||||
* authenticated content endpoint (audit B1). Applied on READ, so stored
|
||||
* configs keep working without a migration.
|
||||
*/
|
||||
/**
|
||||
* How many paths one `houseplan/content/sign` call may carry. The backend caps
|
||||
* the request at the same number and silently ignores the rest, so a client
|
||||
* that sends more gets a partial answer with no way to tell which paths were
|
||||
* dropped — on a wall tablet those entries then expire for good (review R2-2).
|
||||
* Keep in sync with MAX_SIGN_PATHS in custom_components/houseplan/const.py.
|
||||
*/
|
||||
export const MAX_SIGN_PATHS = 200;
|
||||
|
||||
/** A signature is valid for 24 h; refresh once two thirds of it is gone. */
|
||||
export const SIGN_TTL_MS = 24 * 3600 * 1000;
|
||||
export const SIGN_REFRESH_MS = 16 * 3600 * 1000;
|
||||
|
||||
/** Split a list into chunks of at most `size` (used for signing batches). */
|
||||
export function chunk<T>(items: T[], size: number): T[][] {
|
||||
const n = Math.max(1, Math.floor(size));
|
||||
const out: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += n) out.push(items.slice(i, i + n));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every content url the given config still refers to, normalised through
|
||||
* `contentUrl`. The signature cache is pruned to this set: without it the cache
|
||||
* only grows — replaced plans and deleted attachments keep their entries, and
|
||||
* the total can cross the per-request cap even when the live config is small.
|
||||
*/
|
||||
export function referencedContentUrls(cfg: any): Set<string> {
|
||||
const out = new Set<string>();
|
||||
const add = (u: unknown) => {
|
||||
if (typeof u !== 'string' || !u) return;
|
||||
const c = contentUrl(u);
|
||||
if (c.startsWith('/api/houseplan/content/')) out.add(c);
|
||||
};
|
||||
for (const sp of cfg?.spaces || []) {
|
||||
add(sp?.plan_url);
|
||||
for (const m of sp?.markers || []) for (const p of m?.pdfs || []) add(p?.url);
|
||||
}
|
||||
for (const m of cfg?.markers || []) for (const p of m?.pdfs || []) add(p?.url);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function contentUrl(url: string | null | undefined): string {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('/houseplan_files/plans/')) {
|
||||
@@ -1070,12 +1133,25 @@ export function roomFillModeOf(
|
||||
* server moves /files/<oldId>/ to /files/<newId>/, the urls must follow.
|
||||
*/
|
||||
export function migratePdfUrls<T extends { url: string }>(
|
||||
pdfs: T[], oldId: string, newId: string,
|
||||
pdfs: T[], oldId: string, newId: string, mapping?: Record<string, string>,
|
||||
): T[] {
|
||||
if (!oldId || !newId || oldId === newId) return pdfs;
|
||||
const from = '/files/' + oldId + '/';
|
||||
const to = '/files/' + newId + '/';
|
||||
return pdfs.map((p) => (p.url.includes(from) ? { ...p, url: p.url.split(from).join(to) } : p));
|
||||
return pdfs.map((p) => {
|
||||
if (!p.url.includes(from)) return p;
|
||||
const tail = p.url.split(from)[1] || '';
|
||||
const [name, query] = [tail.split('?')[0], tail.includes('?') ? '?' + tail.split('?')[1] : ''];
|
||||
if (mapping) {
|
||||
// review CR-3: rewrite ONLY files the server confirmed it copied, and use
|
||||
// the name it actually wrote (collisions get a unique name). A url that
|
||||
// was not copied keeps pointing at the still-existing old folder.
|
||||
const dst = mapping[decodeURIComponent(name)] ?? mapping[name];
|
||||
if (!dst) return p;
|
||||
return { ...p, url: p.url.split(from + name)[0] + to + encodeURIComponent(dst) + query };
|
||||
}
|
||||
return { ...p, url: p.url.split(from).join(to) };
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- kiosk gestures ----------------
|
||||
|
||||
@@ -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' },
|
||||
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
import { contentUrl, chunk, MAX_SIGN_PATHS, SIGN_TTL_MS, SIGN_REFRESH_MS } from './logic';
|
||||
|
||||
/** A request older than this is presumed lost, and the url may be asked again. */
|
||||
export const SIGN_INFLIGHT_MS = 15000;
|
||||
/** After a failure, wait before retrying; doubles up to the cap. */
|
||||
export const SIGN_BACKOFF_MIN_MS = 2000;
|
||||
export const SIGN_BACKOFF_MAX_MS = 60000;
|
||||
|
||||
/**
|
||||
* Signed urls for the authenticated content endpoint, shared by both cards.
|
||||
*
|
||||
* A browser cannot authenticate an `<image href>` or an `<a href>`: Home
|
||||
* Assistant takes a Bearer header or an `authSig` signed path, and an element
|
||||
* sends neither. So whatever is about to be displayed has to be signed first.
|
||||
*
|
||||
* This used to be implemented twice — and the second copy (houseplan-space-card)
|
||||
* signed correctly but never handed the result to its renderer, so the plan
|
||||
* background asked for the raw protected url and got a 401 on every render
|
||||
* (review R3-2). One implementation, one set of rules:
|
||||
*
|
||||
* - requests are chunked to MAX_SIGN_PATHS, the cap the backend silently
|
||||
* applies (an oversized call comes back partial with no way to tell what was
|
||||
* dropped, and those entries then expire for good);
|
||||
* - every entry carries the time it was issued: past SIGN_REFRESH_MS a
|
||||
* replacement is fetched while the old url keeps rendering, past SIGN_TTL_MS
|
||||
* the entry is dropped rather than served (it would 401 and raise a
|
||||
* failed-login warning for the viewer's own IP);
|
||||
* - a url that is queued or already in flight is not asked for again: renders
|
||||
* are frequent and a slow socket used to turn every one of them into another
|
||||
* `content/sign` call (review R4-2). An in-flight entry expires after
|
||||
* SIGN_INFLIGHT_MS so a promise that never settles cannot block retries
|
||||
* forever, and a failure backs off instead of retrying on the next frame.
|
||||
*/
|
||||
export class ContentSigner {
|
||||
private signed: Record<string, { url: string; at: number }> = {};
|
||||
/** Waiting for the batch timer to fire. */
|
||||
private queued = new Set<string>();
|
||||
/** Sent and not settled yet: url -> when the request went out. */
|
||||
private inFlight = new Map<string, number>();
|
||||
/** After a failure: when this url may be asked again, and the current delay. */
|
||||
private retry = new Map<string, { notBefore: number; delay: number }>();
|
||||
private batchTimer?: ReturnType<typeof setTimeout>;
|
||||
private resignTimer?: ReturnType<typeof setInterval>;
|
||||
private disposed = false;
|
||||
|
||||
/**
|
||||
* @param onUpdate schedule a re-render (a signature arriving changes the DOM)
|
||||
* @param now injectable clock — the tests need to age a signature
|
||||
*/
|
||||
constructor(private onUpdate: () => void, private now: () => number = () => Date.now()) {}
|
||||
|
||||
/** Start the periodic re-sign. `referenced` prunes the cache on each tick. */
|
||||
start(hass: () => any, referenced: () => Set<string>): void {
|
||||
this.disposed = false; // an element can be reconnected after a disconnect
|
||||
this.stopTimer();
|
||||
this.resignTimer = setInterval(() => this.resign(hass(), referenced()), SIGN_REFRESH_MS / 2);
|
||||
}
|
||||
|
||||
/** Release every timer; the cache survives a reconnect, the timers must not. */
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.stopTimer();
|
||||
clearTimeout(this.batchTimer);
|
||||
this.queued.clear();
|
||||
this.inFlight.clear();
|
||||
}
|
||||
|
||||
private stopTimer(): void {
|
||||
if (this.resignTimer !== undefined) clearInterval(this.resignTimer);
|
||||
this.resignTimer = undefined;
|
||||
}
|
||||
|
||||
/** The url to put in the DOM: a signature we hold and still trust, else ''. */
|
||||
display(hass: any, url: string | null | undefined): string {
|
||||
const u = contentUrl(url);
|
||||
if (!u.startsWith('/api/houseplan/content/')) return u;
|
||||
const hit = this.signed[u];
|
||||
const age = hit ? this.now() - hit.at : Infinity;
|
||||
if (age < SIGN_REFRESH_MS) return hit.url;
|
||||
if (age < SIGN_TTL_MS) {
|
||||
// aging but still valid: keep showing it while a fresh one is fetched
|
||||
this.request(hass, u);
|
||||
return hit.url;
|
||||
}
|
||||
if (hit) delete this.signed[u];
|
||||
this.request(hass, u);
|
||||
// Empty, NOT the plain path: an unsigned request to a `requires_auth` view
|
||||
// returns 401 and Home Assistant raises a "failed login attempt".
|
||||
return '';
|
||||
}
|
||||
|
||||
private request(hass: any, url: string): void {
|
||||
if (!hass?.callWS || this.queued.has(url)) return;
|
||||
const now = this.now();
|
||||
const sent = this.inFlight.get(url);
|
||||
if (sent !== undefined && now - sent < SIGN_INFLIGHT_MS) return; // already asking
|
||||
const back = this.retry.get(url);
|
||||
if (back && now < back.notBefore) return; // still backing off
|
||||
this.queued.add(url);
|
||||
clearTimeout(this.batchTimer);
|
||||
// batch: switching space asks for several urls in the same tick
|
||||
this.batchTimer = setTimeout(() => {
|
||||
const paths = [...this.queued];
|
||||
this.queued.clear();
|
||||
this.sign(hass, paths);
|
||||
}, 30);
|
||||
}
|
||||
|
||||
private sign(hass: any, paths: string[]): void {
|
||||
if (!paths.length || !hass?.callWS) return;
|
||||
for (const batch of chunk(paths, MAX_SIGN_PATHS)) {
|
||||
const sentAt = this.now();
|
||||
for (const p of batch) this.inFlight.set(p, sentAt);
|
||||
hass
|
||||
.callWS({ type: 'houseplan/content/sign', paths: batch })
|
||||
.then((r: any) => {
|
||||
if (this.disposed) return;
|
||||
// A successful call does NOT mean every path was signed: the backend
|
||||
// skips a path it cannot sign, logs it and still answers `{urls: …}`
|
||||
// with the rest. Treating the whole batch as done then cleared the
|
||||
// backoff for the missing ones, so every later render asked again —
|
||||
// the very amplification the backoff exists to stop (review R5-1).
|
||||
const at = this.now();
|
||||
const next = { ...this.signed };
|
||||
let accepted = 0;
|
||||
for (const p of batch) {
|
||||
const url = r?.urls?.[p]; // only keys we asked for
|
||||
if (typeof url === 'string' && url) {
|
||||
next[p] = { url, at };
|
||||
this.retry.delete(p);
|
||||
accepted++;
|
||||
} else {
|
||||
this.backOff(p);
|
||||
}
|
||||
}
|
||||
if (!accepted) return;
|
||||
this.signed = next;
|
||||
this.onUpdate();
|
||||
})
|
||||
.catch(() => {
|
||||
// back off rather than retry on the very next frame: a socket that
|
||||
// is refusing sign requests would otherwise be hammered per render
|
||||
for (const p of batch) this.backOff(p);
|
||||
})
|
||||
.finally(() => {
|
||||
// release only our own attempt: a later one may have superseded it
|
||||
for (const p of batch) if (this.inFlight.get(p) === sentAt) this.inFlight.delete(p);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Next attempt for this url waits, and each failure waits twice as long. */
|
||||
private backOff(url: string): void {
|
||||
const prev = this.retry.get(url)?.delay || 0;
|
||||
const delay = Math.min(SIGN_BACKOFF_MAX_MS, prev ? prev * 2 : SIGN_BACKOFF_MIN_MS);
|
||||
this.retry.set(url, { notBefore: this.now() + delay, delay });
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-sign what is still in use. A wall tablet outlives a signature, and an
|
||||
* entry for a plan replaced months ago must not consume a slot in the capped
|
||||
* request — so prune to the urls the live config still references.
|
||||
*/
|
||||
resign(hass: any, referenced: Set<string>): void {
|
||||
const now = this.now();
|
||||
const kept: Record<string, { url: string; at: number }> = {};
|
||||
for (const [k, v] of Object.entries(this.signed)) {
|
||||
if (referenced.has(k) && now - v.at < SIGN_TTL_MS) kept[k] = v;
|
||||
}
|
||||
this.signed = kept;
|
||||
this.retry.clear(); // a scheduled refresh is a fresh chance for everything
|
||||
this.sign(hass, Object.keys(kept));
|
||||
}
|
||||
|
||||
/** Test/debug view of the cache. */
|
||||
get entries(): Record<string, { url: string; at: number }> {
|
||||
return this.signed;
|
||||
}
|
||||
|
||||
/** Test/debug view of what is currently being asked for. */
|
||||
get inFlightUrls(): string[] {
|
||||
return [...this.inFlight.keys()];
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ 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 { ContentSigner } from './signing';
|
||||
import { referencedContentUrls } from './logic';
|
||||
import './space-editor';
|
||||
|
||||
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
|
||||
@@ -73,11 +75,14 @@ class HouseplanSpaceCard extends LitElement {
|
||||
this._snap = null;
|
||||
this.requestUpdate();
|
||||
});
|
||||
// a dashboard on a wall tablet outlives a 24 h signature
|
||||
this._signer.start(() => this.hass, () => this._referenced());
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
this._unsub?.();
|
||||
this._unsub = undefined;
|
||||
this._signer.dispose();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
@@ -121,6 +126,18 @@ class HouseplanSpaceCard extends LitElement {
|
||||
return html`<ha-card><div class="hp-static-error">${msg}</div></ha-card>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same signer as the main card (review R3-2). The previous copy here signed
|
||||
* the url and then threw it away: `getCardSize()` mutated a throwaway model
|
||||
* while `render()` rebuilt its own from the config, so the <image> kept
|
||||
* asking for the raw protected path and got a 401 on every render.
|
||||
*/
|
||||
private _signer = new ContentSigner(() => this.requestUpdate());
|
||||
|
||||
private _referenced(): Set<string> {
|
||||
return referencedContentUrls(this._snap?.config);
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
if (!this._config) return nothing;
|
||||
const cfg = this._snap?.config;
|
||||
@@ -136,6 +153,8 @@ class HouseplanSpaceCard extends LitElement {
|
||||
spaceId,
|
||||
iconSize: this._config.icon_size,
|
||||
lang: this._lang,
|
||||
// resolved at render time: a url baked in earlier would be the unsigned one
|
||||
displayUrl: (raw) => this._signer.display(this.hass, raw),
|
||||
});
|
||||
if (!stage) {
|
||||
return this._errorCard(t(this._lang, 'space_card.not_found', { id: spaceId }));
|
||||
|
||||
@@ -20,6 +20,11 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
area: r.area ?? null,
|
||||
// carried, not dropped: the static card renders from this model too, and
|
||||
// without them it ignored the room-level fill override and drew a room the
|
||||
// full card leaves transparent (HP-1454-07)
|
||||
open_to: r.open_to || undefined,
|
||||
settings: r.settings || undefined,
|
||||
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,
|
||||
|
||||
+19
-8
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { html, svg, nothing, type TemplateResult } from 'lit';
|
||||
import { buildDevices, areaLqi, areaLights, areaTemp } from './devices';
|
||||
import { spaceDisplayOf, roomFillStyle, fillColorsOf } from './logic';
|
||||
import { spaceDisplayOf, roomFillStyle, fillColorsOf, roomFillModeOf } from './logic';
|
||||
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
|
||||
import { t, type Lang } from './i18n';
|
||||
import type { ServerConfig } from './types';
|
||||
@@ -25,6 +25,13 @@ export interface StaticRenderOpts {
|
||||
spaceId: string;
|
||||
iconSize?: number;
|
||||
lang: Lang;
|
||||
/**
|
||||
* Resolve a stored content url to what the DOM may actually request — the
|
||||
* plan lives behind `requires_auth`, so it needs an `authSig` signature.
|
||||
* Returning '' means "not signed yet": the caller must render no <image>
|
||||
* rather than an unsigned one, which would 401 (review R3-2).
|
||||
*/
|
||||
displayUrl?: (raw: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,16 +73,18 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
.map((r) => {
|
||||
let cls = 'room ' + (space.bg ? 'overlay' : 'yard');
|
||||
let style = '';
|
||||
if (disp.showBorders || disp.fill !== 'none') {
|
||||
// tier 3 wins over the space, exactly as on the full card (HP-1454-07)
|
||||
const fill = roomFillModeOf(disp.fill, r);
|
||||
if (disp.showBorders || 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
|
||||
? roomFillStyle(
|
||||
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,
|
||||
fill,
|
||||
fill === 'lqi' ? areaLqi(o.hass, devs, r.area) : null,
|
||||
fill === 'light' ? areaLights(o.hass, devs, r.area) : 'none',
|
||||
fill === 'temp' ? areaTemp(o.hass, devs, r.area) : null,
|
||||
disp.tempMin,
|
||||
disp.tempMax,
|
||||
fillColorsOf(o.cfg?.settings),
|
||||
@@ -118,11 +127,13 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
})
|
||||
: [];
|
||||
|
||||
const bgHref = space.bg ? (o.displayUrl ? o.displayUrl(space.bg.href) : space.bg.href) : '';
|
||||
|
||||
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" />`
|
||||
${bgHref
|
||||
? svg`<image href="${bgHref}" x="${space.bg!.x}" y="${space.bg!.y}" width="${space.bg!.w}" height="${space.bg!.h}" preserveAspectRatio="none" />`
|
||||
: nothing}
|
||||
${roomShapes}
|
||||
</svg>
|
||||
|
||||
+92
-1
@@ -383,6 +383,27 @@ export const cardStyles = css`
|
||||
gap: 0.25em;
|
||||
font-size: calc(1em * var(--rl-name, 1));
|
||||
}
|
||||
.rlgearbtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 3px;
|
||||
padding: 3px 8px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--hp-accent);
|
||||
color: var(--text-primary-color, #fff);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
opacity: 0.92;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.rlgearbtn:hover { opacity: 1; }
|
||||
.rlgearbtn ha-icon { --mdc-icon-size: 14px; display: inline-flex; }
|
||||
.rlgear {
|
||||
--mdc-icon-size: 0.9em;
|
||||
display: inline-flex;
|
||||
@@ -406,7 +427,7 @@ export const cardStyles = css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55em;
|
||||
font-size: calc(0.62em * var(--rl-meta, 1));
|
||||
font-size: calc(0.75em * var(--rl-meta, 1)); /* feedback: 0.62 was unreadable on a tablet */
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
opacity: 0.9;
|
||||
@@ -1041,6 +1062,40 @@ export const cardStyles = css`
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
/* the "already uploaded" picker: a plan is never deleted for being
|
||||
unreferenced, so it has to be findable again */
|
||||
.savedplans {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
margin: 6px 0 2px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--hp-line);
|
||||
border-radius: 8px;
|
||||
background: var(--hp-bg2, rgba(255, 255, 255, 0.03));
|
||||
}
|
||||
.savedplan {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.savedplan.cur { outline: 1px solid var(--hp-accent); border-radius: 6px; }
|
||||
.savedplan img {
|
||||
width: 56px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--hp-line);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
flex: none;
|
||||
}
|
||||
.savedmeta { display: flex; flex-direction: column; min-width: 0; flex: 1; }
|
||||
.savedmeta b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.savedmeta .muted { font-size: 11px; }
|
||||
.savedplan .btn.danger ha-icon { color: #f25a4a; }
|
||||
.savedplan .btn[disabled] { opacity: 0.4; pointer-events: none; }
|
||||
.planprev {
|
||||
max-width: 120px;
|
||||
max-height: 70px;
|
||||
@@ -1194,6 +1249,42 @@ export const cardStyles = css`
|
||||
.pdftag .x:hover {
|
||||
color: #ff7a5c;
|
||||
}
|
||||
.entlist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.entrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--secondary-background-color, rgba(128, 128, 128, 0.12));
|
||||
}
|
||||
.entrow ha-icon { --mdc-icon-size: 20px; color: var(--hp-muted); }
|
||||
.entrow.on ha-icon { color: var(--hp-accent); }
|
||||
.entrow .en { flex: 1; font-size: 13px; }
|
||||
.entrow .ev { font-size: 13px; color: var(--hp-muted); }
|
||||
.entbtn {
|
||||
min-width: 74px;
|
||||
min-height: 32px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--hp-muted);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--hp-txt);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.entbtn.on {
|
||||
background: var(--hp-accent);
|
||||
border-color: var(--hp-accent);
|
||||
color: var(--text-primary-color, #fff);
|
||||
font-weight: 600;
|
||||
}
|
||||
.inforow {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
|
||||
@@ -61,6 +61,12 @@ export interface Marker {
|
||||
controls?: string[] | null;
|
||||
/** Per-source glow radius in cm (glow fill); null = the global default. */
|
||||
glow_radius_cm?: number | null;
|
||||
/**
|
||||
* Treat this marker as a light source in the glow fill even when it has no
|
||||
* light.* entity (a smart switch driving dumb fixtures — field request).
|
||||
* null/undefined = auto: any light.* entity of the device.
|
||||
*/
|
||||
is_light?: boolean | null;
|
||||
}
|
||||
|
||||
/** A door or window: plan geometry (normalized coords), optionally live via entities. */
|
||||
|
||||
+125
-1
@@ -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, areaClimateMap } from '../test-build/devices.js';
|
||||
import { compileIconRules, iconFor } from '../test-build/rules.js';
|
||||
|
||||
/** Minimal fake hass around the pieces buildDevices reads. */
|
||||
@@ -359,3 +359,127 @@ 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);
|
||||
});
|
||||
|
||||
test('areaClimateMap: one registry pass for all areas (review R2-3)', () => {
|
||||
// 60 зон × 2000 сущностей — размер боевой установки из отчёта
|
||||
const devices = {}; const entities = {}; const states = {};
|
||||
for (let a = 0; a < 60; a++) {
|
||||
for (let i = 0; i < 33; i++) {
|
||||
const dev = `d${a}_${i}`;
|
||||
const eid = `sensor.d${a}_${i}_temperature`;
|
||||
devices[dev] = { id: dev, name: `Датчик температуры ${a}.${i}`, area_id: `area${a}` };
|
||||
entities[eid] = { device_id: dev, platform: 'mqtt' };
|
||||
states[eid] = { state: String(20 + a * 0.1), attributes: { device_class: 'temperature', unit_of_measurement: '°C' } };
|
||||
}
|
||||
}
|
||||
// считаем ОБХОДЫ реестра: Object.entries дёргает ownKeys ровно один раз
|
||||
let scans = 0;
|
||||
const traced = new Proxy(entities, { ownKeys(t) { scans++; return Reflect.ownKeys(t); } });
|
||||
const hass = { devices, states, entities: traced };
|
||||
|
||||
|
||||
const map = areaClimateMap(hass);
|
||||
assert.equal(scans, 1, 'реестр обходится один раз на снимок hass');
|
||||
assert.equal(map.size, 60);
|
||||
assert.equal(map.get('area0').temp, 20);
|
||||
assert.equal(map.get('area59').temp, 25.9);
|
||||
assert.equal(map.get('area0').hum, null);
|
||||
assert.equal(map.get('nope'), undefined);
|
||||
|
||||
// старый путь: отдельный обход на каждую комнату и каждую величину
|
||||
scans = 0;
|
||||
for (let a = 0; a < 60; a++) { areaClimate(hass, `area${a}`, 'temp'); areaClimate(hass, `area${a}`, 'hum'); }
|
||||
assert.equal(scans, 120, 'wrapper считает по одной зоне — им нельзя пользоваться в рендере');
|
||||
});
|
||||
|
||||
test('areaClimateMap: температура и влажность живут в одной записи', () => {
|
||||
const hass = {
|
||||
devices: { q: { id: 'q', name: 'Qingping Air Monitor', area_id: 'hall' } },
|
||||
entities: {
|
||||
'sensor.q_t': { device_id: 'q', platform: 'xiaomi' },
|
||||
'sensor.q_h': { device_id: 'q', platform: 'xiaomi' },
|
||||
},
|
||||
states: {
|
||||
'sensor.q_t': { state: '21.4', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
||||
'sensor.q_h': { state: '48', attributes: { device_class: 'humidity', unit_of_measurement: '%' } },
|
||||
},
|
||||
};
|
||||
assert.deepEqual(areaClimateMap(hass).get('hall'), { temp: 21.4, hum: 48 });
|
||||
});
|
||||
|
||||
+44
-1
@@ -12,7 +12,7 @@ import {
|
||||
swipeTarget, clampScale,
|
||||
migratePdfUrls,
|
||||
roomFillModeOf,
|
||||
contentUrl,
|
||||
contentUrl, chunk, referencedContentUrls, MAX_SIGN_PATHS,
|
||||
interiorPoint,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
|
||||
@@ -792,6 +792,22 @@ test('clampScale', () => {
|
||||
assert.equal(clampScale(undefined, 1.5), 1.5);
|
||||
});
|
||||
|
||||
test('migratePdfUrls: only confirmed copies are rewritten (review CR-3)', () => {
|
||||
const pdfs = [
|
||||
{ name: 'a.pdf', url: '/houseplan_files/files/v_old1/a.pdf?v=1' },
|
||||
{ name: 'b.pdf', url: '/houseplan_files/files/v_old1/b.pdf?v=2' },
|
||||
];
|
||||
// сервер скопировал только a.pdf, причём переименовал из-за коллизии
|
||||
// collision names use only characters the content view accepts back in a
|
||||
// request: ' (2)' was sanitised to '_2_' server-side and 404'd (v1.46.0)
|
||||
const out = migratePdfUrls(pdfs, 'v_old1', 'dev99', { 'a.pdf': 'a-2.pdf' });
|
||||
assert.equal(out[0].url, '/houseplan_files/files/dev99/a-2.pdf?v=1');
|
||||
assert.equal(out[1].url, pdfs[1].url, 'нескопированный файл ссылается на старую папку');
|
||||
// пустой маппинг = ничего не переносим
|
||||
assert.deepEqual(migratePdfUrls(pdfs, 'v_old1', 'dev99', {}).map((p) => p.url),
|
||||
pdfs.map((p) => p.url));
|
||||
});
|
||||
|
||||
test('migratePdfUrls: rebinding rewrites file urls', () => {
|
||||
const pdfs = [
|
||||
{ name: 'a.pdf', url: '/houseplan_files/files/v_old1/a.pdf?v=1' },
|
||||
@@ -867,3 +883,30 @@ test('segKey: one wall, one key at any precision (audit G3)', () => {
|
||||
// разные стены — разные ключи
|
||||
assert.notEqual(segKey([0, 0], [1, 1]), segKey([0, 0], [2, 2]));
|
||||
});
|
||||
|
||||
test('chunk / referencedContentUrls: signing batches and cache pruning (review R2-2)', () => {
|
||||
assert.deepEqual(chunk([1, 2, 3, 4, 5], 2), [[1, 2], [3, 4], [5]]);
|
||||
assert.deepEqual(chunk([], 200), []);
|
||||
assert.equal(chunk(new Array(201).fill(0), MAX_SIGN_PATHS).length, 2);
|
||||
assert.deepEqual(chunk([1, 2, 3], 0), [[1], [2], [3]]); // never an infinite loop
|
||||
|
||||
const cfg = {
|
||||
spaces: [
|
||||
{ id: 'f1', plan_url: '/houseplan_files/plans/f1.svg' }, // legacy, rewritten on read
|
||||
{ id: 'f2', plan_url: '/api/houseplan/content/plans/_/f2.abc.png' },
|
||||
{ id: 'f3', plan_url: null },
|
||||
{ id: 'f4', plan_url: '/local/not-ours.png' }, // not our endpoint
|
||||
],
|
||||
markers: [
|
||||
{ id: 'm1', pdfs: [{ url: '/houseplan_files/files/m1/manual.pdf' }, { url: '' }] },
|
||||
{ id: 'm2' },
|
||||
],
|
||||
};
|
||||
assert.deepEqual([...referencedContentUrls(cfg)].sort(), [
|
||||
'/api/houseplan/content/files/m1/manual.pdf',
|
||||
'/api/houseplan/content/plans/_/f1.svg',
|
||||
'/api/houseplan/content/plans/_/f2.abc.png',
|
||||
]);
|
||||
assert.equal(referencedContentUrls(null).size, 0);
|
||||
assert.equal(referencedContentUrls({}).size, 0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ContentSigner, SIGN_INFLIGHT_MS, SIGN_BACKOFF_MIN_MS } from '../test-build/signing.js';
|
||||
import { SIGN_TTL_MS, SIGN_REFRESH_MS } from '../test-build/logic.js';
|
||||
|
||||
const URL_A = '/api/houseplan/content/plans/_/f1.tok.svg';
|
||||
const URL_B = '/api/houseplan/content/files/m1/manual.pdf';
|
||||
const tick = () => new Promise((r) => setTimeout(r, 45)); // > the 30 ms batch timer
|
||||
|
||||
/** hass whose sign call is resolved/rejected by hand. */
|
||||
function makeHass() {
|
||||
const calls = [];
|
||||
const hass = {
|
||||
callWS(m) {
|
||||
let settle;
|
||||
const p = new Promise((res, rej) => { settle = { res, rej }; });
|
||||
calls.push({ paths: m.paths, ...settle });
|
||||
return p;
|
||||
},
|
||||
};
|
||||
return { hass, calls };
|
||||
}
|
||||
|
||||
function signer(now = () => Date.now()) {
|
||||
let updates = 0;
|
||||
const s = new ContentSigner(() => { updates++; }, now);
|
||||
return { s, updates: () => updates };
|
||||
}
|
||||
|
||||
test('R4-2: renders during one unresolved request do not multiply it', async () => {
|
||||
const { hass, calls } = makeHass();
|
||||
const { s } = signer();
|
||||
for (let i = 0; i < 6; i++) assert.equal(s.display(hass, URL_A), '');
|
||||
await tick();
|
||||
assert.equal(calls.length, 1, 'one batched request');
|
||||
// more renders while it is still in flight
|
||||
for (let i = 0; i < 5; i++) s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 1, 'still one: the url is in flight');
|
||||
assert.deepEqual(s.inFlightUrls, [URL_A]);
|
||||
|
||||
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=OK' } });
|
||||
await tick();
|
||||
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=OK');
|
||||
assert.equal(calls.length, 1, 'a resolved signature starts no extra request');
|
||||
assert.deepEqual(s.inFlightUrls, []);
|
||||
});
|
||||
|
||||
test('R4-2: a rejection frees the url but backs off before retrying', async () => {
|
||||
let t = 1_000_000;
|
||||
const { hass, calls } = makeHass();
|
||||
const { s } = signer(() => t);
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
calls[0].rej(new Error('socket'));
|
||||
await tick();
|
||||
assert.deepEqual(s.inFlightUrls, [], 'released, not wedged');
|
||||
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 1, 'the very next render must not retry');
|
||||
|
||||
t += SIGN_BACKOFF_MIN_MS + 1;
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 2, 'retried once the backoff has passed');
|
||||
|
||||
// the second failure waits longer than the first
|
||||
calls[1].rej(new Error('socket'));
|
||||
await tick();
|
||||
t += SIGN_BACKOFF_MIN_MS + 1;
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 2, 'backoff doubled');
|
||||
t += SIGN_BACKOFF_MIN_MS * 2;
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 3);
|
||||
|
||||
calls[2].res({ urls: { [URL_A]: URL_A + '?authSig=OK' } });
|
||||
await tick();
|
||||
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=OK');
|
||||
});
|
||||
|
||||
test('R4-2: a request that never settles stops blocking after the timeout', async () => {
|
||||
let t = 1_000_000;
|
||||
const { hass, calls } = makeHass();
|
||||
const { s } = signer(() => t);
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 1);
|
||||
|
||||
t += SIGN_INFLIGHT_MS - 1;
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 1, 'still presumed in flight');
|
||||
|
||||
t += 2;
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 2, 'presumed lost — asked again');
|
||||
|
||||
// the FIRST promise finally answers: it must not resurrect a stale in-flight
|
||||
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=LATE' } });
|
||||
await tick();
|
||||
assert.deepEqual(s.inFlightUrls, [URL_A], 'only the second attempt is in flight');
|
||||
});
|
||||
|
||||
test('signatures age: fresh, aging, expired', async () => {
|
||||
let t = 1_000_000;
|
||||
const { hass, calls } = makeHass();
|
||||
const { s } = signer(() => t);
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=ONE' } });
|
||||
await tick();
|
||||
|
||||
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=ONE');
|
||||
assert.equal(calls.length, 1, 'a fresh signature asks for nothing');
|
||||
|
||||
t += SIGN_REFRESH_MS + 1; // aging: keep rendering, fetch a replacement
|
||||
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=ONE');
|
||||
await tick();
|
||||
assert.equal(calls.length, 2);
|
||||
|
||||
t += SIGN_TTL_MS; // expired: rendering it would 401
|
||||
assert.equal(s.display(hass, URL_A), '');
|
||||
assert.equal(s.entries[URL_A], undefined, 'the dead entry is dropped');
|
||||
});
|
||||
|
||||
test('a non-content url is passed straight through, nothing is signed', async () => {
|
||||
const { hass, calls } = makeHass();
|
||||
const { s } = signer();
|
||||
assert.equal(s.display(hass, '/local/plan.png'), '/local/plan.png');
|
||||
assert.equal(s.display(hass, ''), '');
|
||||
assert.equal(s.display(hass, null), '');
|
||||
await tick();
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('legacy urls are normalised before signing', async () => {
|
||||
const { hass, calls } = makeHass();
|
||||
const { s } = signer();
|
||||
s.display(hass, '/houseplan_files/plans/f1.svg');
|
||||
await tick();
|
||||
assert.deepEqual(calls[0].paths, ['/api/houseplan/content/plans/_/f1.svg']);
|
||||
});
|
||||
|
||||
test('resign prunes to the referenced set and gives everything a fresh chance', async () => {
|
||||
let t = 1_000_000;
|
||||
const { hass, calls } = makeHass();
|
||||
const { s } = signer(() => t);
|
||||
s.display(hass, URL_A);
|
||||
s.display(hass, URL_B);
|
||||
await tick();
|
||||
calls[0].res({ urls: { [URL_A]: URL_A + '?s=1', [URL_B]: URL_B + '?s=1' } });
|
||||
await tick();
|
||||
assert.equal(Object.keys(s.entries).length, 2);
|
||||
|
||||
s.resign(hass, new Set([URL_A]));
|
||||
await tick();
|
||||
assert.deepEqual(Object.keys(s.entries), [URL_A], 'the unreferenced url is dropped');
|
||||
assert.deepEqual(calls[1].paths, [URL_A]);
|
||||
});
|
||||
|
||||
test('dispose(): a late answer neither renders nor throws, start() revives it', async () => {
|
||||
const { hass, calls } = makeHass();
|
||||
const { s, updates } = signer();
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
s.dispose();
|
||||
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=LATE' } });
|
||||
await tick();
|
||||
assert.equal(updates(), 0, 'no re-render after teardown');
|
||||
|
||||
s.start(() => hass, () => new Set([URL_A]));
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 2, 'a reconnected card asks again');
|
||||
calls[1].res({ urls: { [URL_A]: URL_A + '?authSig=NEW' } });
|
||||
await tick();
|
||||
assert.equal(updates(), 1);
|
||||
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=NEW');
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('R5-1: an empty but successful answer still backs off', async () => {
|
||||
let t = 1_000_000;
|
||||
const { hass, calls } = makeHass();
|
||||
const { s, updates } = signer(() => t);
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
calls[0].res({ urls: {} }); // the backend skipped the path it could not sign
|
||||
await tick();
|
||||
assert.equal(updates(), 0, 'nothing was signed, so nothing to re-render for');
|
||||
|
||||
for (let i = 0; i < 5; i++) { s.display(hass, URL_A); await tick(); }
|
||||
assert.equal(calls.length, 1, 'five renders, still one request');
|
||||
|
||||
t += SIGN_BACKOFF_MIN_MS + 1;
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 2, 'retried after the backoff');
|
||||
});
|
||||
|
||||
test('R5-1: a partial answer backs off only the path that is missing', async () => {
|
||||
let t = 1_000_000;
|
||||
const { hass, calls } = makeHass();
|
||||
const { s } = signer(() => t);
|
||||
s.display(hass, URL_A);
|
||||
s.display(hass, URL_B);
|
||||
await tick();
|
||||
assert.deepEqual(calls[0].paths.sort(), [URL_B, URL_A].sort());
|
||||
|
||||
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=OK' } }); // B was skipped
|
||||
await tick();
|
||||
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=OK');
|
||||
assert.equal(s.display(hass, URL_B), '');
|
||||
await tick();
|
||||
assert.equal(calls.length, 1, 'the missing path is in backoff, not re-asked');
|
||||
|
||||
t += SIGN_BACKOFF_MIN_MS + 1;
|
||||
s.display(hass, URL_A);
|
||||
s.display(hass, URL_B);
|
||||
await tick();
|
||||
assert.deepEqual(calls[1].paths, [URL_B], 'only the missing path is retried');
|
||||
|
||||
calls[1].res({ urls: { [URL_B]: URL_B + '?authSig=OK' } });
|
||||
await tick();
|
||||
assert.equal(s.display(hass, URL_B), URL_B + '?authSig=OK');
|
||||
t += SIGN_BACKOFF_MIN_MS * 8;
|
||||
s.display(hass, URL_B);
|
||||
await tick();
|
||||
assert.equal(calls.length, 2, 'a success clears the backoff state, no stray retry');
|
||||
});
|
||||
|
||||
test('R5-1: a key we never asked for is ignored', async () => {
|
||||
const { hass, calls } = makeHass();
|
||||
const { s } = signer();
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=OK', '/api/houseplan/content/files/x/evil.pdf': 'nope' } });
|
||||
await tick();
|
||||
assert.deepEqual(Object.keys(s.entries), [URL_A]);
|
||||
});
|
||||
@@ -32,7 +32,10 @@ async def test_upload_ok(hass: HomeAssistant, hass_client: ClientSessionGenerato
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
# audit B1: uploads now return the AUTHENTICATED content URL
|
||||
assert body["ok"] and body["url"].startswith("/api/houseplan/content/files/m1/manual.pdf?v=")
|
||||
# HP-1454-02: uploads take a FREE name and never overwrite, so the url is
|
||||
# the name that was actually used — no cache-busting query needed any more
|
||||
assert body["ok"] and body["url"].startswith("/api/houseplan/content/files/m1/manual")
|
||||
assert body["url"].endswith(".pdf") and "?" not in body["url"]
|
||||
|
||||
|
||||
async def test_upload_bad_ext(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> None:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,44 @@ v = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(v)
|
||||
|
||||
|
||||
def _load_pure(name):
|
||||
"""Load one pure module of the integration without importing the package.
|
||||
|
||||
custom_components/houseplan/__init__.py pulls in Home Assistant, which the
|
||||
local sandbox does not have; but plans.py is deliberately pure, so a tiny
|
||||
stub package lets it keep its normal relative imports.
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
|
||||
pkg_dir = os.path.join(
|
||||
os.path.dirname(os.path.dirname(__file__)), "custom_components", "houseplan"
|
||||
)
|
||||
pkg = sys.modules.get("hp_pure")
|
||||
if pkg is None:
|
||||
pkg = types.ModuleType("hp_pure")
|
||||
pkg.__path__ = [pkg_dir]
|
||||
sys.modules["hp_pure"] = pkg
|
||||
for dep in ("const", "validation"):
|
||||
sp = importlib.util.spec_from_file_location(
|
||||
f"hp_pure.{dep}", os.path.join(pkg_dir, f"{dep}.py")
|
||||
)
|
||||
mod = importlib.util.module_from_spec(sp)
|
||||
sys.modules[f"hp_pure.{dep}"] = mod
|
||||
sp.loader.exec_module(mod)
|
||||
sp = importlib.util.spec_from_file_location(
|
||||
f"hp_pure.{name}", os.path.join(pkg_dir, f"{name}.py")
|
||||
)
|
||||
mod = importlib.util.module_from_spec(sp)
|
||||
sys.modules[f"hp_pure.{name}"] = mod
|
||||
sp.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
plans = _load_pure("plans")
|
||||
const = importlib.import_module("hp_pure.const")
|
||||
|
||||
|
||||
def test_sanitize_marker_id():
|
||||
assert v.sanitize_marker_id("../etc/passwd") == "_etc_passwd"
|
||||
assert v.sanitize_marker_id("..") == "misc" # pure traversal → misc
|
||||
@@ -140,3 +178,491 @@ 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}]})
|
||||
|
||||
|
||||
# ---------- plan-file collection (review R3-1) ----------
|
||||
|
||||
|
||||
def _plans(tmp_path, names, age=0.0):
|
||||
import os, time
|
||||
d = tmp_path / "plans"
|
||||
d.mkdir(exist_ok=True)
|
||||
for n in names:
|
||||
(d / n).write_bytes(b"x")
|
||||
if age:
|
||||
t = time.time() - age
|
||||
os.utime(d / n, (t, t))
|
||||
return d
|
||||
|
||||
|
||||
def _cfg(*urls):
|
||||
return {"spaces": [{"id": f"s{i}", "plan_url": u} for i, u in enumerate(urls)]}
|
||||
|
||||
|
||||
def test_plan_basename_and_refs():
|
||||
plan_basename, plan_refs, is_plan_file = plans.plan_basename, plans.plan_refs, plans.is_plan_file
|
||||
|
||||
assert plan_basename("/api/houseplan/content/plans/_/f1.abc.png?v=7") == "f1.abc.png"
|
||||
assert plan_basename("/houseplan_files/plans/f1.svg") == "f1.svg"
|
||||
assert plan_basename(None) == "" and plan_basename("") == "" and plan_basename(7) == ""
|
||||
assert plan_refs(None) == set() and plan_refs({}) == set()
|
||||
assert plan_refs(_cfg("/p/a.png", None, "/p/b.svg")) == {"a.png", "b.svg"}
|
||||
assert is_plan_file("f1.svg") and is_plan_file("f1.tok.png")
|
||||
assert not is_plan_file("notes.txt") and not is_plan_file("readme")
|
||||
assert not is_plan_file("deep.name.with.dots.png") # 4 parts: not ours
|
||||
|
||||
|
||||
def test_collect_plans_removes_only_the_superseded_file(tmp_path):
|
||||
collect_plans = plans.collect_plans
|
||||
|
||||
d = _plans(tmp_path, ["f1.old.png", "f1.new.png", "f2.png"])
|
||||
removed = collect_plans(d, _cfg("/p/f1.old.png", "/p/f2.png"), _cfg("/p/f1.new.png", "/p/f2.png"))
|
||||
assert removed == 1
|
||||
assert not (d / "f1.old.png").exists()
|
||||
assert (d / "f1.new.png").is_file() and (d / "f2.png").is_file()
|
||||
|
||||
|
||||
def test_collect_plans_keeps_a_fresh_unreferenced_upload(tmp_path):
|
||||
"""Another client may be mid-transaction: its file is unreferenced but young."""
|
||||
collect_plans = plans.collect_plans
|
||||
|
||||
d = _plans(tmp_path, ["f1.committed.png", "f1.inflight.png"])
|
||||
removed = collect_plans(d, _cfg("/p/f1.committed.png"), _cfg("/p/f1.committed.png"))
|
||||
assert removed == 0
|
||||
assert (d / "f1.inflight.png").is_file()
|
||||
|
||||
|
||||
def test_collect_plans_never_touches_a_referenced_or_foreign_file(tmp_path):
|
||||
PLAN_ORPHAN_TTL_S = const.PLAN_ORPHAN_TTL_S
|
||||
collect_plans = plans.collect_plans
|
||||
|
||||
old = PLAN_ORPHAN_TTL_S + 60
|
||||
d = _plans(tmp_path, ["f1.png", "notes.txt", "readme", "deep.name.with.dots.png"], age=old)
|
||||
removed = collect_plans(d, _cfg("/p/f1.png"), _cfg("/p/f1.png"))
|
||||
assert removed == 0
|
||||
for n in ("f1.png", "notes.txt", "readme", "deep.name.with.dots.png"):
|
||||
assert (d / n).is_file()
|
||||
|
||||
|
||||
def test_collect_plans_survives_a_missing_directory(tmp_path):
|
||||
collect_plans = plans.collect_plans
|
||||
|
||||
assert collect_plans(tmp_path / "nope", _cfg(), _cfg()) == 0
|
||||
|
||||
|
||||
def test_collect_plans_never_raises_when_the_directory_disappears(tmp_path, monkeypatch):
|
||||
"""review R4-1: it runs behind a durable commit, so it may only report 0."""
|
||||
collect_plans = plans.collect_plans
|
||||
d = tmp_path / "plans"
|
||||
d.mkdir()
|
||||
|
||||
def _boom(self):
|
||||
raise OSError("gone")
|
||||
|
||||
monkeypatch.setattr(type(d), "iterdir", _boom, raising=False)
|
||||
assert collect_plans(d, _cfg("/p/a.png"), _cfg("/p/b.png")) == 0
|
||||
|
||||
|
||||
# ---------- the editor's options must be storable (issue #3) ----------
|
||||
|
||||
|
||||
def _ts_list(name):
|
||||
"""Read one `export const NAME = [...] as const;` list out of src/logic.ts.
|
||||
|
||||
Deliberately reads the TypeScript source rather than duplicating the values:
|
||||
a list that lives in two places drifts, which is exactly what happened here.
|
||||
"""
|
||||
import re
|
||||
|
||||
src = os.path.join(os.path.dirname(os.path.dirname(__file__)), "src", "logic.ts")
|
||||
with open(src, encoding="utf-8") as fh:
|
||||
text = fh.read()
|
||||
m = re.search(rf"export const {name} = \[(.*?)\] as const;", text, re.S)
|
||||
assert m, f"{name} not found in src/logic.ts"
|
||||
return re.findall(r"'([^']+)'", m.group(1))
|
||||
|
||||
|
||||
def _marker(**extra):
|
||||
return {"id": "m1", "binding": "entity:sensor.x", **extra}
|
||||
|
||||
|
||||
def test_every_display_mode_the_editor_offers_is_accepted():
|
||||
"""issue #3: 'value' was added to the card in v1.26.0 and never to the schema.
|
||||
|
||||
Saving a sensor set to "value instead of an icon" failed with
|
||||
"not a valid value for dictionary value @ data['config']['markers'][n]['display']",
|
||||
and because one bad marker rejects the whole config, the user could not save
|
||||
at all. Reported 2026-07-27.
|
||||
"""
|
||||
modes = _ts_list("DISPLAY_MODES")
|
||||
assert "value" in modes, "the regression this test exists for"
|
||||
for mode in modes:
|
||||
v.MARKER_SCHEMA(_marker(display=mode))
|
||||
v.MARKER_SCHEMA(_marker(display=None))
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.MARKER_SCHEMA(_marker(display="wat"))
|
||||
|
||||
|
||||
def test_every_tap_action_the_editor_offers_is_accepted():
|
||||
for action in _ts_list("TAP_ACTIONS"):
|
||||
v.MARKER_SCHEMA(_marker(tap_action=action))
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.MARKER_SCHEMA(_marker(tap_action="launch-missiles"))
|
||||
|
||||
|
||||
def _space(**settings):
|
||||
return {
|
||||
"id": "f1", "title": "F1", "aspect": 1.4, "view_box": [0, 0, 1, 1],
|
||||
"rooms": [], "settings": settings,
|
||||
}
|
||||
|
||||
|
||||
def test_every_fill_mode_the_editor_offers_is_accepted():
|
||||
for mode in _ts_list("SPACE_FILL_MODES"):
|
||||
v.SPACE_SCHEMA(_space(fill_mode=mode))
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.SPACE_SCHEMA(_space(fill_mode="rainbow"))
|
||||
|
||||
|
||||
def test_every_room_fill_mode_the_editor_offers_is_accepted():
|
||||
def room(mode):
|
||||
return {
|
||||
"id": "f1", "title": "F1", "aspect": 1.4, "view_box": [0, 0, 1, 1],
|
||||
"rooms": [{"id": "r1", "name": "R", "x": 0.1, "y": 0.1, "w": 0.2, "h": 0.2,
|
||||
"settings": {"fill_mode": mode}}],
|
||||
}
|
||||
|
||||
for mode in _ts_list("ROOM_FILL_MODES"):
|
||||
v.SPACE_SCHEMA(room(mode))
|
||||
v.SPACE_SCHEMA(room(None)) # inherit from the space
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.SPACE_SCHEMA(room("rainbow"))
|
||||
|
||||
|
||||
# ---------- attachments & inner limits (HP-1454-02, -05) ----------
|
||||
|
||||
|
||||
def test_reserve_filename_claims_the_name_atomically(tmp_path):
|
||||
"""HP-1460-01: choosing a name and taking it must be one operation.
|
||||
|
||||
The old helper asked `exists()` and returned a string; two uploads racing
|
||||
between the check and the write agreed on the same name and one overwrote
|
||||
the other, both reporting success.
|
||||
"""
|
||||
reserve = plans.reserve_filename
|
||||
d = tmp_path / "m1"
|
||||
|
||||
first = reserve(d, "manual.pdf")
|
||||
assert first == "manual.pdf"
|
||||
assert (d / first).is_file(), "the name is taken, not merely picked"
|
||||
second = reserve(d, "manual.pdf")
|
||||
assert second == "manual-2.pdf" and (d / second).is_file()
|
||||
assert reserve(d, "manual.pdf") == "manual-3.pdf"
|
||||
|
||||
assert reserve(d, "readme") == "readme"
|
||||
assert reserve(d, "readme") == "readme-2"
|
||||
assert reserve(d, "../../etc/passwd") == "passwd"
|
||||
|
||||
|
||||
def test_reserve_filename_result_survives_the_content_sanitizer(tmp_path):
|
||||
"""A name the view would rewrite is a file written and then never served."""
|
||||
reserve = plans.reserve_filename
|
||||
d = tmp_path / "m2"
|
||||
long_stem = "x" * 200 # far past the limit
|
||||
names = [reserve(d, f"{long_stem}.pdf") for _ in range(12)]
|
||||
assert len(set(names)) == 12, "each call takes its own name"
|
||||
for n in names:
|
||||
assert len(n) <= v.MAX_FILENAME
|
||||
assert v.sanitize_filename(n) == n, n
|
||||
assert n.endswith(".pdf")
|
||||
|
||||
# a name already exactly at the limit still leaves room for the tag
|
||||
exact = "y" * (v.MAX_FILENAME - 4) + ".pdf"
|
||||
assert len(exact) == v.MAX_FILENAME
|
||||
a = reserve(d, exact)
|
||||
b = reserve(d, exact)
|
||||
assert a != b and len(b) <= v.MAX_FILENAME and v.sanitize_filename(b) == b
|
||||
|
||||
|
||||
def test_reserve_filename_is_safe_under_concurrency(tmp_path):
|
||||
"""Twenty threads, one filename: twenty distinct files, nothing overwritten."""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
reserve = plans.reserve_filename
|
||||
d = tmp_path / "m3"
|
||||
d.mkdir(parents=True)
|
||||
with ThreadPoolExecutor(max_workers=20) as pool:
|
||||
names = list(pool.map(lambda _: reserve(d, "manual.pdf"), range(20)))
|
||||
assert len(set(names)) == 20
|
||||
assert sorted(p.name for p in d.iterdir()) == sorted(names)
|
||||
|
||||
|
||||
def test_sweep_upload_temps(tmp_path):
|
||||
"""HP-1460-02: a crashed transfer leaves a temp no other collector sees."""
|
||||
import os
|
||||
import time
|
||||
|
||||
files = tmp_path / "files"
|
||||
files.mkdir()
|
||||
fresh = files / f"{plans.TMP_PREFIX}fresh"
|
||||
old = files / f"{plans.TMP_PREFIX}old"
|
||||
keep = files / "notes.txt"
|
||||
for f in (fresh, old, keep):
|
||||
f.write_bytes(b"x")
|
||||
t = time.time() - const.PLAN_ORPHAN_TTL_S - 60
|
||||
os.utime(old, (t, t))
|
||||
|
||||
assert plans.sweep_upload_temps(files) == 1
|
||||
assert not old.exists(), "an aged temporary goes"
|
||||
assert fresh.is_file(), "a fresh one may belong to a request in flight"
|
||||
assert keep.is_file(), "nothing else is touched"
|
||||
assert plans.sweep_upload_temps(tmp_path / "nope") == 0
|
||||
|
||||
|
||||
def _acfg(*pairs):
|
||||
return {"markers": [{"id": f"m{i}", "pdfs": [{"url": f"/api/houseplan/content/files/{p}"}]}
|
||||
for i, p in enumerate(pairs)]}
|
||||
|
||||
|
||||
def test_attachment_refs_reads_marker_urls():
|
||||
attachment_refs = plans.attachment_refs
|
||||
assert attachment_refs(None) == set()
|
||||
assert attachment_refs(_acfg("m1/a.pdf", "m2/b.pdf")) == {"m1/a.pdf", "m2/b.pdf"}
|
||||
# legacy and foreign urls are not ours to collect against
|
||||
cfg = {"markers": [{"id": "m", "pdfs": [{"url": "/local/x.pdf"}, {"url": "/api/houseplan/content/files/deep/a/b.pdf"}]}]}
|
||||
assert plans.attachment_refs(cfg) == set()
|
||||
|
||||
|
||||
def test_collect_attachments_removes_the_empty_folder_and_never_raises(tmp_path):
|
||||
import os
|
||||
import time
|
||||
|
||||
files = tmp_path / "files"
|
||||
(files / "up_x").mkdir(parents=True)
|
||||
f = files / "up_x" / "orphan.pdf"
|
||||
f.write_bytes(b"x")
|
||||
old = time.time() - const.PLAN_ORPHAN_TTL_S - 60
|
||||
os.utime(f, (old, old))
|
||||
assert plans.collect_attachments(files, {}, {}) == 1
|
||||
assert not (files / "up_x").exists(), "the staging folder goes with its last file"
|
||||
assert plans.collect_attachments(tmp_path / "nope", {}, {}) == 0
|
||||
|
||||
|
||||
def test_inner_collection_limits():
|
||||
room = {"id": "r", "name": "R", "poly": [[0.1, 0.1]] * v.MAX_POLY_POINTS}
|
||||
v.ROOM_SCHEMA(room)
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.ROOM_SCHEMA({**room, "poly": [[0.1, 0.1]] * (v.MAX_POLY_POINTS + 1)})
|
||||
|
||||
rect = {"id": "r", "name": "R", "x": 0.1, "y": 0.1, "w": 0.2, "h": 0.2}
|
||||
v.ROOM_SCHEMA({**rect, "open_to": ["x"] * v.MAX_OPEN_TO})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.ROOM_SCHEMA({**rect, "open_to": ["x"] * (v.MAX_OPEN_TO + 1)})
|
||||
|
||||
m = {"id": "m", "binding": "virtual"}
|
||||
v.MARKER_SCHEMA({**m, "controls": ["light.x"] * v.MAX_CONTROLS})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.MARKER_SCHEMA({**m, "controls": ["light.x"] * (v.MAX_CONTROLS + 1)})
|
||||
|
||||
pdf = {"name": "n", "url": "/api/houseplan/content/files/m/a.pdf"}
|
||||
v.MARKER_SCHEMA({**m, "pdfs": [pdf] * v.MAX_PDFS})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.MARKER_SCHEMA({**m, "pdfs": [pdf] * (v.MAX_PDFS + 1)})
|
||||
|
||||
v.MARKER_SCHEMA({**m, "name": "n" * v.MAX_TEXT})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.MARKER_SCHEMA({**m, "name": "n" * (v.MAX_TEXT + 1)})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.MARKER_SCHEMA({**m, "link": "u" * (v.MAX_URL + 1)})
|
||||
|
||||
|
||||
def test_legacy_segments_are_dropped_by_the_server():
|
||||
"""A limit that depends on the client stripping the field is not a limit."""
|
||||
out = v.SPACE_SCHEMA({
|
||||
"id": "f1", "title": "F", "aspect": 1.4, "view_box": [0, 0, 1, 1], "rooms": [],
|
||||
"segments": [[1, 2, 3, 4]] * 100000,
|
||||
})
|
||||
assert "segments" not in out
|
||||
|
||||
|
||||
def _aged(path, seconds):
|
||||
import os
|
||||
import time
|
||||
|
||||
t = time.time() - seconds
|
||||
os.utime(path, (t, t))
|
||||
|
||||
|
||||
def _sp(sid, url):
|
||||
return {"id": sid, "plan_url": f"/api/houseplan/content/plans/_/{url}" if url else None}
|
||||
|
||||
|
||||
def test_plan_collection_matrix(tmp_path):
|
||||
"""Which config transition means "the user asked for this file to go"?
|
||||
|
||||
`old_refs - new_refs` cannot tell replace, detach and delete-space apart —
|
||||
they look identical. v1.46.4/v1.46.5 added guards for detach and only ever
|
||||
reached them on the scheduled pass, so the commit itself still deleted a
|
||||
detached plan the moment it was detached (HP-1465-01). One case is a
|
||||
deletion the user asked for; the rest are kept.
|
||||
"""
|
||||
collect = plans.collect_plans
|
||||
d = tmp_path / "plans"
|
||||
d.mkdir()
|
||||
|
||||
def seed(*names):
|
||||
for n in names:
|
||||
(d / n).write_bytes(b"x")
|
||||
_aged(d / n, const.SCHEDULED_GRACE_S * 2) # old enough for any rule
|
||||
|
||||
# 1. replace: the user picked a different image for the same space
|
||||
seed("f1.old.png", "f1.new.png")
|
||||
assert collect(d, {"spaces": [_sp("f1", "f1.old.png")]},
|
||||
{"spaces": [_sp("f1", "f1.new.png")]}) == 1
|
||||
assert not (d / "f1.old.png").exists() and (d / "f1.new.png").is_file()
|
||||
|
||||
# 2. detach: same space, switched to "draw"
|
||||
seed("f2.png")
|
||||
assert collect(d, {"spaces": [_sp("f2", "f2.png")]},
|
||||
{"spaces": [_sp("f2", None)]}) == 0
|
||||
assert (d / "f2.png").is_file(), "the editor says the file stays — it stays"
|
||||
|
||||
# 3. the space is deleted outright
|
||||
seed("f3.png")
|
||||
assert collect(d, {"spaces": [_sp("f3", "f3.png")]}, {"spaces": []}) == 0
|
||||
assert (d / "f3.png").is_file()
|
||||
|
||||
# 4. the scheduled pass, later, still keeps both
|
||||
cfg = {"spaces": [_sp("f2", None)]}
|
||||
assert collect(d, cfg, cfg) == 0
|
||||
assert (d / "f2.png").is_file() and (d / "f3.png").is_file()
|
||||
|
||||
# 5. an upload whose save was rejected is kept too, at any age. Ageing those
|
||||
# out raced the retry: the sweep deleted a file the save was committing a
|
||||
# reference to (caught by test_sweep_and_a_config_write_do_not_race).
|
||||
seed("f4.current.png", "f4.reject.png")
|
||||
live = {"spaces": [_sp("f4", "f4.current.png")]}
|
||||
assert collect(d, live, live) == 0
|
||||
assert (d / "f4.current.png").is_file() and (d / "f4.reject.png").is_file()
|
||||
|
||||
# 6. the same file still referenced by another space is never touched
|
||||
seed("shared.png")
|
||||
assert collect(d, {"spaces": [_sp("a", "shared.png"), _sp("b", "shared.png")]},
|
||||
{"spaces": [_sp("a", None), _sp("b", "shared.png")]}) == 0
|
||||
assert (d / "shared.png").is_file()
|
||||
|
||||
|
||||
def test_attachment_collection_matrix(tmp_path):
|
||||
"""Removing an attachment is a trash button; deleting the device is not."""
|
||||
collect = plans.collect_attachments
|
||||
|
||||
def case(name):
|
||||
d = tmp_path / name
|
||||
d.mkdir()
|
||||
return d
|
||||
|
||||
def seed(root, folder, fname, age=None):
|
||||
(root / folder).mkdir(parents=True, exist_ok=True)
|
||||
p = root / folder / fname
|
||||
p.write_bytes(b"x")
|
||||
if age:
|
||||
_aged(p, age)
|
||||
return p
|
||||
|
||||
def cfg(*markers):
|
||||
return {"markers": [
|
||||
{"id": mid, "pdfs": [{"url": f"/api/houseplan/content/files/{mid}/{n}"} for n in names]}
|
||||
for mid, names in markers
|
||||
]}
|
||||
|
||||
# 1. the user removed one attachment from a device that still exists
|
||||
d = case("dropped")
|
||||
seed(d, "m1", "dropped.pdf")
|
||||
seed(d, "m1", "kept.pdf")
|
||||
assert collect(d, cfg(("m1", ["dropped.pdf", "kept.pdf"])), cfg(("m1", ["kept.pdf"]))) == 1
|
||||
assert not (d / "m1" / "dropped.pdf").exists()
|
||||
assert (d / "m1" / "kept.pdf").is_file()
|
||||
|
||||
# 2. the device itself is gone: its manuals are not ours to throw away
|
||||
d = case("device_gone")
|
||||
seed(d, "m2", "manual.pdf", age=const.SCHEDULED_GRACE_S * 2)
|
||||
assert collect(d, cfg(("m2", ["manual.pdf"])), cfg()) == 0
|
||||
assert (d / "m2" / "manual.pdf").is_file()
|
||||
# and the scheduled pass, later, agrees
|
||||
assert collect(d, cfg(), cfg()) == 0
|
||||
assert (d / "m2" / "manual.pdf").is_file()
|
||||
|
||||
# 3. a dialog that was never saved, in its own staging folder
|
||||
d = case("staging")
|
||||
seed(d, "up_x", "manual.pdf", age=const.PLAN_ORPHAN_TTL_S + 60)
|
||||
assert collect(d, cfg(), cfg()) == 1
|
||||
assert not (d / "up_x").exists()
|
||||
|
||||
# 4. an upload into a live device's folder whose save was rejected: kept,
|
||||
# for the same reason as a plan's — a retry may be about to reference it
|
||||
d = case("reject")
|
||||
seed(d, "m3", "current.pdf")
|
||||
seed(d, "m3", "rejected.pdf", age=const.SCHEDULED_GRACE_S + 60)
|
||||
live = cfg(("m3", ["current.pdf"]))
|
||||
assert collect(d, live, live) == 0
|
||||
assert (d / "m3" / "current.pdf").is_file()
|
||||
assert (d / "m3" / "rejected.pdf").is_file()
|
||||
|
||||
|
||||
def test_only_a_staging_folder_ages_out(tmp_path):
|
||||
"""The one age rule left. Everything else waits for the user to say so."""
|
||||
import os
|
||||
import time
|
||||
|
||||
files = tmp_path / "files"
|
||||
(files / "m1").mkdir(parents=True)
|
||||
(files / "up_abandoned").mkdir(parents=True)
|
||||
ancient = time.time() - const.SCHEDULED_GRACE_S * 12
|
||||
hour_ago = time.time() - const.PLAN_ORPHAN_TTL_S - 60
|
||||
|
||||
for path, when in (
|
||||
((files / "m1" / "ancient.pdf"), ancient),
|
||||
((files / "up_abandoned" / "manual.pdf"), hour_ago),
|
||||
):
|
||||
path.write_bytes(b"x")
|
||||
os.utime(path, (when, when))
|
||||
|
||||
cfg = {"markers": [{"id": "m1", "pdfs": []}]}
|
||||
assert plans.collect_attachments(files, cfg, cfg) == 1
|
||||
assert (files / "m1" / "ancient.pdf").is_file(), "age alone is never a reason"
|
||||
assert not (files / "up_abandoned").exists(), "a cancelled dialog goes after an hour"
|
||||
|
||||
|
||||
|
||||
+2
-1
@@ -12,6 +12,7 @@
|
||||
"src/rules.ts",
|
||||
"src/devices.ts",
|
||||
"src/types.ts",
|
||||
"src/space-geometry.ts"
|
||||
"src/space-geometry.ts",
|
||||
"src/signing.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user