mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 11:18:48 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7ae3e7adf | ||
|
|
379fb68db2 | ||
|
|
96a70495d3 | ||
|
|
d3db9e30e6 | ||
|
|
2e731debd9 | ||
|
|
a49b5e6d2e | ||
|
|
4418312b0b | ||
|
|
3f719cc32a | ||
|
|
260615a63f | ||
|
|
e4e300adaa | ||
|
|
96d387ff1d | ||
|
|
8b531db3f5 |
@@ -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,8 +20,9 @@ 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
|
||||
from .store import HouseplanConfigEntry, create_data, get_data
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -97,6 +100,48 @@ 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 = get_data(hass)
|
||||
if data is None: # entry unloaded — nothing authoritative to compare against
|
||||
await hass.async_add_executor_job(sweep_upload_temps, files_dir)
|
||||
return
|
||||
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)
|
||||
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")
|
||||
|
||||
await _sweep()
|
||||
entry.async_on_unload(
|
||||
async_track_time_interval(hass, _sweep, timedelta(hours=24))
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
@@ -24,7 +24,7 @@ MAX_SIGN_PATHS = 200
|
||||
PLAN_ORPHAN_TTL_S = 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.45.3"
|
||||
VERSION = "1.46.2"
|
||||
|
||||
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
|
||||
@@ -20,6 +22,7 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, PLANS_DIR
|
||||
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):
|
||||
@@ -98,55 +121,122 @@ class HouseplanUploadView(HomeAssistantView):
|
||||
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.45.3"
|
||||
"version": "1.46.2"
|
||||
}
|
||||
|
||||
@@ -1,22 +1,173 @@
|
||||
"""Plan-file collection — pure, so it is unit-testable without Home Assistant.
|
||||
"""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 delete a plan file, and when, is a correctness question rather than a
|
||||
housekeeping one. It lives here, apart from the WebSocket plumbing, precisely
|
||||
because it is the part that has to be reasoned about and tested.
|
||||
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 PLAN_EXTENSIONS
|
||||
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 was superseded
|
||||
by this commit and goes. Anything else unreferenced is an upload that was
|
||||
never saved — a cancelled dialog, a rejected write — and waits out
|
||||
PLAN_ORPHAN_TTL_S first, because a fresh one may belong to a dialog the user
|
||||
still has open. Never raises: it runs behind a durable write.
|
||||
"""
|
||||
new_refs = attachment_refs(new_cfg)
|
||||
old_refs = attachment_refs(old_cfg)
|
||||
cutoff = (time.time() if now is None else now) - 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:
|
||||
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
|
||||
try:
|
||||
stale = item.stat().st_mtime < cutoff
|
||||
except OSError:
|
||||
stale = False
|
||||
if rel not in old_refs and not stale:
|
||||
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)."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
@@ -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(
|
||||
@@ -106,7 +134,7 @@ ROOM_SCHEMA = vol.All(
|
||||
vol.Optional("y"): _finite,
|
||||
vol.Optional("w"): _finite,
|
||||
vol.Optional("h"): _finite,
|
||||
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3)),
|
||||
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3, max=MAX_POLY_POINTS)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
@@ -185,7 +213,10 @@ SPACE_SCHEMA = vol.Schema(
|
||||
# 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,13 +228,13 @@ 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),
|
||||
@@ -214,9 +245,10 @@ MARKER_SCHEMA = vol.Schema(
|
||||
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,
|
||||
)
|
||||
@@ -227,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,7 @@ import logging
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -16,13 +17,13 @@ from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import (
|
||||
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
|
||||
CONTENT_URL, MAX_SIGN_PATHS, PLANS_DIR, PLANS_URL,
|
||||
CONTENT_URL, FILES_DIR, MAX_SIGN_PATHS, PLANS_DIR, PLANS_URL,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .plans import collect_plans
|
||||
from .plans import collect_attachments, collect_plans, reserve_filename
|
||||
from .store import HouseplanData, get_data, get_entry
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_PLAN_BYTES,
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, valid_space_id,
|
||||
)
|
||||
|
||||
@@ -74,7 +75,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(
|
||||
@@ -107,8 +110,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(
|
||||
@@ -131,8 +136,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(
|
||||
@@ -183,17 +193,18 @@ async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
for f in sorted(src.iterdir()):
|
||||
if not f.is_file():
|
||||
continue
|
||||
target = dst / f.name
|
||||
if target.exists():
|
||||
# a different file already owns this name — do NOT silently
|
||||
# point the url at it; give the copy a unique name instead
|
||||
stem, suffix = f.stem, f.suffix
|
||||
i = 2
|
||||
while (dst / f"{stem} ({i}){suffix}").exists():
|
||||
i += 1
|
||||
target = dst / f"{stem} ({i}){suffix}"
|
||||
shutil.copy2(str(f), str(target))
|
||||
mapping[f.name] = target.name
|
||||
# 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
|
||||
|
||||
try:
|
||||
@@ -297,13 +308,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 ----------------
|
||||
@@ -343,6 +358,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)
|
||||
@@ -369,12 +394,14 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
# 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_plans, Path(hass.config.path(PLANS_DIR)), data.get("config"), msg["config"]
|
||||
)
|
||||
await hass.async_add_executor_job(_collect)
|
||||
except Exception: # noqa: BLE001 — see above: the commit stands regardless
|
||||
_LOGGER.exception("House Plan: collecting superseded plan files failed")
|
||||
_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)
|
||||
|
||||
@@ -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,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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
File diff suppressed because one or more lines are too long
Vendored
+16
-16
File diff suppressed because one or more lines are too long
+39
-3
@@ -172,14 +172,50 @@ 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}` — writes `<space>.<token>.<ext>`, deletes nothing |
|
||||
| `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, and anything unreferenced and
|
||||
older than `PLAN_ORPHAN_TTL_S`.
|
||||
|
||||
**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
|
||||
|
||||
+150
-2
@@ -1,5 +1,153 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
@@ -12,8 +160,8 @@
|
||||
`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 a year
|
||||
later through somebody's error message.
|
||||
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).**
|
||||
|
||||
+152
-1
@@ -6,6 +6,157 @@
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## 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, но серверная схема всё это
|
||||
@@ -20,7 +171,7 @@
|
||||
экспортируются из карточки, и backend-тест читает их, проверяя, что схема
|
||||
принимает каждое значение, которое пользователь реально может выбрать.
|
||||
Теперь добавить опцию в редактор и забыть про схему — значит уронить тесты, а
|
||||
не узнать об этом через полтора года из чужого сообщения об ошибке.
|
||||
не узнать об этом из чужого сообщения об ошибке.
|
||||
|
||||
## v1.45.2 — 2026-07-27 (закалка по ревью v1.45.1: R4-1, R4-2)
|
||||
- **Сбой уборки больше не превращает принятое сохранение в ошибку (R4-1).**
|
||||
|
||||
+5
-5
@@ -11,18 +11,18 @@
|
||||
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
|
||||
> ARCHITECTURE.md for design changes, ROADMAP.md when plans move.
|
||||
|
||||
## Snapshot (2026-07-27)
|
||||
## Snapshot (2026-07-28)
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.45.3** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Version | **v1.46.2** 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.45.3** via direct copy (HACS custom repo also installed) |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.46.2** via direct copy (HACS custom repo also installed) |
|
||||
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
|
||||
| Tests | 121 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 |
|
||||
|
||||
|
||||
@@ -239,6 +239,65 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
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]
|
||||
- [ ] Nothing accumulates on an idle instance (v1.46.2, HP-1461-01): attach a
|
||||
file, cancel the dialog, and do not save anything else — an hour later (or
|
||||
after a restart) the file is gone, while every file the configuration
|
||||
still references is untouched
|
||||
[auto: backend test_scheduled_sweep_collects_what_no_commit_will]
|
||||
- [ ] 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
|
||||
|
||||
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.45.3",
|
||||
"version": "1.46.2",
|
||||
"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;
|
||||
}
|
||||
|
||||
+188
-57
@@ -35,7 +35,7 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.45.3';
|
||||
const CARD_VERSION = '1.46.2';
|
||||
const LS_KEY = 'houseplan_card_layout_v1';
|
||||
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||
@@ -122,6 +122,8 @@ class HouseplanCard extends LitElement {
|
||||
private _serverCfg: ServerConfig | null = null;
|
||||
private _cfgRev = 0;
|
||||
private _unsubCfg: (() => void) | null = null;
|
||||
private _unsubLayout: (() => void) | null = null;
|
||||
private _layoutRev = 0;
|
||||
private _devices: DevItem[] = [];
|
||||
private _regSignature = '';
|
||||
private _defPos: Record<string, { x: number; y: number }> = {};
|
||||
@@ -234,6 +236,15 @@ class HouseplanCard extends LitElement {
|
||||
private _infoCard: DevItem | null = null;
|
||||
private _markerDialog: {
|
||||
devId?: string; // the icon being edited (if any)
|
||||
/**
|
||||
* Folder attachments are uploaded into while this dialog is open. For a NEW
|
||||
* icon there is no marker id yet; every one of them used to upload into a
|
||||
* shared `files/new/`, so two markers attaching `manual.pdf` ended up
|
||||
* pointing at the same bytes (HP-1454-02). A per-dialog id keeps them
|
||||
* apart, and the files are moved to the real marker id once the config
|
||||
* write is accepted — the same copy→save→cleanup order as a rebind.
|
||||
*/
|
||||
uploadId?: string;
|
||||
name: string;
|
||||
binding: string; // 'device:<id>' | 'entity:<eid>' | 'virtual' | '' (not chosen yet)
|
||||
bindingMode: 'virtual' | 'ha';
|
||||
@@ -393,6 +404,11 @@ class HouseplanCard extends LitElement {
|
||||
this._unsubCfg();
|
||||
this._unsubCfg = null;
|
||||
}
|
||||
if (this._unsubLayout) {
|
||||
this._unsubLayout();
|
||||
this._unsubLayout = null;
|
||||
}
|
||||
clearTimeout(this._layoutSyncTimer);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
@@ -556,9 +572,17 @@ class HouseplanCard extends LitElement {
|
||||
for (const x of sp as any[]) {
|
||||
s += (x.id || '') + ',' + (x.aspect || '') + ',' + (x.plan_url || '').length + ','
|
||||
+ (x.rooms?.length || 0) + ',' + (x.openings?.length || 0) + ',' + (x.decor?.length || 0) + ';';
|
||||
for (const r of x.rooms || [])
|
||||
for (const r of x.rooms || []) {
|
||||
// O(1) geometry roll-up per room: the count alone said nothing about
|
||||
// where the room actually is, so a moved rectangle or a dragged first/
|
||||
// last vertex looked identical (HP-1454-04). The epoch remains the
|
||||
// primary signal — this is the belt for a mutation that forgot to bump it.
|
||||
const p0 = r.poly?.[0], pn = r.poly?.[r.poly.length - 1];
|
||||
s += (r.poly?.length || 0) + '.' + (r.id || '') + '.' + (r.open_to || []).join('+') + '.'
|
||||
+ (r.area || '') + '.' + JSON.stringify(r.settings || 0) + ';';
|
||||
+ (r.area || '') + '.' + JSON.stringify(r.settings || 0) + '.'
|
||||
+ (r.x ?? '') + ',' + (r.y ?? '') + ',' + (r.w ?? '') + ',' + (r.h ?? '') + ','
|
||||
+ (p0 ? p0[0] + '/' + p0[1] : '') + ',' + (pn ? pn[0] + '/' + pn[1] : '') + ';';
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
@@ -706,6 +730,7 @@ class HouseplanCard extends LitElement {
|
||||
this._cfgEpoch++;
|
||||
this._cfgRev = cfgResp?.rev || 0;
|
||||
this._layout = layResp?.layout || {};
|
||||
this._layoutRev = layResp?.rev ?? 0;
|
||||
// live sync: the config was changed in another window → re-read it
|
||||
if (!this._unsubCfg) {
|
||||
this._unsubCfg = await this.hass.connection.subscribeEvents((ev: any) => {
|
||||
@@ -715,6 +740,15 @@ class HouseplanCard extends LitElement {
|
||||
if ((ev?.data?.rev ?? -1) !== this._cfgRev) this._reloadConfigOnly();
|
||||
}, 'houseplan_config_updated');
|
||||
}
|
||||
if (!this._unsubLayout) {
|
||||
// Positions are separate state. The static card learned to follow them
|
||||
// in v1.46.0 and the full one did not, so two full cards side by side
|
||||
// stayed out of sync until a reload (HP-1460-03).
|
||||
this._unsubLayout = await this.hass.connection.subscribeEvents(
|
||||
(ev: any) => this._onLayoutEvent(Number(ev?.data?.rev ?? -1)),
|
||||
'houseplan_layout_updated',
|
||||
);
|
||||
}
|
||||
const hs = this._hashSpace();
|
||||
const nav = this._savedNav();
|
||||
if (!this._hashApplied && hs && this._model.find((s) => s.id === hs)) {
|
||||
@@ -801,7 +835,73 @@ class HouseplanCard extends LitElement {
|
||||
this._signer.resign(this.hass, referencedContentUrls(this._serverCfg));
|
||||
}
|
||||
|
||||
private _layoutSyncTimer?: number;
|
||||
|
||||
/**
|
||||
* A layout revision appeared. It may well be ours: the event travels over the
|
||||
* same socket as the reply to our own write and can arrive first, so
|
||||
* reacting immediately means re-reading what we just sent — and, worse,
|
||||
* racing a drag that has not been flushed yet. Wait a beat; if our own reply
|
||||
* lands in the meantime, `_layoutRev` catches up and there is nothing to do.
|
||||
*/
|
||||
private _onLayoutEvent(rev: number): void {
|
||||
if (rev <= this._layoutRev) return;
|
||||
clearTimeout(this._layoutSyncTimer);
|
||||
this._layoutSyncTimer = window.setTimeout(() => {
|
||||
if (rev <= this._layoutRev) return; // it was ours after all
|
||||
this._reloadLayoutOnly();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember a revision this card produced, so its own `layout_updated` event
|
||||
* is not mistaken for someone else's and does not trigger a pointless
|
||||
* re-read of what we just wrote.
|
||||
*/
|
||||
private _noteLayoutRev(r: any): void {
|
||||
const rev = r?.rev;
|
||||
if (typeof rev === 'number' && rev > this._layoutRev) this._layoutRev = rev;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt positions written elsewhere, without dropping our own (HP-1460-03).
|
||||
*
|
||||
* Only the layout is re-read — the config is untouched, so this cannot
|
||||
* disturb an edit in progress. Positions this card has moved but not yet
|
||||
* sent are flushed first and then kept on top of the server's answer: a fix
|
||||
* for a stale UI must not turn into a lost drag.
|
||||
*/
|
||||
private async _reloadLayoutOnly(): Promise<void> {
|
||||
if (!this._serverStorage || !this.hass?.callWS) return;
|
||||
// Snapshot BEFORE flushing. `flush()` runs the debounced writer
|
||||
// synchronously, and the first thing that does is empty `_dirtyPos` — so
|
||||
// reading the dirty set afterwards found nothing to protect and the server's
|
||||
// older position was painted over the drag the user had just made
|
||||
// (HP-1461-02). Positions are captured by value for the same reason.
|
||||
const mine = new Map<string, any>();
|
||||
for (const id of this._dirtyPos) if (this._layout[id]) mine.set(id, this._layout[id]);
|
||||
if (this._persistLayout.pending()) this._persistLayout.flush();
|
||||
// …and again after the flush: what was dirty is now in flight, and a write
|
||||
// sent before this reload was even scheduled is in there too. Until the
|
||||
// server acknowledges a position, this card is the authority on it.
|
||||
for (const [id, pos] of this._sentPos) mine.set(id, pos);
|
||||
try {
|
||||
const resp = await this.hass.callWS({ type: 'houseplan/layout/get' });
|
||||
const remote = resp?.layout || {};
|
||||
const merged: Record<string, any> = { ...remote };
|
||||
for (const [id, pos] of mine) merged[id] = pos;
|
||||
this._layout = merged;
|
||||
this._layoutRev = resp?.rev ?? this._layoutRev;
|
||||
this._cacheSnapshot();
|
||||
this.requestUpdate();
|
||||
} catch {
|
||||
/* a failed refresh just leaves the positions we already had */
|
||||
}
|
||||
}
|
||||
|
||||
private _dirtyPos = new Set<string>();
|
||||
/** Positions sent to the server and not acknowledged yet (HP-1461-02). */
|
||||
private _sentPos = new Map<string, { s?: string; x: number; y: number }>();
|
||||
|
||||
private _persistLayout = debounce(() => {
|
||||
if (this._serverStorage) {
|
||||
@@ -811,9 +911,14 @@ class HouseplanCard extends LitElement {
|
||||
for (const id of ids) {
|
||||
const pos = this._layout[id];
|
||||
if (!pos) continue;
|
||||
// in flight until the server answers: a layout reload triggered in the
|
||||
// meantime must keep this position, not the one the server still has
|
||||
this._sentPos.set(id, pos);
|
||||
this.hass
|
||||
.callWS({ type: 'houseplan/layout/update', device_id: id, pos })
|
||||
.catch((e: any) => this._showToast(this._t('toast.pos_save_failed', { err: this._errText(e) })));
|
||||
.then((r: any) => this._noteLayoutRev(r))
|
||||
.catch((e: any) => this._showToast(this._t('toast.pos_save_failed', { err: this._errText(e) })))
|
||||
.finally(() => { if (this._sentPos.get(id) === pos) this._sentPos.delete(id); });
|
||||
}
|
||||
this._cacheSnapshot();
|
||||
} else {
|
||||
@@ -1530,8 +1635,44 @@ class HouseplanCard extends LitElement {
|
||||
for (const sp of this._serverCfg?.spaces || []) delete (sp as any).segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Config writes are serialized (HP-1454-03).
|
||||
*
|
||||
* The debounce only spaced out the *starts*. If a write took longer than
|
||||
* 500 ms — a busy instance, a slow link — the next edit went out with the
|
||||
* same `expected_rev`, the server accepted the first and rejected the second
|
||||
* as a conflict, and the conflict handler reloaded the server copy over the
|
||||
* local one. The user's second edit was gone, with a toast that blamed
|
||||
* another window when there was none.
|
||||
*
|
||||
* One chain, one write in flight. A write always reads `_serverCfg` at the
|
||||
* moment it runs, so edits made while another write was out are carried by
|
||||
* the next one, with the revision that write returned.
|
||||
*/
|
||||
private _writesPending = 0;
|
||||
private _writeChain: Promise<void> = Promise.resolve();
|
||||
|
||||
/** A config write is in flight — the card must not adopt a server revision. */
|
||||
private _cfgWriting = false;
|
||||
private get _cfgWriting(): boolean {
|
||||
return this._writesPending > 0;
|
||||
}
|
||||
|
||||
private _writeConfig(): Promise<void> {
|
||||
this._writesPending++;
|
||||
this._writeChain = this._writeChain
|
||||
.catch(() => undefined) // a failed write must not poison the queue
|
||||
.then(async () => {
|
||||
if (!this._serverCfg) return;
|
||||
this._dropLegacySegments();
|
||||
const r = await this.hass.callWS({
|
||||
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
|
||||
});
|
||||
this._cfgRev = r?.rev ?? this._cfgRev + 1;
|
||||
});
|
||||
const mine = this._writeChain.finally(() => { this._writesPending--; });
|
||||
// keep the chain itself unadorned so the next link waits for the write only
|
||||
return mine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every mutation path ends here, so this is the one place that can invalidate
|
||||
@@ -1546,24 +1687,16 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
private _saveConfigDebounced = debounce(() => {
|
||||
if (!this._serverCfg) return;
|
||||
this._dropLegacySegments();
|
||||
this._cfgWriting = true;
|
||||
this.hass
|
||||
.callWS({ type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev })
|
||||
.then((r: any) => {
|
||||
this._cfgRev = r?.rev ?? this._cfgRev + 1;
|
||||
this._cfgWriting = false;
|
||||
})
|
||||
.catch((e: any) => {
|
||||
this._cfgWriting = false;
|
||||
if (e?.code === 'conflict') {
|
||||
this._showToast(this._t('toast.conflict'));
|
||||
this._cancelPath();
|
||||
this._reloadConfigOnly(true);
|
||||
} else {
|
||||
this._showToast(this._t('toast.cfg_save_failed', { err: this._errText(e) }));
|
||||
}
|
||||
});
|
||||
this._writeConfig().catch((e: any) => {
|
||||
if (e?.code === 'conflict') {
|
||||
// a real one now: another window wrote between our read and our write
|
||||
this._showToast(this._t('toast.conflict'));
|
||||
this._cancelPath();
|
||||
this._reloadConfigOnly(true);
|
||||
} else {
|
||||
this._showToast(this._t('toast.cfg_save_failed', { err: this._errText(e) }));
|
||||
}
|
||||
});
|
||||
}, 500);
|
||||
|
||||
/**
|
||||
@@ -1961,21 +2094,24 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
/** All open-boundary pairs of the current space with their shared segments. */
|
||||
private _openPairsCache: { key: string; pairs: { a: RoomCfg; b: RoomCfg; segs: number[][] }[] } | null = null;
|
||||
private _openPairsCache: { model: SpaceModel; pairs: { a: RoomCfg; b: RoomCfg; segs: number[][] }[] } | null = null;
|
||||
|
||||
private _openPairs(): { a: RoomCfg; b: RoomCfg; segs: number[][] }[] {
|
||||
// audit L1: this used to run once PER ROOM on every render (O(rooms^3)
|
||||
// collinear-overlap math on every HA state push). Memoized on the config
|
||||
// epoch + current space.
|
||||
// The key includes the open_to links themselves: _serverCfg is mutated in
|
||||
// place in ~22 places (audit L7), so an epoch counter alone is not a
|
||||
// trustworthy cache key — a missed bump would render a stale plan, which is
|
||||
// far worse than recomputing a short string here.
|
||||
// collinear-overlap math on every HA state push), so it is memoized.
|
||||
//
|
||||
// The key is the SPACE MODEL OBJECT ITSELF (HP-1454-04). It used to be a
|
||||
// string of room ids and open_to links, which said nothing about geometry:
|
||||
// change the space's aspect, or drag a vertex, and the shared segments were
|
||||
// recomputed for the outlines but the open boundaries — and the glow cuts
|
||||
// that follow them — kept their old coordinates until a full reload.
|
||||
// `_model` is already rebuilt whenever the epoch or the config fingerprint
|
||||
// moves, and everything below derives from it, so its identity is an exact
|
||||
// and cheaper key. One cache invalidation strategy, not two.
|
||||
const sp = this._spaceModel();
|
||||
const key = this._space + '|' + sp.rooms.map((r) => r.id + ':' + ((r as any).open_to || []).join(',')).join(';');
|
||||
if (this._openPairsCache && this._openPairsCache.key === key) return this._openPairsCache.pairs;
|
||||
if (this._openPairsCache && this._openPairsCache.model === sp) return this._openPairsCache.pairs;
|
||||
const pairs = this._computeOpenPairs();
|
||||
this._openPairsCache = { key, pairs };
|
||||
this._openPairsCache = { model: sp, pairs };
|
||||
return pairs;
|
||||
}
|
||||
|
||||
@@ -2508,6 +2644,7 @@ class HouseplanCard extends LitElement {
|
||||
tapAction: '', defaultTap: 'info', controls: [], controlsFilter: '', isLight: false,
|
||||
glowRadius: '', model: '',
|
||||
link: '', description: '', pdfs: [], room: '', busy: false,
|
||||
uploadId: 'up_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2619,7 +2756,7 @@ class HouseplanCard extends LitElement {
|
||||
const files = input.files ? [...input.files] : [];
|
||||
input.value = '';
|
||||
if (!files.length || !this._markerDialog) return;
|
||||
const mid = this._markerDialog.devId || 'new';
|
||||
const mid = this._markerDialog.uploadId || this._markerDialog.devId || 'new';
|
||||
const uploaded: PdfRef[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
@@ -2728,13 +2865,16 @@ class HouseplanCard extends LitElement {
|
||||
// stored config still resolve — the files never left. A failed copy
|
||||
// leaves the urls untouched and tells the user (review CR-3).
|
||||
let cleanupOldFiles = false;
|
||||
if (oldId && oldId !== id && marker.pdfs?.length) {
|
||||
// a new icon uploaded into its own staging folder; an edited one into its
|
||||
// own id. Either way the files move to the final id here.
|
||||
const fileSrc = dlg.uploadId || oldId;
|
||||
if (fileSrc && fileSrc !== id && marker.pdfs?.length) {
|
||||
try {
|
||||
const res: any = await this.hass.callWS({
|
||||
type: 'houseplan/files/migrate', from_id: oldId, to_id: id,
|
||||
type: 'houseplan/files/migrate', from_id: fileSrc, to_id: id,
|
||||
});
|
||||
const mapping = res?.mapping || {};
|
||||
marker.pdfs = migratePdfUrls(marker.pdfs, oldId, id, mapping);
|
||||
marker.pdfs = migratePdfUrls(marker.pdfs, fileSrc, id, mapping);
|
||||
cleanupOldFiles = Object.keys(mapping).length > 0;
|
||||
} catch (e: any) {
|
||||
this._showToast(this._t('toast.files_migrate_failed', { err: this._errText(e) }));
|
||||
@@ -2779,16 +2919,17 @@ class HouseplanCard extends LitElement {
|
||||
this._layout = { ...this._layout, [id]: newPos };
|
||||
}
|
||||
await this._saveConfigNow();
|
||||
if (newPos) await this.hass.callWS({ type: 'houseplan/layout/update', device_id: id, pos: newPos });
|
||||
if (newPos) this._noteLayoutRev(await this.hass.callWS({ type: 'houseplan/layout/update', device_id: id, pos: newPos }));
|
||||
if (oldId && oldId !== id) {
|
||||
// rebinding changed the icon id — clean up the old position
|
||||
delete this._layout[oldId];
|
||||
await this.hass.callWS({ type: 'houseplan/layout/delete', device_id: oldId }).catch(() => undefined);
|
||||
await this.hass.callWS({ type: 'houseplan/layout/delete', device_id: oldId })
|
||||
.then((r: any) => this._noteLayoutRev(r)).catch(() => undefined);
|
||||
}
|
||||
// the config is committed — now it is safe to drop the old folder
|
||||
if (cleanupOldFiles && oldId) {
|
||||
if (cleanupOldFiles && fileSrc) {
|
||||
await this.hass
|
||||
.callWS({ type: 'houseplan/files/cleanup', marker_id: oldId })
|
||||
.callWS({ type: 'houseplan/files/cleanup', marker_id: fileSrc })
|
||||
.catch(() => undefined); // leftovers are harmless; broken links are not
|
||||
}
|
||||
this._markerDialog = null;
|
||||
@@ -2833,7 +2974,8 @@ class HouseplanCard extends LitElement {
|
||||
if (d && d.bindingKind === 'virtual' && this._layout[d.id]) {
|
||||
// the virtual one is deleted for good → its position is no longer needed
|
||||
delete this._layout[d.id];
|
||||
await this.hass.callWS({ type: 'houseplan/layout/delete', device_id: d.id }).catch(() => undefined);
|
||||
await this.hass.callWS({ type: 'houseplan/layout/delete', device_id: d.id })
|
||||
.then((r: any) => this._noteLayoutRev(r)).catch(() => undefined);
|
||||
}
|
||||
this._markerDialog = null;
|
||||
this._regSignature = '';
|
||||
@@ -3048,25 +3190,14 @@ class HouseplanCard extends LitElement {
|
||||
user's retry starts from the fresh config instead of hitting the same
|
||||
conflict again. */
|
||||
private async _saveConfigNow(): Promise<void> {
|
||||
this._dropLegacySegments();
|
||||
this._cfgEpoch++;
|
||||
// same flag the debounced writer uses: while it is set, an incoming
|
||||
// `houseplan_config_updated` defers its reload instead of replacing the
|
||||
// config under an unfinished write (audit L2, extended to this path)
|
||||
this._cfgWriting = true;
|
||||
try {
|
||||
const r = await this.hass.callWS({
|
||||
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
|
||||
});
|
||||
this._cfgRev = r?.rev ?? this._cfgRev + 1;
|
||||
// same queue as the debounced writer: a dialog saving while a background
|
||||
// write is still out must not race it into a self-inflicted conflict
|
||||
await this._writeConfig();
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'conflict') {
|
||||
this._cfgWriting = false;
|
||||
await this._reloadConfigOnly();
|
||||
}
|
||||
if (e?.code === 'conflict') await this._reloadConfigOnly();
|
||||
throw e;
|
||||
} finally {
|
||||
this._cfgWriting = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-4
@@ -537,10 +537,13 @@ export type TapAction = 'info' | 'more-info' | 'toggle';
|
||||
*
|
||||
* `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 — the feature
|
||||
* was unusable for a year and a half and only surfaced through a user's error
|
||||
* message (issue #3, 2026-07-27). The lists are exported so a backend test can
|
||||
* read them and assert the schema accepts every value a user can pick.
|
||||
* 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;
|
||||
|
||||
+26
-9
@@ -114,23 +114,33 @@ export class ContentSigner {
|
||||
hass
|
||||
.callWS({ type: 'houseplan/content/sign', paths: batch })
|
||||
.then((r: any) => {
|
||||
for (const p of batch) this.retry.delete(p);
|
||||
if (!r?.urls || this.disposed) return;
|
||||
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 };
|
||||
for (const [k, v] of Object.entries<string>(r.urls)) next[k] = { url: v, at };
|
||||
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
|
||||
const now = this.now();
|
||||
for (const p of batch) {
|
||||
const prev = this.retry.get(p)?.delay || 0;
|
||||
const delay = Math.min(SIGN_BACKOFF_MAX_MS, prev ? prev * 2 : SIGN_BACKOFF_MIN_MS);
|
||||
this.retry.set(p, { notBefore: now + delay, delay });
|
||||
}
|
||||
for (const p of batch) this.backOff(p);
|
||||
})
|
||||
.finally(() => {
|
||||
// release only our own attempt: a later one may have superseded it
|
||||
@@ -139,6 +149,13 @@ export class ContentSigner {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+8
-6
@@ -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';
|
||||
@@ -73,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),
|
||||
|
||||
+4
-2
@@ -798,8 +798,10 @@ test('migratePdfUrls: only confirmed copies are rewritten (review CR-3)', () =>
|
||||
{ name: 'b.pdf', url: '/houseplan_files/files/v_old1/b.pdf?v=2' },
|
||||
];
|
||||
// сервер скопировал только a.pdf, причём переименовал из-за коллизии
|
||||
const out = migratePdfUrls(pdfs, 'v_old1', 'dev99', { 'a.pdf': 'a (2).pdf' });
|
||||
assert.equal(out[0].url, '/houseplan_files/files/dev99/a%20(2).pdf?v=1');
|
||||
// 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),
|
||||
|
||||
@@ -183,3 +183,63 @@ test('dispose(): a late answer neither renders nor throws, start() revives it',
|
||||
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:
|
||||
|
||||
@@ -504,3 +504,412 @@ async def test_content_signed_path_opens_without_a_bearer_header(
|
||||
)
|
||||
resp2 = await client.receive_json()
|
||||
assert resp2["success"] and resp2["result"]["urls"] == {}
|
||||
|
||||
|
||||
async def test_signing_one_path_may_fail_without_failing_the_request(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch
|
||||
) -> None:
|
||||
"""review R5-1: pin the contract the card now codes against.
|
||||
|
||||
One unsignable path must NOT fail the whole call — a single bad url would
|
||||
otherwise block the signatures of every other file in the batch. The answer
|
||||
is a partial map, and the card treats a path missing from it as a failure
|
||||
for that path (backing off) rather than as success.
|
||||
"""
|
||||
from custom_components.houseplan import websocket_api as wsapi
|
||||
from custom_components.houseplan.const import CONTENT_URL
|
||||
|
||||
await _setup(hass)
|
||||
good = f"{CONTENT_URL}/plans/_/good.png"
|
||||
bad = f"{CONTENT_URL}/plans/_/bad.png"
|
||||
|
||||
real = wsapi.async_sign_path if hasattr(wsapi, "async_sign_path") else None
|
||||
assert real is None # imported inside the handler, so patch the source module
|
||||
|
||||
import homeassistant.components.http.auth as ha_auth
|
||||
|
||||
original = ha_auth.async_sign_path
|
||||
|
||||
def _sign(hass_, *args, **kwargs):
|
||||
path = next((a for a in args if isinstance(a, str) and a.startswith("/")), "")
|
||||
if path == bad:
|
||||
raise ValueError("cannot sign this one")
|
||||
return original(hass_, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(ha_auth, "async_sign_path", _sign)
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json_auto_id({"type": "houseplan/content/sign", "paths": [good, bad]})
|
||||
resp = await client.receive_json()
|
||||
assert resp["success"], "one bad path must not fail the batch"
|
||||
urls = resp["result"]["urls"]
|
||||
assert good in urls and "authSig=" in urls[good]
|
||||
assert bad not in urls, "an unsignable path is absent, never an unsigned url"
|
||||
|
||||
|
||||
async def test_config_write_is_capped_by_total_size(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""HP-1454-05: per-field limits bound each list, this bounds their product."""
|
||||
from custom_components.houseplan.validation import MAX_CONFIG_BYTES, MAX_TEXT
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
cfg = await _cfg([{"id": "f1", "plan_url": None}])
|
||||
# every field inside the caps, the whole thing far past them
|
||||
blob = "d" * MAX_TEXT
|
||||
cfg["settings"] = {"known_devices": [blob] * (MAX_CONFIG_BYTES // MAX_TEXT + 100)}
|
||||
resp = await _save(client, cfg, 0)
|
||||
assert not resp["success"] and resp["error"]["code"] == "too_large"
|
||||
|
||||
cfg["settings"] = {"known_devices": ["ok"]}
|
||||
assert (await _save(client, cfg, 0))["success"]
|
||||
|
||||
|
||||
async def test_layout_keeps_its_revision_and_announces_changes(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""HP-1454-08: point-wise writes used to drop the revision and say nothing.
|
||||
|
||||
layout/set offered optimistic locking, but every drag wrote {"layout": …}
|
||||
and reset the counter to 0, so the lock protected nothing; and a static card
|
||||
on the same dashboard never learned that a marker had moved.
|
||||
"""
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
events: list[dict] = []
|
||||
hass.bus.async_listen("houseplan_layout_updated", lambda ev: events.append(ev.data))
|
||||
|
||||
await client.send_json_auto_id({"type": "houseplan/layout/get"})
|
||||
assert (await client.receive_json())["result"]["rev"] == 0
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{"type": "houseplan/layout/set", "layout": {"a": {"x": 1, "y": 2}}, "expected_rev": 0}
|
||||
)
|
||||
rev = (await client.receive_json())["result"]["rev"]
|
||||
assert rev == 1
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{"type": "houseplan/layout/update", "device_id": "b", "pos": {"x": 3, "y": 4}}
|
||||
)
|
||||
assert (await client.receive_json())["result"]["rev"] == 2
|
||||
|
||||
await client.send_json_auto_id({"type": "houseplan/layout/delete", "device_id": "b"})
|
||||
assert (await client.receive_json())["result"]["rev"] == 3
|
||||
|
||||
await client.send_json_auto_id({"type": "houseplan/layout/get"})
|
||||
got = await client.receive_json()
|
||||
assert got["result"]["rev"] == 3 and got["result"]["layout"] == {"a": {"x": 1, "y": 2}}
|
||||
|
||||
# a stale wholesale write is refused, which it could not be before
|
||||
await client.send_json_auto_id(
|
||||
{"type": "houseplan/layout/set", "layout": {}, "expected_rev": 1}
|
||||
)
|
||||
bad = await client.receive_json()
|
||||
assert not bad["success"] and bad["error"]["code"] == "conflict"
|
||||
|
||||
await hass.async_block_till_done()
|
||||
assert [e["rev"] for e in events] == [1, 2, 3]
|
||||
|
||||
|
||||
async def test_uploaded_svg_is_sandboxed_and_a_pdf_is_not(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_client
|
||||
) -> None:
|
||||
"""HP-1454-01: user SVG served from HA's origin must not be a live document.
|
||||
|
||||
Only SVG gets the header: a CSP on a PDF response can break the browser's
|
||||
built-in viewer, and a raster image cannot execute anything anyway.
|
||||
"""
|
||||
import os
|
||||
|
||||
from custom_components.houseplan.const import CONTENT_URL, FILES_DIR, PLANS_DIR
|
||||
|
||||
await _setup(hass)
|
||||
plans = hass.config.path(PLANS_DIR)
|
||||
files = os.path.join(hass.config.path(FILES_DIR), "m1")
|
||||
|
||||
def _write() -> None:
|
||||
os.makedirs(plans, exist_ok=True)
|
||||
os.makedirs(files, exist_ok=True)
|
||||
with open(os.path.join(plans, "x.svg"), "wb") as fh:
|
||||
fh.write(b"<svg xmlns='http://www.w3.org/2000/svg'/>")
|
||||
with open(os.path.join(plans, "x.png"), "wb") as fh:
|
||||
fh.write(b"PNG")
|
||||
with open(os.path.join(files, "m.pdf"), "wb") as fh:
|
||||
fh.write(b"%PDF-1.4")
|
||||
|
||||
await hass.async_add_executor_job(_write)
|
||||
http = await hass_client()
|
||||
|
||||
svg = await http.get(f"{CONTENT_URL}/plans/_/x.svg")
|
||||
assert svg.status == 200
|
||||
csp = svg.headers.get("Content-Security-Policy", "")
|
||||
assert "sandbox" in csp and "script-src 'none'" in csp
|
||||
assert svg.headers["Content-Type"].startswith("image/svg+xml")
|
||||
|
||||
png = await http.get(f"{CONTENT_URL}/plans/_/x.png")
|
||||
assert png.status == 200 and "Content-Security-Policy" not in png.headers
|
||||
|
||||
pdf = await http.get(f"{CONTENT_URL}/files/m1/m.pdf")
|
||||
assert pdf.status == 200 and "Content-Security-Policy" not in pdf.headers
|
||||
assert await pdf.read() == b"%PDF-1.4"
|
||||
|
||||
|
||||
async def test_upload_never_overwrites_an_existing_attachment(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_client
|
||||
) -> None:
|
||||
"""HP-1454-02: an upload is not part of the config transaction.
|
||||
|
||||
Writing straight to `<marker>/<filename>` meant a cancelled dialog — or a
|
||||
rejected save — left the stored url serving the new bytes. And two new
|
||||
markers both uploading `manual.pdf` shared one physical file.
|
||||
"""
|
||||
import os
|
||||
|
||||
from custom_components.houseplan.const import CONTENT_URL, FILES_DIR
|
||||
|
||||
await _setup(hass)
|
||||
http = await hass_client()
|
||||
|
||||
async def upload(marker_id: str, name: str, data: bytes) -> str:
|
||||
import aiohttp
|
||||
|
||||
writer = aiohttp.FormData()
|
||||
writer.add_field("marker_id", marker_id)
|
||||
writer.add_field("file", data, filename=name)
|
||||
resp = await http.post("/api/houseplan/upload", data=writer)
|
||||
assert resp.status == 200, await resp.text()
|
||||
return (await resp.json())["url"]
|
||||
|
||||
first = await upload("m9", "manual.pdf", b"ONE")
|
||||
second = await upload("m9", "manual.pdf", b"TWO")
|
||||
assert first != second, "the second upload must not take the first name"
|
||||
|
||||
folder = os.path.join(hass.config.path(FILES_DIR), "m9")
|
||||
# the HA test config dir is shared across the module — hence our own marker id
|
||||
names = sorted(
|
||||
n for n in await hass.async_add_executor_job(os.listdir, folder) if n.startswith("manual")
|
||||
)
|
||||
assert names == ["manual-2.pdf", "manual.pdf"]
|
||||
|
||||
got = await http.get(first.replace(CONTENT_URL, CONTENT_URL))
|
||||
assert await got.read() == b"ONE", "the first file is untouched"
|
||||
got2 = await http.get(second)
|
||||
assert await got2.read() == b"TWO"
|
||||
|
||||
|
||||
async def test_upload_leaves_no_temporary_behind(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_client, monkeypatch
|
||||
) -> None:
|
||||
"""HP-1460-02: every exit path must take its temporary file with it.
|
||||
|
||||
The streaming rewrite kept one `tmp_path` and cleaned it in an
|
||||
`except Exception`, which cancellation (a BaseException) walks straight
|
||||
past — and the attachment collector only ever looks inside marker folders,
|
||||
so a stranded `.upload-*` was never seen again.
|
||||
"""
|
||||
import os
|
||||
|
||||
from custom_components.houseplan import http_api
|
||||
from custom_components.houseplan.const import FILES_DIR
|
||||
from custom_components.houseplan.plans import TMP_PREFIX
|
||||
|
||||
await _setup(hass)
|
||||
http = await hass_client()
|
||||
root = hass.config.path(FILES_DIR)
|
||||
|
||||
def temps() -> list[str]:
|
||||
return [n for n in os.listdir(root) if n.startswith(TMP_PREFIX)]
|
||||
|
||||
import aiohttp
|
||||
|
||||
def form(*files, marker="m8"):
|
||||
w = aiohttp.FormData()
|
||||
w.add_field("marker_id", marker)
|
||||
for name, data in files:
|
||||
w.add_field("file", data, filename=name)
|
||||
return w
|
||||
|
||||
# two file parts: refused, and nothing left over
|
||||
resp = await http.post("/api/houseplan/upload", data=form(("a.pdf", b"A"), ("b.pdf", b"B")))
|
||||
assert resp.status == 400 and (await resp.json())["error"] == "one_file_only"
|
||||
assert temps() == []
|
||||
|
||||
# a rejected extension after the temporary already exists
|
||||
resp = await http.post("/api/houseplan/upload", data=form(("evil.exe", b"X")))
|
||||
assert resp.status == 400
|
||||
assert temps() == []
|
||||
|
||||
# promotion itself blows up
|
||||
real = http_api.reserve_filename
|
||||
|
||||
def _boom(*_a, **_k):
|
||||
raise OSError("disk on fire")
|
||||
|
||||
monkeypatch.setattr(http_api, "reserve_filename", _boom)
|
||||
resp = await http.post("/api/houseplan/upload", data=form(("c.pdf", b"C")))
|
||||
assert resp.status == 500
|
||||
assert temps() == [], "a failed promotion must not strand the upload"
|
||||
monkeypatch.setattr(http_api, "reserve_filename", real)
|
||||
|
||||
# and the happy path leaves nothing either
|
||||
resp = await http.post("/api/houseplan/upload", data=form(("d.pdf", b"D")))
|
||||
assert resp.status == 200
|
||||
assert temps() == []
|
||||
|
||||
|
||||
async def test_repair_issue_goes_when_its_space_does(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""HP-1454-09: the cleanup used to walk only spaces that still exist.
|
||||
|
||||
So deleting or renaming a space with a missing plan left its warning in
|
||||
Repairs with nothing able to clear it.
|
||||
"""
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from custom_components.houseplan.const import DOMAIN as HP_DOMAIN
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
registry = ir.async_get(hass)
|
||||
|
||||
gone = "/api/houseplan/content/plans/_/nosuchfile.png"
|
||||
rev = (await _save(client, await _cfg([{"id": "r7", "plan_url": gone}]), 0))["result"]["rev"]
|
||||
await hass.async_block_till_done()
|
||||
assert registry.async_get_issue(HP_DOMAIN, "broken_plan_r7") is not None
|
||||
|
||||
# the space is deleted entirely — the warning must not outlive it
|
||||
await _save(client, await _cfg([{"id": "other", "plan_url": None}]), rev)
|
||||
await hass.async_block_till_done()
|
||||
assert registry.async_get_issue(HP_DOMAIN, "broken_plan_r7") is None
|
||||
|
||||
|
||||
async def test_cancelling_an_upload_takes_its_temporary_with_it(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""HP-1460-02, properly this time: cancellation, not an ordinary error.
|
||||
|
||||
`asyncio.CancelledError` is a BaseException, so the old `except Exception`
|
||||
never saw it and an aborted transfer stranded its `.upload-*`. The previous
|
||||
test claimed to cover this and did not — it only exercised error paths.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from custom_components.houseplan.const import FILES_DIR
|
||||
from custom_components.houseplan.http_api import HouseplanUploadView
|
||||
from custom_components.houseplan.plans import TMP_PREFIX
|
||||
|
||||
entry = await _setup(hass)
|
||||
root = hass.config.path(FILES_DIR)
|
||||
os.makedirs(root, exist_ok=True)
|
||||
|
||||
started = asyncio.Event()
|
||||
|
||||
class _Part:
|
||||
name = "file"
|
||||
filename = "big.pdf"
|
||||
|
||||
async def read_chunk(self, _size):
|
||||
started.set()
|
||||
await asyncio.sleep(3600) # the client stopped sending; we wait
|
||||
|
||||
class _Reader:
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
if getattr(self, "_done", False):
|
||||
raise StopAsyncIteration
|
||||
self._done = True
|
||||
return _Part()
|
||||
|
||||
from custom_components.houseplan import http_api as hp_http
|
||||
|
||||
class _User:
|
||||
is_admin = True
|
||||
|
||||
class _Request:
|
||||
app = {hp_http.KEY_HASS: hass}
|
||||
|
||||
def get(self, _key, default=None):
|
||||
return _User()
|
||||
|
||||
async def multipart(self):
|
||||
return _Reader()
|
||||
|
||||
task = hass.async_create_task(HouseplanUploadView().post(_Request()))
|
||||
await started.wait()
|
||||
await asyncio.sleep(0)
|
||||
assert [n for n in os.listdir(root) if n.startswith(TMP_PREFIX)], "temp exists mid-upload"
|
||||
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert [n for n in os.listdir(root) if n.startswith(TMP_PREFIX)] == [], (
|
||||
"a cancelled upload must not leave its temporary behind"
|
||||
)
|
||||
assert entry
|
||||
|
||||
|
||||
async def test_scheduled_sweep_collects_what_no_commit_will(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""HP-1461-01: a commit collects what it superseded — but only when it runs.
|
||||
|
||||
Cancel a dialog after the file uploaded, or lose the connection right after,
|
||||
and nothing references the file and no future write notices it. The daily
|
||||
sweep used to remove only streaming temporaries, so the promise that a
|
||||
cancelled attachment goes after an hour did not hold on an instance nobody
|
||||
edits.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
|
||||
from custom_components.houseplan.const import FILES_DIR, PLANS_DIR, PLAN_ORPHAN_TTL_S
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
files = hass.config.path(FILES_DIR)
|
||||
plans = hass.config.path(PLANS_DIR)
|
||||
old = time.time() - PLAN_ORPHAN_TTL_S - 60
|
||||
|
||||
def _seed() -> None:
|
||||
os.makedirs(os.path.join(files, "m5"), exist_ok=True)
|
||||
os.makedirs(os.path.join(files, "up_cancelled"), exist_ok=True)
|
||||
os.makedirs(plans, exist_ok=True)
|
||||
for path in (
|
||||
os.path.join(files, "m5", "kept.pdf"),
|
||||
os.path.join(files, "up_cancelled", "manual.pdf"),
|
||||
os.path.join(plans, "s5.tok.png"),
|
||||
os.path.join(plans, "s5.orphan.png"),
|
||||
):
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(b"x")
|
||||
os.utime(path, (old, old))
|
||||
|
||||
await hass.async_add_executor_job(_seed)
|
||||
|
||||
cfg = await _cfg([{"id": "s5", "plan_url": "/api/houseplan/content/plans/_/s5.tok.png"}])
|
||||
cfg["markers"] = [
|
||||
{"id": "m5", "binding": "virtual",
|
||||
"pdfs": [{"name": "k", "url": "/api/houseplan/content/files/m5/kept.pdf"}]}
|
||||
]
|
||||
assert (await _save(client, cfg, 0))["success"]
|
||||
|
||||
# reload the entry: that is what runs the sweep at startup
|
||||
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
assert await hass.config_entries.async_reload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
exists = lambda p: os.path.isfile(p) # noqa: E731
|
||||
assert await hass.async_add_executor_job(exists, os.path.join(files, "m5", "kept.pdf"))
|
||||
assert await hass.async_add_executor_job(exists, os.path.join(plans, "s5.tok.png"))
|
||||
assert not await hass.async_add_executor_job(
|
||||
exists, os.path.join(files, "up_cancelled", "manual.pdf")
|
||||
), "an aged cancelled attachment is collected without any further config write"
|
||||
assert not await hass.async_add_executor_job(exists, os.path.join(plans, "s5.orphan.png"))
|
||||
assert not await hass.async_add_executor_job(os.path.isdir, os.path.join(files, "up_cancelled"))
|
||||
|
||||
@@ -381,3 +381,174 @@ def test_every_room_fill_mode_the_editor_offers_is_accepted():
|
||||
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_supersedes_and_ages(tmp_path):
|
||||
import os
|
||||
import time
|
||||
|
||||
collect_attachments = plans.collect_attachments
|
||||
files = tmp_path / "files"
|
||||
(files / "m1").mkdir(parents=True)
|
||||
for n in ("old.pdf", "new.pdf", "cancelled.pdf"):
|
||||
(files / "m1" / n).write_bytes(b"x")
|
||||
|
||||
# the commit swapped old.pdf for new.pdf; cancelled.pdf is a fresh upload
|
||||
# nobody saved — it may belong to a dialog that is still open
|
||||
removed = collect_attachments(files, _acfg("m1/old.pdf"), _acfg("m1/new.pdf"))
|
||||
assert removed == 1
|
||||
assert not (files / "m1" / "old.pdf").exists()
|
||||
assert (files / "m1" / "new.pdf").is_file()
|
||||
assert (files / "m1" / "cancelled.pdf").is_file()
|
||||
|
||||
old = time.time() - const.PLAN_ORPHAN_TTL_S - 60
|
||||
os.utime(files / "m1" / "cancelled.pdf", (old, old))
|
||||
assert collect_attachments(files, _acfg("m1/new.pdf"), _acfg("m1/new.pdf")) == 1
|
||||
assert not (files / "m1" / "cancelled.pdf").exists()
|
||||
assert (files / "m1" / "new.pdf").is_file()
|
||||
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user