mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 19:28:46 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9282c28830 | ||
|
|
8c5d5ba5c5 | ||
|
|
6c90e03427 | ||
|
|
9b180c5917 | ||
|
|
3084472c75 | ||
|
|
c00048611e | ||
|
|
f5e6c0318d | ||
|
|
e1e730560d | ||
|
|
94b298962a | ||
|
|
f7fe63776a | ||
|
|
01bc4f9711 | ||
|
|
85491d0fea | ||
|
|
d37a67c29f | ||
|
|
a66272c6f4 | ||
|
|
f4af2fe508 | ||
|
|
8e07e3c958 | ||
|
|
9868f1035f | ||
|
|
f2c9b07cc1 | ||
|
|
2c7a2f849d | ||
|
|
33e71ca96c | ||
|
|
7128ab504d | ||
|
|
f953a3c286 | ||
|
|
ef270d11b7 | ||
|
|
dc24390222 | ||
|
|
254354bf56 | ||
|
|
c9a60a110d | ||
|
|
75279308c1 | ||
|
|
b7ae3e7adf | ||
|
|
379fb68db2 | ||
|
|
96a70495d3 | ||
|
|
d3db9e30e6 | ||
|
|
2e731debd9 | ||
|
|
a49b5e6d2e | ||
|
|
4418312b0b | ||
|
|
3f719cc32a | ||
|
|
260615a63f | ||
|
|
e4e300adaa | ||
|
|
96d387ff1d | ||
|
|
8b531db3f5 | ||
|
|
68aa1f04ba | ||
|
|
3d41fe16b8 |
@@ -281,7 +281,7 @@ Services → House Plan**.
|
||||
|
||||
**Do I need to write anything in YAML?** No. The only line is adding the card to the dashboard; everything else is done with the mouse.
|
||||
|
||||
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. If the device exists but is hidden by curation (bridges, service records, duplicates) — enable the **👁 "Show all devices"** button in the header.
|
||||
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. If the device exists but is hidden by filtering (bridges, service records, duplicates) — enable the **👁 "Show all devices"** button in the header.
|
||||
|
||||
**Can I hide an unwanted device or rename it?** Yes — click the device on the plan and press "Edit" in its card: there you can change the name, icon, model or hide the icon.
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.components.frontend import add_extra_js_url
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
from . import websocket_api as hp_ws
|
||||
from .const import (
|
||||
@@ -18,6 +20,8 @@ from .const import (
|
||||
PLANS_URL,
|
||||
VERSION,
|
||||
)
|
||||
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
|
||||
from .plans import collect_attachments, collect_plans, sweep_upload_temps
|
||||
from .repairs import async_check_plan_files
|
||||
from .store import HouseplanConfigEntry, create_data
|
||||
|
||||
@@ -96,7 +100,96 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
module_url, module_url,
|
||||
)
|
||||
|
||||
# One-time move to the square canvas (v1.48.0). Coordinates used to be
|
||||
# normalised against a per-space aspect ratio; the canvas is now always
|
||||
# square and a plan is centred inside it. Nothing about the drawing changes
|
||||
# — the box is padded and the numbers re-expressed against it.
|
||||
# The two stores are written independently, and the lock is no transaction:
|
||||
# a crash between the writes used to leave the config in square coordinates
|
||||
# with the layout still in the old ones — permanently, because the config
|
||||
# write had already deleted the `aspect` fields the layout half needed
|
||||
# (HP-1490-01). So the intent is made durable FIRST, in the layout store,
|
||||
# and each half carries its own trigger with its own write: the config half
|
||||
# removes `aspect`, the layout half removes the saved intent. Whatever
|
||||
# half is missing after a crash, the next start finishes exactly it.
|
||||
async with data.write_lock:
|
||||
stored = await data.config_store.async_load() or {}
|
||||
cfg = stored.get("config")
|
||||
lay_stored = await data.store.async_load() or {}
|
||||
layout = lay_stored.get("layout") or {}
|
||||
pending = {
|
||||
str(k): v for k, v in (lay_stored.get("geom_pending") or {}).items()
|
||||
}
|
||||
merged = {**pending, **pending_from_config(cfg)}
|
||||
if merged:
|
||||
lay_rev = int(lay_stored.get("rev", 0))
|
||||
if merged != pending: # 1. the durable intent, before anything moves
|
||||
await data.store.async_save(
|
||||
{"layout": layout, "rev": lay_rev, "geom_pending": merged}
|
||||
)
|
||||
rev = int(stored.get("rev", 0))
|
||||
if cfg and migrate_config(cfg): # 2. the config half
|
||||
rev += 1
|
||||
await data.config_store.async_save({"config": cfg, "rev": rev})
|
||||
migrate_layout(layout, merged) # 3. the layout half + intent cleared
|
||||
await data.store.async_save({"layout": layout, "rev": lay_rev + 1})
|
||||
_LOGGER.info(
|
||||
"House Plan: migrated %s space(s) to the square canvas", len(merged)
|
||||
)
|
||||
# only once both halves are durable — a client refetching on this
|
||||
# event must never see one migrated half and one old one
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": rev})
|
||||
|
||||
await async_check_plan_files(hass, entry)
|
||||
|
||||
# Scheduled collection of everything nobody ended up referencing.
|
||||
#
|
||||
# A commit collects what that commit superseded, which is the right rule for
|
||||
# a commit — but it only ever runs when somebody saves. Cancel a dialog
|
||||
# after the file has already uploaded, lose the connection after the upload
|
||||
# succeeded, or call the API directly, and the file is unreferenced with no
|
||||
# future write to notice it (HP-1461-01). The earlier version of this sweep
|
||||
# only removed streaming temporaries, which are a different, narrower case.
|
||||
#
|
||||
# Passing the CURRENT configuration as both sides means "nothing was
|
||||
# superseded": every referenced file is preserved and only unreferenced ones
|
||||
# past PLAN_ORPHAN_TTL_S go. It runs under the same lock as a config write,
|
||||
# so it cannot decide from a snapshot that a commit is about to replace.
|
||||
async def _sweep(_now=None) -> None:
|
||||
files_dir = Path(hass.config.path(FILES_DIR))
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
try:
|
||||
# `data` from the closure, NOT get_data(hass): during
|
||||
# async_setup_entry the entry is still SETUP_IN_PROGRESS, so
|
||||
# async_loaded_entries() does not list it and the lookup returned
|
||||
# None. The startup pass then silently degraded to removing
|
||||
# streaming temporaries only, and the real collection waited a full
|
||||
# day — restarting more often than that meant it never ran at all
|
||||
# (HP-1462-01). The callback is unregistered with the entry, so
|
||||
# closing over its runtime data matches the lifecycle exactly.
|
||||
async with data.write_lock:
|
||||
stored = await data.config_store.async_load() or {}
|
||||
cfg = stored.get("config") or {}
|
||||
|
||||
def _collect() -> int:
|
||||
n = sweep_upload_temps(files_dir)
|
||||
# same config on both sides: nothing is superseded, so this
|
||||
# only ever collects what the shared rules call abandoned
|
||||
n += collect_attachments(files_dir, cfg, cfg)
|
||||
n += collect_plans(plans_dir, cfg, cfg)
|
||||
return n
|
||||
|
||||
n = await hass.async_add_executor_job(_collect)
|
||||
if n:
|
||||
_LOGGER.info("House Plan: removed %s unreferenced file(s)", n)
|
||||
except Exception: # noqa: BLE001 — housekeeping must never fail a setup
|
||||
_LOGGER.exception("House Plan: sweeping unreferenced files failed")
|
||||
|
||||
data.sweep = _sweep
|
||||
await _sweep()
|
||||
entry.async_on_unload(
|
||||
async_track_time_interval(hass, _sweep, timedelta(hours=24))
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -17,14 +17,35 @@ CONTENT_URL = "/api/houseplan/content"
|
||||
# way to tell which paths were dropped (review R2-2).
|
||||
MAX_SIGN_PATHS = 200
|
||||
|
||||
# Nothing is ever deleted for being old (docs/SCOPE.md), so growth has to be
|
||||
# stopped at the door instead. These bound the whole store, not one request: by
|
||||
# default any authenticated user may upload, and a per-request cap of 8/50 MB
|
||||
# says nothing about how many requests there are (HP-1470-01).
|
||||
MAX_PLANS_BYTES = 256 * 1024 * 1024
|
||||
MAX_PLANS_FILES = 200
|
||||
# How many the picker asks for at once — newest first.
|
||||
MAX_PLANS_LISTED = 60
|
||||
MAX_FILES_BYTES = 1024 * 1024 * 1024
|
||||
MAX_FILES_COUNT = 1000
|
||||
# Refuse to write when the disk is nearly full: filling the config partition
|
||||
# breaks .storage, the recorder and backups, not just this card.
|
||||
MIN_FREE_BYTES = 512 * 1024 * 1024
|
||||
|
||||
# An uploaded plan that no accepted configuration references is collected only
|
||||
# once it is this old. Age is a race guard, not a policy: a plan uploaded
|
||||
# seconds ago may belong to another client's transaction that has not written
|
||||
# its configuration yet (review R3-1).
|
||||
PLAN_ORPHAN_TTL_S = 3600
|
||||
|
||||
# Kept for compatibility with anything reading it; the collectors no longer use
|
||||
# a long grace at all. Every attempt to age files out ended badly — first by
|
||||
# deleting detached plans, then by racing the save that was about to reference a
|
||||
# retried upload. What is left is deliberately simple: files go when the user's
|
||||
# action says so, plus staging folders after PLAN_ORPHAN_TTL_S.
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.45.2"
|
||||
VERSION = "1.50.0"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,161 @@
|
||||
"""One-time migration to a square canvas — pure, so it can be tested alone.
|
||||
|
||||
Until v1.48.0 a space had an `aspect`, and coordinates were normalised against
|
||||
it: x by the width, y by the HEIGHT. Making every canvas square without touching
|
||||
the numbers would stretch every plan vertically.
|
||||
|
||||
Nothing about the drawing changes here. The canvas is padded to a square —
|
||||
top and bottom for a wide plan, left and right for a tall one — and the
|
||||
coordinates are re-expressed against that larger box. In render units it is a
|
||||
uniform scale plus an offset, so angles, room proportions and relative positions
|
||||
survive exactly. `cell_cm` follows, because the grid is tied to the width: for a
|
||||
tall plan the width grew, so the same wall would otherwise measure less.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def transform_for(aspect: float) -> tuple[float, float, float, float]:
|
||||
"""(dx, dy, kx, ky) that map old normalised coordinates onto the square.
|
||||
|
||||
x' = dx + x * kx, y' = dy + y * ky. Lengths along an axis scale by that
|
||||
axis's factor; both are the same uniform scale in RENDER units, which is
|
||||
why angles are preserved.
|
||||
"""
|
||||
a = float(aspect)
|
||||
if not a or a <= 0:
|
||||
a = 1.0
|
||||
k = min(1.0, a) # how much the old box shrinks inside the square
|
||||
kx = k # x was normalised by the width
|
||||
ky = k / a # y was normalised by the height (= width / aspect)
|
||||
return (1.0 - kx) / 2, (1.0 - ky) / 2, kx, ky
|
||||
|
||||
|
||||
def _pt(p: Any, dx: float, dy: float, kx: float, ky: float) -> Any:
|
||||
if isinstance(p, (list, tuple)) and len(p) >= 2:
|
||||
return [dx + float(p[0]) * kx, dy + float(p[1]) * ky]
|
||||
return p
|
||||
|
||||
|
||||
def migrate_space(space: dict[str, Any]) -> bool:
|
||||
"""Rewrite one space in place. Returns True when anything was changed."""
|
||||
if "aspect" not in space:
|
||||
return False
|
||||
try:
|
||||
aspect = float(space.get("aspect") or 1)
|
||||
except (TypeError, ValueError):
|
||||
aspect = 1.0
|
||||
dx, dy, kx, ky = transform_for(aspect)
|
||||
space.pop("aspect", None)
|
||||
|
||||
for room in space.get("rooms") or []:
|
||||
if room.get("x") is not None:
|
||||
room["x"] = dx + float(room["x"]) * kx
|
||||
if room.get("y") is not None:
|
||||
room["y"] = dy + float(room["y"]) * ky
|
||||
if room.get("w") is not None:
|
||||
room["w"] = float(room["w"]) * kx
|
||||
if room.get("h") is not None:
|
||||
room["h"] = float(room["h"]) * ky
|
||||
if room.get("poly"):
|
||||
room["poly"] = [_pt(p, dx, dy, kx, ky) for p in room["poly"]]
|
||||
|
||||
for op in space.get("openings") or []:
|
||||
op["x"] = dx + float(op.get("x", 0)) * kx
|
||||
op["y"] = dy + float(op.get("y", 0)) * ky
|
||||
# a length is measured along the wall, and the render scale is uniform
|
||||
if op.get("length") is not None:
|
||||
op["length"] = float(op["length"]) * kx
|
||||
|
||||
for shape in space.get("decor") or []:
|
||||
for a, b, fx, fy in (("x1", "y1", kx, ky), ("x2", "y2", kx, ky), ("x", "y", kx, ky)):
|
||||
if shape.get(a) is not None:
|
||||
shape[a] = dx + float(shape[a]) * fx
|
||||
if shape.get(b) is not None:
|
||||
shape[b] = dy + float(shape[b]) * fy
|
||||
if shape.get("w") is not None:
|
||||
shape["w"] = float(shape["w"]) * kx
|
||||
if shape.get("h") is not None:
|
||||
shape["h"] = float(shape["h"]) * ky
|
||||
|
||||
# The viewport becomes the whole square rather than the transformed old
|
||||
# rectangle. It is what the grid is drawn over and what "fit to screen"
|
||||
# fits, so keeping the old box would leave the new margins outside the
|
||||
# canvas — no dots, nothing to draw on — which is exactly the room this
|
||||
# change was meant to give.
|
||||
space["view_box"] = [0.0, 0.0, 1.0, 1.0]
|
||||
|
||||
# The grid pitch is a fraction of the WIDTH. A tall plan just got a wider
|
||||
# canvas, so a wall now covers fewer cells; without this every measurement
|
||||
# in the plan would silently shrink.
|
||||
if kx != 1:
|
||||
try:
|
||||
cell = float(space.get("cell_cm") or 5)
|
||||
except (TypeError, ValueError):
|
||||
cell = 5.0
|
||||
space["cell_cm"] = round(cell / kx, 4)
|
||||
|
||||
# The image keeps its own proportions and is centred; the space no longer
|
||||
# has any of its own.
|
||||
if space.get("plan_url") and not space.get("plan_aspect"):
|
||||
space["plan_aspect"] = round(aspect, 6)
|
||||
return True
|
||||
|
||||
|
||||
def pending_from_config(config: dict[str, Any] | None) -> dict[str, float]:
|
||||
"""{space_id: old aspect} for every space still carrying one.
|
||||
|
||||
This is the migration INTENT. The two stores are written independently and
|
||||
either write can fail, so the intent has to survive on its own: it is saved
|
||||
into the layout store BEFORE anything changes (HP-1490-01), and cleared by
|
||||
the same write that stores the migrated layout. A crash between the writes
|
||||
leaves the intent behind, and the next start finishes the missing half —
|
||||
each half is idempotent because its trigger (`aspect` in the config, the
|
||||
saved intent for the layout) travels with that half's own write.
|
||||
"""
|
||||
out: dict[str, float] = {}
|
||||
for space in (config or {}).get("spaces") or []:
|
||||
if "aspect" not in space:
|
||||
continue
|
||||
try:
|
||||
out[str(space.get("id"))] = float(space.get("aspect") or 1) or 1.0
|
||||
except (TypeError, ValueError):
|
||||
out[str(space.get("id"))] = 1.0
|
||||
return out
|
||||
|
||||
|
||||
def migrate_config(config: dict[str, Any], layout: dict[str, Any] | None = None) -> bool:
|
||||
"""The config half: migrate every space still carrying an `aspect`.
|
||||
|
||||
`layout` is accepted for backward compatibility and migrated with the
|
||||
factors found in the config — callers that can crash between store writes
|
||||
should use `pending_from_config()` + `migrate_layout()` instead, so the
|
||||
layout half does not depend on state the config half just deleted.
|
||||
"""
|
||||
factors = pending_from_config(config)
|
||||
if not factors:
|
||||
return False
|
||||
for space in config.get("spaces") or []:
|
||||
migrate_space(space)
|
||||
if layout:
|
||||
migrate_layout(layout, factors)
|
||||
return True
|
||||
|
||||
|
||||
def migrate_layout(layout: dict[str, Any] | None, pending: dict[str, float]) -> bool:
|
||||
"""The layout half: marker and label positions of the spaces in `pending`."""
|
||||
changed = False
|
||||
for pos in (layout or {}).values():
|
||||
if not isinstance(pos, dict) or str(pos.get("s")) not in pending:
|
||||
continue
|
||||
dx, dy, kx, ky = transform_for(pending[str(pos.get("s"))])
|
||||
if pos.get("x") is not None:
|
||||
pos["x"] = dx + float(pos["x"]) * kx
|
||||
if pos.get("y") is not None:
|
||||
pos["y"] = dy + float(pos["y"]) * ky
|
||||
changed = True
|
||||
return changed
|
||||
@@ -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
|
||||
@@ -18,8 +20,12 @@ except ImportError: # older HA versions
|
||||
KEY_HASS = "hass" # type: ignore[assignment]
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, PLANS_DIR
|
||||
from .const import (
|
||||
CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, MAX_FILES_BYTES,
|
||||
MAX_FILES_COUNT, PLANS_DIR,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .plans import TMP_PREFIX, QuotaError, check_quota, reserve_filename
|
||||
from .validation import (
|
||||
FILE_EXTENSIONS,
|
||||
MAX_FILE_BYTES,
|
||||
@@ -31,6 +37,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 +81,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 +124,132 @@ 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]
|
||||
try:
|
||||
await hass.async_add_executor_job(
|
||||
check_quota, files_root, tmp_path.stat().st_size,
|
||||
MAX_FILES_BYTES, MAX_FILES_COUNT,
|
||||
)
|
||||
except QuotaError as err:
|
||||
_LOGGER.warning("House Plan upload refused: %s", err.detail)
|
||||
return web.json_response({"error": err.reason, "detail": err.detail}, status=507)
|
||||
except OSError:
|
||||
pass
|
||||
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.2"
|
||||
"version": "1.50.0"
|
||||
}
|
||||
|
||||
@@ -1,22 +1,243 @@
|
||||
"""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 .const import MIN_FREE_BYTES, PLAN_ORPHAN_TTL_S
|
||||
from .validation import MAX_FILENAME, PLAN_EXTENSIONS, sanitize_filename
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Streaming uploads land here first. The prefix is a dot so the name can never
|
||||
# collide with an attachment (sanitize_filename strips leading dots) and is easy
|
||||
# to sweep.
|
||||
TMP_PREFIX = ".upload-"
|
||||
|
||||
|
||||
def reserve_filename(directory: Path, name: str) -> str:
|
||||
"""Atomically claim a free name inside `directory` and return it.
|
||||
|
||||
Creates the file, empty, with `O_CREAT | O_EXCL`, so the name is *taken* the
|
||||
moment it is chosen. The previous version asked `exists()` and returned a
|
||||
string; two uploads racing between the check and the write agreed on the
|
||||
same name and one silently overwrote the other, both reporting success
|
||||
(HP-1460-01). The caller writes the real bytes over the placeholder — it
|
||||
owns the name by then — and must remove it if it never gets that far.
|
||||
|
||||
The result is guaranteed to satisfy `sanitize_filename(result) == result`:
|
||||
the content view sanitises the name in the request too, so a name it would
|
||||
shorten or rewrite is a file that is written and then never served.
|
||||
"""
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
# Split the extension off the RAW name: sanitize_filename() truncates to
|
||||
# MAX_FILENAME, so sanitising first would cut ".pdf" off a long name and the
|
||||
# attachment would be stored — and served — without its type.
|
||||
base = name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
stem, dot, suffix = base.rpartition(".")
|
||||
if not dot:
|
||||
stem, suffix = base, ""
|
||||
stem = sanitize_filename(stem)
|
||||
ext = f".{sanitize_filename(suffix)[:16]}" if suffix else ""
|
||||
i = 1
|
||||
while True:
|
||||
tag = "" if i == 1 else f"-{i}"
|
||||
# budget the stem so the WHOLE name fits, including the collision tag —
|
||||
# appending "-2" to an already maximal name produced a url the view
|
||||
# truncated back to something else, i.e. a permanent 404
|
||||
room = MAX_FILENAME - len(ext) - len(tag)
|
||||
candidate = (stem[:room] if room > 0 else "f") + tag + ext
|
||||
candidate = sanitize_filename(candidate)
|
||||
if candidate.startswith("."): # a name that is only an extension
|
||||
candidate = "file" + candidate
|
||||
try:
|
||||
fd = os.open(directory / candidate, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
||||
except FileExistsError:
|
||||
i += 1
|
||||
if i > 10000: # pathological directory; do not spin forever
|
||||
raise
|
||||
continue
|
||||
os.close(fd)
|
||||
return candidate
|
||||
|
||||
|
||||
def attachment_refs(cfg: dict[str, Any] | None) -> set[str]:
|
||||
""""<marker>/<file>" for every attachment a configuration references."""
|
||||
out: set[str] = set()
|
||||
for m in (cfg or {}).get("markers") or []:
|
||||
for pdf in m.get("pdfs") or []:
|
||||
url = pdf.get("url") if isinstance(pdf, dict) else None
|
||||
if not isinstance(url, str) or "/files/" not in url:
|
||||
continue
|
||||
rel = url.split("?", 1)[0].split("/files/", 1)[1]
|
||||
if rel.count("/") == 1:
|
||||
out.add(rel)
|
||||
return out
|
||||
|
||||
|
||||
def sweep_upload_temps(files_dir: Path, now: float | None = None) -> int:
|
||||
"""Remove abandoned streaming temporaries (HP-1460-02).
|
||||
|
||||
The request itself deletes its own, but a hard kill — a restart mid-upload,
|
||||
an OOM — leaves one behind, and the attachment collector only walks marker
|
||||
folders, so it would never be seen. Age-gated for the same reason as the
|
||||
rest: a fresh one belongs to a request still in flight.
|
||||
"""
|
||||
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
|
||||
removed = 0
|
||||
try:
|
||||
items = [p for p in files_dir.iterdir() if p.is_file()] if files_dir.is_dir() else []
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not list %s: %s", files_dir, err)
|
||||
return 0
|
||||
for item in items:
|
||||
if not item.name.startswith(TMP_PREFIX):
|
||||
continue
|
||||
try:
|
||||
if item.stat().st_mtime >= cutoff:
|
||||
continue
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError:
|
||||
continue
|
||||
return removed
|
||||
|
||||
|
||||
def collect_attachments(
|
||||
files_dir: Path,
|
||||
old_cfg: dict[str, Any] | None,
|
||||
new_cfg: dict[str, Any],
|
||||
now: float | None = None,
|
||||
) -> int:
|
||||
"""The same commit-scoped rule as `collect_plans`, for marker attachments.
|
||||
|
||||
A file the old revision referenced and the new one does not, whose marker
|
||||
still exists, was removed on purpose — the dialog has a trash button and
|
||||
promises nothing. It goes. Everything else is kept, except a staging folder
|
||||
(`up_*`), which by construction only ever holds an upload from a dialog that
|
||||
was never saved: those go after PLAN_ORPHAN_TTL_S. Never raises: it runs
|
||||
behind a durable write.
|
||||
"""
|
||||
new_refs = attachment_refs(new_cfg)
|
||||
old_refs = attachment_refs(old_cfg)
|
||||
# Removing an attachment from a device that still exists is the user saying
|
||||
# "drop this one" — a trash button, no promise that anything is kept. A
|
||||
# device that is GONE is a different transition, and its files follow the
|
||||
# same rule as a deleted space's plan: kept.
|
||||
live_markers = {str(m.get("id")) for m in (new_cfg or {}).get("markers") or []}
|
||||
# Same distinction as for plans. A staging folder (`up_*`) is different: it
|
||||
# only ever holds an upload from a dialog that was never saved, so the short
|
||||
# rule is exactly right there even on the timer.
|
||||
now_s = time.time() if now is None else now
|
||||
staging_cutoff = now_s - PLAN_ORPHAN_TTL_S
|
||||
removed = 0
|
||||
try:
|
||||
folders = sorted(p for p in files_dir.iterdir() if p.is_dir()) if files_dir.is_dir() else []
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not list %s: %s", files_dir, err)
|
||||
return 0
|
||||
removed += sweep_upload_temps(files_dir, now)
|
||||
for folder in folders:
|
||||
# A staging folder only ever holds an upload from a dialog that was never
|
||||
# saved — unambiguous, so an hour is right, and no device owns it.
|
||||
staging = folder.name.startswith("up_")
|
||||
try:
|
||||
items = sorted(p for p in folder.iterdir() if p.is_file())
|
||||
except OSError:
|
||||
continue
|
||||
for item in items:
|
||||
rel = f"{folder.name}/{item.name}"
|
||||
if rel in new_refs:
|
||||
continue
|
||||
dropped = rel in old_refs and folder.name in live_markers
|
||||
if not dropped:
|
||||
if not staging:
|
||||
# Same rule as for plans: not asked for, so kept. A file in
|
||||
# a device's folder that the device does not list is an
|
||||
# upload whose save was rejected — and ageing those out
|
||||
# raced the retry that was about to reference them.
|
||||
continue
|
||||
try:
|
||||
if item.stat().st_mtime >= staging_cutoff:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not remove the attachment %s: %s", item, err)
|
||||
try:
|
||||
next(folder.iterdir())
|
||||
except StopIteration:
|
||||
try:
|
||||
folder.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
return removed
|
||||
|
||||
|
||||
class QuotaError(Exception):
|
||||
"""A store limit would be exceeded. Carries what to tell the user."""
|
||||
|
||||
def __init__(self, reason: str, detail: str) -> None:
|
||||
super().__init__(detail)
|
||||
self.reason = reason
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def dir_usage(path: Path) -> tuple[int, int]:
|
||||
"""(bytes, files) below `path`, ignoring what we cannot read."""
|
||||
total = count = 0
|
||||
if not path.is_dir():
|
||||
return 0, 0
|
||||
for item in path.rglob("*"):
|
||||
try:
|
||||
if item.is_file():
|
||||
total += item.stat().st_size
|
||||
count += 1
|
||||
except OSError:
|
||||
continue
|
||||
return total, count
|
||||
|
||||
|
||||
def check_quota(path: Path, incoming: int, max_bytes: int, max_files: int) -> None:
|
||||
"""Raise QuotaError unless `incoming` more bytes fit.
|
||||
|
||||
Deliberately not an age rule. Files are never removed for getting old — that
|
||||
cost real plans twice — so the limit sits where a decision is being made
|
||||
anyway: at the moment somebody asks to store something new.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
used, count = dir_usage(path)
|
||||
if count + 1 > max_files:
|
||||
raise QuotaError("too_many_files", f"{count} files already stored, the limit is {max_files}")
|
||||
if used + incoming > max_bytes:
|
||||
raise QuotaError(
|
||||
"quota_exceeded",
|
||||
f"{(used + incoming) // 1024 // 1024} MB would be stored, the limit is "
|
||||
f"{max_bytes // 1024 // 1024} MB",
|
||||
)
|
||||
try:
|
||||
free = shutil.disk_usage(str(path if path.is_dir() else path.parent)).free
|
||||
except OSError:
|
||||
return
|
||||
if free - incoming < MIN_FREE_BYTES:
|
||||
raise QuotaError("low_disk_space", f"only {free // 1024 // 1024} MB free on the disk")
|
||||
|
||||
|
||||
def plan_basename(url: Any) -> str:
|
||||
"""File name a stored plan_url points at ('' when there is none)."""
|
||||
@@ -35,6 +256,14 @@ def plan_refs(cfg: dict[str, Any] | None) -> set[str]:
|
||||
return out
|
||||
|
||||
|
||||
def plan_by_space(cfg: dict[str, Any] | None) -> dict[str, str]:
|
||||
"""space id -> the plan file it references ('' when it has none)."""
|
||||
return {
|
||||
str(sp.get("id")): plan_basename(sp.get("plan_url"))
|
||||
for sp in (cfg or {}).get("spaces") or []
|
||||
}
|
||||
|
||||
|
||||
def is_plan_file(name: str) -> bool:
|
||||
"""Does this look like a plan we wrote: <space>.<ext> or <space>.<token>.<ext>?"""
|
||||
parts = name.split(".")
|
||||
@@ -61,7 +290,7 @@ def collect_plans(
|
||||
* a file the OLD configuration referenced and the new one does not was
|
||||
authoritative and has been superseded — remove it;
|
||||
* any other unreferenced plan file is a rejected or abandoned upload, and
|
||||
is removed only once PLAN_ORPHAN_TTL_S has passed: a fresh one may
|
||||
is KEPT — see the rule above; only a staging folder ages out: a fresh one may
|
||||
belong to a transaction that has not committed yet.
|
||||
|
||||
Never raises: the configuration is already stored by the time this runs, so
|
||||
@@ -69,7 +298,29 @@ def collect_plans(
|
||||
"""
|
||||
new_refs = plan_refs(new_cfg)
|
||||
old_refs = plan_refs(old_cfg)
|
||||
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
|
||||
# A commit knows what it superseded. The timer only knows what nothing
|
||||
# points at *right now*, and for a plan that is a reversible state: the
|
||||
# editor detaches the image when a space switches to "draw" and says the
|
||||
# file stays on disk. So the scheduled pass keeps anything belonging to a
|
||||
# space that still exists, and waits a month for the rest.
|
||||
# A space with NO plan_url has had its image detached — reversible, and the
|
||||
# editor promises the file stays. A space that HAS one is different: any
|
||||
# other file of its own is a superseded or rejected upload, so the short
|
||||
# rule is right for those. Getting this distinction wrong (protecting
|
||||
# nothing) destroyed two detached plans on 2026-07-28.
|
||||
# The short rule fits exactly one case: a space that HAS a plan, where any
|
||||
# other file of its own can only be a superseded or rejected upload.
|
||||
old_by_space = plan_by_space(old_cfg)
|
||||
new_by_space = plan_by_space(new_cfg)
|
||||
# A file that left the configuration tells us nothing on its own: replacing a
|
||||
# plan, detaching one and deleting a space all look identical from
|
||||
# `old_refs - new_refs`. Only the first is a deletion the user asked for
|
||||
# (HP-1465-01 — the guards below were written and then never reached,
|
||||
# because the code decided "superseded" before asking why).
|
||||
replaced = {
|
||||
name for space, name in old_by_space.items()
|
||||
if new_by_space.get(space) and new_by_space[space] != name
|
||||
}
|
||||
removed = 0
|
||||
try:
|
||||
items = sorted(plans_dir.iterdir()) if plans_dir.is_dir() else []
|
||||
@@ -82,12 +333,21 @@ def collect_plans(
|
||||
for item in items:
|
||||
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
|
||||
continue
|
||||
superseded = item.name in old_refs
|
||||
try:
|
||||
stale = item.stat().st_mtime < cutoff
|
||||
except OSError:
|
||||
stale = False
|
||||
if not superseded and not stale:
|
||||
if item.name not in replaced:
|
||||
# PRODUCT RULE (owner's decision, 2026-07-28): a plan file we were
|
||||
# not told to delete is kept, however long it sits there. Detaching
|
||||
# is one click to undo and the editor says the image stays; deleting
|
||||
# a space is deliberate but the image was imported and may be
|
||||
# nowhere else. The errors are not symmetrical — unnecessary
|
||||
# megabytes can be removed by hand, a deleted file cannot be
|
||||
# brought back.
|
||||
#
|
||||
# There is deliberately no age rule here. An earlier version aged
|
||||
# out "rejected uploads" — a file of a space that has a plan, which
|
||||
# was never the plan — and that raced a save: the sweep deleted the
|
||||
# upload from the failed attempt while a retry was committing a
|
||||
# reference to it. A rule that can delete a file somebody is about
|
||||
# to point at is not worth the disk it reclaims.
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
|
||||
@@ -51,8 +51,17 @@ async def async_check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntr
|
||||
translation_key="broken_plan",
|
||||
translation_placeholders={"space": space_id, "file": fname},
|
||||
)
|
||||
# clear stale issues for spaces that are fine again (or gone)
|
||||
for sp in spaces:
|
||||
sid = sp.get("id", "?")
|
||||
if sid not in broken:
|
||||
ir.async_delete_issue(hass, DOMAIN, f"broken_plan_{sid}")
|
||||
# Clear stale issues. Iterating the CURRENT spaces could only ever clear
|
||||
# issues for spaces that still exist, so deleting or renaming a space with a
|
||||
# missing plan left its warning in Repairs forever, with nothing left to fix
|
||||
# it (HP-1454-09). Enumerate what we actually published instead.
|
||||
registry = ir.async_get(hass)
|
||||
stale = [
|
||||
issue_id
|
||||
for (domain, issue_id) in list(registry.issues)
|
||||
if domain == DOMAIN
|
||||
and issue_id.startswith("broken_plan_")
|
||||
and issue_id[len("broken_plan_") :] not in broken
|
||||
]
|
||||
for issue_id in stale:
|
||||
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
@@ -42,6 +43,17 @@ class HouseplanData:
|
||||
# One lock for every load→modify→save cycle of both stores: prevents
|
||||
# lost updates from concurrent WS calls and makes the rev check atomic.
|
||||
write_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
# A separate, narrower lock for the check-quota→write-file pair of an
|
||||
# upload. Without it N parallel uploads all measure the store BEFORE any
|
||||
# of them writes, and all pass a quota only one of them fits under
|
||||
# (HP-1490-02). Separate from write_lock so a slow directory scan does not
|
||||
# stall config/layout commits.
|
||||
upload_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
# Collect files nothing references any more. Set during setup, which also
|
||||
# runs it once and schedules it daily. Exposed so it can be invoked
|
||||
# directly — a test that fakes a 24 h jump proves the timer fires, not that
|
||||
# the work happens, and those are different claims.
|
||||
sweep: Callable[[], Awaitable[None]] | None = None
|
||||
|
||||
|
||||
HouseplanConfigEntry = ConfigEntry[HouseplanData]
|
||||
|
||||
@@ -16,6 +16,9 @@ MAX_FILE_BYTES = 50 * 1024 * 1024
|
||||
|
||||
SPACE_ID_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
|
||||
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
# The name length the content view will accept back in a request. Anything a
|
||||
# generated name must fit inside, collision tag included (HP-1460-01).
|
||||
MAX_FILENAME = 120
|
||||
|
||||
# ---------- sanitizers ----------
|
||||
|
||||
@@ -33,7 +36,7 @@ def sanitize_marker_id(value: str) -> str:
|
||||
def sanitize_filename(value: str) -> str:
|
||||
"""Drop the path and leading dots, keep a safe file name."""
|
||||
raw = value.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
return _SAFE_NAME_RE.sub("_", raw).lstrip(".")[:120] or "file"
|
||||
return _SAFE_NAME_RE.sub("_", raw).lstrip(".")[:MAX_FILENAME] or "file"
|
||||
|
||||
|
||||
def file_ext(filename: str) -> str:
|
||||
@@ -66,6 +69,31 @@ MAX_MARKERS = 2000
|
||||
MAX_OPENINGS = 500
|
||||
MAX_DECOR = 1000
|
||||
MAX_LAYOUT = 5000
|
||||
# Inner limits (HP-1454-05). The outer collections were capped, the collections
|
||||
# INSIDE them were not: a 150 000-point polygon or a 100 000-entry known_devices
|
||||
# list passed validation, then made the card build gigantic SVG attributes and
|
||||
# walk them on every render. Any authenticated writer could store one, and with
|
||||
# `admin_only` off that is every user. These are product limits, not guesses: a
|
||||
# hand-drawn room does not need 500 vertices, and no home has 200 lights behind
|
||||
# one switch.
|
||||
MAX_POLY_POINTS = 500
|
||||
MAX_OPEN_TO = 50
|
||||
MAX_CONTROLS = 200
|
||||
MAX_PDFS = 50
|
||||
MAX_KNOWN_DEVICES = 20000
|
||||
MAX_TEXT = 500 # names, models, ids
|
||||
MAX_DESCRIPTION = 4000
|
||||
MAX_URL = 2000
|
||||
# Comfortably below the WebSocket frame limit (aiohttp's default is 4 MB): a
|
||||
# payload larger than the frame never reaches the handler at all — the socket
|
||||
# closes with 1009 and the user sees a dropped connection instead of an error
|
||||
# they can act on. For scale, a real three-floor home with ~200 devices stores
|
||||
# about 70 KB, so this is ~30x headroom.
|
||||
MAX_CONFIG_BYTES = 2 * 1024 * 1024
|
||||
|
||||
_TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
|
||||
_TEXT_OR_NONE = vol.Any(None, _TEXT)
|
||||
_URL = vol.All(str, vol.Length(max=MAX_URL))
|
||||
|
||||
POS_SCHEMA = vol.Schema(
|
||||
{vol.Required("x"): _finite, vol.Required("y"): _finite},
|
||||
@@ -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,
|
||||
),
|
||||
@@ -160,7 +188,15 @@ SPACE_SCHEMA = vol.Schema(
|
||||
vol.Required("title"): str,
|
||||
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
|
||||
vol.Optional("plan_url"): vol.Any(str, None),
|
||||
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
||||
# The canvas is square since v1.48.0. What used to be the space's own
|
||||
# `aspect` is gone; the background image keeps its own proportions and
|
||||
# is centred, so only the IMAGE's ratio is stored. A stale tab may still
|
||||
# send the old field — it is dropped rather than trusted, because the
|
||||
# coordinates it comes with were normalised against a different box.
|
||||
vol.Remove("aspect"): object,
|
||||
vol.Optional("plan_aspect"): vol.Any(
|
||||
None, vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20))
|
||||
),
|
||||
vol.Required("view_box"): vol.All([_finite], vol.Length(min=4, max=4)),
|
||||
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
|
||||
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
|
||||
@@ -185,7 +221,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,24 +236,27 @@ MARKER_SCHEMA = vol.Schema(
|
||||
vol.Optional("space"): vol.Any(str, None),
|
||||
vol.Optional("area"): vol.Any(str, None),
|
||||
vol.Optional("hidden"): bool,
|
||||
vol.Optional("name"): vol.Any(str, None),
|
||||
vol.Optional("icon"): vol.Any(str, None),
|
||||
vol.Optional("model"): vol.Any(str, None),
|
||||
vol.Optional("link"): vol.Any(str, None),
|
||||
vol.Optional("description"): vol.Any(str, None),
|
||||
vol.Optional("name"): _TEXT_OR_NONE,
|
||||
vol.Optional("icon"): _TEXT_OR_NONE,
|
||||
vol.Optional("model"): _TEXT_OR_NONE,
|
||||
vol.Optional("link"): vol.Any(None, _URL),
|
||||
vol.Optional("description"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DESCRIPTION))),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
|
||||
vol.Optional("controls"): vol.Any([str], None),
|
||||
vol.Optional("controls"): vol.Any(None, vol.All([_TEXT], vol.Length(max=MAX_CONTROLS))),
|
||||
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
|
||||
vol.Optional("is_light"): vol.Any(bool, None),
|
||||
vol.Optional("room_id"): vol.Any(str, None),
|
||||
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", None),
|
||||
# keep in sync with DISPLAY_MODES in src/logic.ts — a cross-language test
|
||||
# asserts every option the editor offers is accepted here (issue #3)
|
||||
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", "value", None),
|
||||
vol.Optional("ripple_color"): vol.Any(str, None),
|
||||
vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None),
|
||||
vol.Optional("size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0.2, max=6)), None),
|
||||
vol.Optional("angle"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-360, max=360)), None),
|
||||
vol.Optional("pdfs"): [
|
||||
vol.Schema({vol.Required("name"): str, vol.Required("url"): str}, extra=vol.ALLOW_EXTRA)
|
||||
],
|
||||
vol.Optional("pdfs"): vol.All(
|
||||
[vol.Schema({vol.Required("name"): _TEXT, vol.Required("url"): _URL}, extra=vol.ALLOW_EXTRA)],
|
||||
vol.Length(max=MAX_PDFS),
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
@@ -225,8 +267,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,14 +17,19 @@ 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_PLANS_BYTES, MAX_PLANS_FILES, MAX_PLANS_LISTED,
|
||||
MAX_SIGN_PATHS,
|
||||
PLANS_DIR, PLANS_URL,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .plans import collect_plans
|
||||
from .plans import (
|
||||
QuotaError, check_quota, collect_attachments, collect_plans, is_plan_file,
|
||||
plan_basename, plan_refs, reserve_filename,
|
||||
)
|
||||
from .store import HouseplanData, get_data, get_entry
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, valid_space_id,
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, sanitize_filename, valid_space_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -40,6 +46,8 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_config_get)
|
||||
websocket_api.async_register_command(hass, ws_config_set)
|
||||
websocket_api.async_register_command(hass, ws_plan_set)
|
||||
websocket_api.async_register_command(hass, ws_plans_list)
|
||||
websocket_api.async_register_command(hass, ws_plans_delete)
|
||||
websocket_api.async_register_command(hass, ws_files_migrate)
|
||||
websocket_api.async_register_command(hass, ws_files_cleanup)
|
||||
websocket_api.async_register_command(hass, ws_content_sign)
|
||||
@@ -74,7 +82,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 +117,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 +143,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 +200,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:
|
||||
@@ -204,6 +222,105 @@ async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
connection.send_result(msg["id"], {"ok": True, "mapping": mapping, "copied": len(mapping)})
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/plans/list"})
|
||||
@websocket_api.async_response
|
||||
async def ws_plans_list(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Plan images on the server, with what still uses them.
|
||||
|
||||
Files are never removed for being unreferenced (docs/SCOPE.md), which only
|
||||
works as a policy if the user can see them: detaching a plan keeps the
|
||||
image, and this is how it gets picked up again — or deleted on purpose.
|
||||
"""
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
cfg = stored.get("config") or {}
|
||||
used: dict[str, list[str]] = {}
|
||||
for space in cfg.get("spaces") or []:
|
||||
name = plan_basename(space.get("plan_url"))
|
||||
if name:
|
||||
used.setdefault(name, []).append(space.get("title") or space.get("id") or "?")
|
||||
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
|
||||
def _scan() -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
if not plans_dir.is_dir():
|
||||
return out
|
||||
for item in sorted(plans_dir.iterdir()):
|
||||
if not item.is_file() or not is_plan_file(item.name):
|
||||
continue
|
||||
try:
|
||||
st = item.stat()
|
||||
except OSError:
|
||||
continue
|
||||
out.append({
|
||||
"name": item.name,
|
||||
"url": f"{CONTENT_URL}/plans/_/{item.name}",
|
||||
"size": st.st_size,
|
||||
"modified": int(st.st_mtime),
|
||||
"used_by": used.get(item.name, []),
|
||||
})
|
||||
out.sort(key=lambda x: -x["modified"])
|
||||
return out
|
||||
|
||||
plans = await hass.async_add_executor_job(_scan)
|
||||
# newest first and capped: a folder with thousands of files would otherwise
|
||||
# become one huge message, one huge list and a signing request per row
|
||||
connection.send_result(
|
||||
msg["id"], {"plans": plans[:MAX_PLANS_LISTED], "total": len(plans)}
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/plans/delete",
|
||||
vol.Required("name"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_plans_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Delete a plan image because the user asked — the only way one goes.
|
||||
|
||||
Refuses while a space still references it: the answer to "can I delete this"
|
||||
is the stored configuration's, not the client's.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may delete plans")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
name = sanitize_filename(msg["name"])
|
||||
if not is_plan_file(name):
|
||||
connection.send_error(msg["id"], "invalid_name", "Not a plan file")
|
||||
return
|
||||
|
||||
async with rt.write_lock:
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
cfg = stored.get("config") or {}
|
||||
if name in plan_refs(cfg):
|
||||
connection.send_error(
|
||||
msg["id"], "in_use", "A space still uses this plan — detach it first"
|
||||
)
|
||||
return
|
||||
path = Path(hass.config.path(PLANS_DIR)) / name
|
||||
|
||||
def _rm() -> bool:
|
||||
try:
|
||||
path.unlink()
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not delete %s: %s", path, err)
|
||||
return False
|
||||
|
||||
removed = await hass.async_add_executor_job(_rm)
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/content/sign",
|
||||
@@ -252,34 +369,68 @@ async def ws_content_sign(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_files_cleanup(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Delete a marker's file folder — called only AFTER the config is committed."""
|
||||
"""Drop a marker folder's leftovers after its files moved elsewhere.
|
||||
|
||||
Called after a rebind: the files were copied to the new marker id and the
|
||||
config that references them is committed, so the source folder is spent.
|
||||
|
||||
It used to `rmtree` the folder on the client's word alone. Two ways that
|
||||
ends badly: a partial copy leaves some urls still pointing INTO this folder
|
||||
(the migration deliberately does not rewrite those), and a wrong or stale
|
||||
id from any client deletes a live marker's attachments outright. So the
|
||||
server checks for itself — under the config lock — and removes only files
|
||||
the stored configuration does not reference. Same principle as the
|
||||
collector: a client may say what it no longer needs, never what may go.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit files")
|
||||
return
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
from .const import FILES_DIR
|
||||
from .plans import attachment_refs
|
||||
from .validation import sanitize_marker_id
|
||||
|
||||
mid = sanitize_marker_id(msg["marker_id"])
|
||||
if not mid:
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": False})
|
||||
return
|
||||
base = Path(hass.config.path(FILES_DIR)).resolve()
|
||||
target = (base / mid).resolve()
|
||||
if not str(target).startswith(str(base)) or target == base:
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": False})
|
||||
target = (base / mid).resolve() if mid else base
|
||||
if not mid or not str(target).startswith(str(base)) or target == base:
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": 0, "kept": 0})
|
||||
return
|
||||
|
||||
def _rm() -> bool:
|
||||
if not target.is_dir():
|
||||
return False
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
return True
|
||||
async with rt.write_lock:
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
refs = attachment_refs(stored.get("config") or {})
|
||||
|
||||
removed = await hass.async_add_executor_job(_rm)
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed})
|
||||
def _rm() -> tuple[int, int]:
|
||||
if not target.is_dir():
|
||||
return 0, 0
|
||||
removed = kept = 0
|
||||
for item in sorted(target.iterdir()):
|
||||
if not item.is_file():
|
||||
continue
|
||||
if f"{mid}/{item.name}" in refs:
|
||||
kept += 1
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not remove %s: %s", item, err)
|
||||
if not kept:
|
||||
try:
|
||||
target.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
return removed, kept
|
||||
|
||||
removed, kept = await hass.async_add_executor_job(_rm)
|
||||
if kept:
|
||||
_LOGGER.info(
|
||||
"House Plan: kept %s file(s) in %s — the configuration still references them", kept, mid
|
||||
)
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed, "kept": kept})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -297,13 +448,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 ----------------
|
||||
@@ -322,6 +477,46 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
|
||||
|
||||
|
||||
def _internal_plan_names(config: dict[str, Any]) -> set[str]:
|
||||
"""Plan file names a configuration names through OUR urls.
|
||||
|
||||
Only `/api/houseplan/content/plans/_/<name>` and the legacy static path
|
||||
count. Anything else belongs to the user and may point wherever they like.
|
||||
"""
|
||||
out: set[str] = set()
|
||||
for space in (config or {}).get("spaces") or []:
|
||||
url = space.get("plan_url")
|
||||
if not isinstance(url, str) or not url:
|
||||
continue
|
||||
if not (url.startswith(CONTENT_URL + "/plans/") or url.startswith(PLANS_URL + "/")):
|
||||
continue
|
||||
name = plan_basename(url)
|
||||
if name:
|
||||
out.add(name)
|
||||
return out
|
||||
|
||||
|
||||
def _missing_internal_plans(
|
||||
plans_dir: Path, config: dict[str, Any], previous: dict[str, Any] | None = None
|
||||
) -> set[str]:
|
||||
"""Newly named plan files that are not on disk.
|
||||
|
||||
Guards the pick-then-save window: another client may delete a plan between
|
||||
the moment this one chose it and the moment it saves, which would otherwise
|
||||
store a url with nothing behind it (HP-1470-02).
|
||||
|
||||
A name the stored configuration already carries is deliberately let through.
|
||||
It is already broken — repairs says so — and refusing the write would lock
|
||||
the owner out of every other edit, including the one that detaches it.
|
||||
"""
|
||||
known = _internal_plan_names(previous or {})
|
||||
return {
|
||||
name
|
||||
for name in _internal_plan_names(config)
|
||||
if name not in known and not (plans_dir / name).is_file()
|
||||
}
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/config/set",
|
||||
@@ -343,6 +538,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)
|
||||
@@ -361,6 +566,23 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
f"Configuration was changed in another window (rev {current_rev} != {msg['expected_rev']})",
|
||||
)
|
||||
return
|
||||
# An internal plan url must name a file that exists. The card can pick a
|
||||
# plan and then delete it from the same dialog, and two clients can do
|
||||
# the same thing in either order — the lock serialises them but says
|
||||
# nothing about whether the file survived (HP-1470-02). External and
|
||||
# legacy urls are not ours to check and are left alone.
|
||||
missing = await hass.async_add_executor_job(
|
||||
_missing_internal_plans,
|
||||
Path(hass.config.path(PLANS_DIR)),
|
||||
msg["config"],
|
||||
data.get("config"),
|
||||
)
|
||||
if missing:
|
||||
connection.send_error(
|
||||
msg["id"], "missing_plan",
|
||||
"Plan file no longer exists: " + ", ".join(sorted(missing)),
|
||||
)
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
|
||||
# Still holding the lock: the file system is not part of the store's
|
||||
@@ -369,12 +591,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)
|
||||
@@ -419,10 +643,11 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
|
||||
# deleted here (review R2-1). The old name stays readable, so a config write
|
||||
# that is later rejected — revision conflict, validation, lost connection —
|
||||
# leaves the stored plan exactly as it was. The card calls
|
||||
# nothing here; the superseded file is collected by `config/set` itself,
|
||||
# inside the write lock, once a revision that no longer references it has
|
||||
# been accepted (review R3-1). A crash in between leaves an orphan, which
|
||||
# the same collector removes on a later commit once it is old enough.
|
||||
# nothing here; the file a commit REPLACES is collected by `config/set`
|
||||
# itself, inside the write lock (review R3-1). An upload that never gets
|
||||
# committed is not collected at all — it is offered back in the space
|
||||
# dialog's "already uploaded" list, where the user can attach or delete it.
|
||||
# Every attempt to age these out ended in data loss or a race (v1.46.4-6).
|
||||
#
|
||||
# `.` separates the id from the token because a space id cannot contain one
|
||||
# (SPACE_ID_RE), so "<space>.<token>.<ext>" can never be confused with the
|
||||
@@ -431,9 +656,22 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
|
||||
name = f"{space_id}.{secrets.token_hex(4)}.{msg['ext']}"
|
||||
path = plans_dir / name
|
||||
|
||||
def _write() -> None:
|
||||
def _check_and_write() -> None:
|
||||
# one executor job for the pair, under upload_lock: the measurement
|
||||
# is only a bound if nothing else writes between it and our write
|
||||
# (HP-1490-02). A failed write reserves nothing — the file either
|
||||
# exists and is counted by the next scan, or does not and is not.
|
||||
check_quota(plans_dir, len(raw), MAX_PLANS_BYTES, MAX_PLANS_FILES)
|
||||
plans_dir.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(raw)
|
||||
|
||||
await hass.async_add_executor_job(_write)
|
||||
data = _runtime(hass, connection, msg["id"])
|
||||
if data is None:
|
||||
return
|
||||
async with data.upload_lock:
|
||||
try:
|
||||
await hass.async_add_executor_job(_check_and_write)
|
||||
except QuotaError as err:
|
||||
connection.send_error(msg["id"], err.reason, err.detail)
|
||||
return
|
||||
connection.send_result(msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{name}"})
|
||||
|
||||
@@ -33,7 +33,7 @@ const res = await page.evaluate(async () => {
|
||||
const a = devs[0], b = devs[1];
|
||||
const pa = c._pos(a);
|
||||
// поставим b на тот же Y, начнём drag
|
||||
c._layout = { ...c._layout, [b.id]: { s: c._space, x: (pa.x + g * 12) / 1000, y: pa.y / (1000 / (c._curSpaceCfg.aspect || 1)) } };
|
||||
c._layout = { ...c._layout, [b.id]: { s: c._space, x: (pa.x + g * 12) / 1000, y: pa.y / 1000 } };
|
||||
c._drag = { id: b.id, sx: 0, sy: 0, ox: 0, oy: 0, moved: true };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.devGuide = guides() >= 1;
|
||||
@@ -42,7 +42,7 @@ const res = await page.evaluate(async () => {
|
||||
// 4) подложка: рисование прямоугольника с углом на одном X с углом другой фигуры
|
||||
c._setMode('decor'); await c.updateComplete;
|
||||
c._curSpaceCfg.decor = [{ id: 'd1', kind: 'rect', x: 0.2, y: 0.2, w: 0.1, h: 0.1, color: '#ff0000', width: 3 }];
|
||||
const W = 1000, H = 1000 / (c._curSpaceCfg.aspect || 1);
|
||||
const W = 1000, H = 1000; // square canvas
|
||||
c._decorDraft = { kind: 'rect', a: [0.5 * W, 0.5 * H], b: [0.2 * W, 0.6 * H], pid: 9 }; // b.x == углу d1
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.decorGuide = guides() >= 1;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Аудит v1.49.0: HP-1490-03 (редакторы видят весь холст) и HP-1490-04
|
||||
// (Save ждёт пропорции выбранного сохранённого плана, старые не наследуются).
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 1000 }, 1);
|
||||
const out = {};
|
||||
|
||||
// ---- HP-1490-03: content-fit только в просмотре -------------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
// рукописное пространство: одна маленькая комната в центре квадрата
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.spaces[0].plan_url = null; cfg.spaces[0].plan_aspect = null;
|
||||
cfg.spaces[0].rooms = [{ id: 'r1', name: 'One', area: 'living_room',
|
||||
poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }];
|
||||
c._serverCfg = cfg; c._model = null; c._view = null; c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
const inView = c._baseVb();
|
||||
o.viewIsContentFit = inView[2] < 999; // меньше холста (устройства тоже содержимое)
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
const inPlan = c._baseVb();
|
||||
o.editorSeesWholeCanvas = inPlan[2] === 1000 && inPlan[3] === 1000;
|
||||
const v = c._viewOr(c._baseVb());
|
||||
o.editorViewCoversCanvas = v.w >= 999; // старый cropped view не пережил смену
|
||||
// в редакторе можно ткнуть в дальний угол холста
|
||||
o.canReachFarCorner = (() => {
|
||||
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
|
||||
const pt = c._screenToVb(stage.clientWidth - 1, stage.clientHeight - 1);
|
||||
return pt[0] > 900 || pt[1] > 900;
|
||||
})();
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
const back = c._baseVb();
|
||||
o.contentFitRestored = back[2] < 999;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- HP-1490-04: Save ждёт aspect --------------------------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const base = c.hass.callWS;
|
||||
let saved = null;
|
||||
let signDelay = 500; // подпись приходит поздно
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/plans/list') return { plans: [
|
||||
{ name: 'wide.svg', url: '/api/houseplan/content/plans/_/wide.svg', size: 10, modified: 1, used_by: [] },
|
||||
] };
|
||||
if (m.type === 'houseplan/content/sign') {
|
||||
await new Promise((r) => setTimeout(r, signDelay));
|
||||
const urls = {}; for (const p of m.paths) urls[p] = '/assets/wide.svg'; return { urls };
|
||||
}
|
||||
if (m.type === 'houseplan/config/set') { saved = m.config; return { rev: (c._cfgRev || 0) + 1 }; }
|
||||
if (m.type === 'houseplan/config/get') return { config: saved || c._serverCfg, rev: c._cfgRev || 0 };
|
||||
return base(m);
|
||||
} };
|
||||
// страница отдаёт /assets/wide.svg размером 800x200 (создан рядом)
|
||||
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, source: 'file', planUrl: null, planFile: null };
|
||||
await c.updateComplete;
|
||||
c._useServerPlan('/api/houseplan/content/plans/_/wide.svg');
|
||||
o.oldAspectCleared = c._spaceDialog.savedAspect === undefined;
|
||||
// Save сразу, до прихода подписи
|
||||
const p = c._saveSpaceDialog();
|
||||
await p;
|
||||
o.savedUrl = saved?.spaces?.[0]?.plan_url === '/api/houseplan/content/plans/_/wide.svg';
|
||||
const a = saved?.spaces?.[0]?.plan_aspect;
|
||||
o.savedAspectIsReal = Math.abs((a || 0) - 4) < 0.01; // 800x200
|
||||
o.notTheOldAspect = a !== 1.25;
|
||||
return o;
|
||||
}));
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -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);
|
||||
@@ -11,7 +11,7 @@ const res = await page.evaluate(async () => {
|
||||
document.body.appendChild(c1);
|
||||
c1.hass = { language:'en', locale:{language:'en'}, devices:{}, entities:{}, areas:{}, states:{},
|
||||
callWS: async (m) => m.type==='houseplan/config/get'
|
||||
? { config:{ spaces:[{ id:'s1', title:'Empty', plan_url:null, aspect:1.4, view_box:[0,0,1,1], rooms:[], segments:[] }], markers:[], settings:{} }, rev:1 }
|
||||
? { config:{ spaces:[{ id:'s1', title:'Empty', plan_url:null, view_box:[0,0,1,1], rooms:[], segments:[] }], markers:[], settings:{} }, rev:1 }
|
||||
: { layout:{} },
|
||||
connection:{ subscribeEvents: async()=>()=>{} } };
|
||||
await new Promise(r=>setTimeout(r,150));
|
||||
@@ -31,7 +31,7 @@ const res = await page.evaluate(async () => {
|
||||
devices:{ d1:{ id:'d1', name: evil, model:'M<script>1</script>', area_id:'a1', identifiers:[['x','1']] } },
|
||||
entities:{}, areas:{ a1:{ area_id:'a1', name:'A1' } }, states:{},
|
||||
callWS: async (m) => m.type==='houseplan/config/get'
|
||||
? { config:{ spaces:[{ id:'s1', title:'S', plan_url:null, aspect:1, view_box:[0,0,1,1],
|
||||
? { config:{ spaces:[{ id:'s1', title:'S', plan_url:null, view_box:[0,0,1,1],
|
||||
rooms:[{ id:'r1', name: evil, area:'a1', poly:[[0.1,0.1],[0.9,0.1],[0.9,0.9],[0.1,0.9]] }], segments:[] }], markers:[], settings:{} }, rev:1 }
|
||||
: { layout:{} },
|
||||
connection:{ subscribeEvents: async()=>()=>{} } };
|
||||
|
||||
+3
-4
@@ -44,7 +44,7 @@ const res = await page.evaluate(async () => {
|
||||
const c2 = c._roomCenter(r2);
|
||||
const poly1 = r1.poly || [[r1.x, r1.y], [r1.x + r1.w, r1.y], [r1.x + r1.w, r1.y + r1.h], [r1.x, r1.y + r1.h]];
|
||||
// общая стена вертикальная — дверь ставим на неё
|
||||
const H = 1000 / (c._curSpaceCfg.aspect || 1);
|
||||
const H = 1000; // square canvas
|
||||
const doorPt = (() => {
|
||||
let best = null, bd = 1e9;
|
||||
for (const [x, y] of [[550, 150], [550, 200], [550, 250]]) {
|
||||
@@ -57,9 +57,8 @@ const res = await page.evaluate(async () => {
|
||||
...s, openings: [{ id: 'gd', type: 'door', x: doorPt[0] / 1000, y: doorPt[1] / H, angle: 90, length: 0.09 }] })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
// источник детерминированно ставим в центр r1 (двигаем реальную включённую лампу)
|
||||
const aspect = c._curSpaceCfg.aspect || 1;
|
||||
const c1 = c._roomCenter(r1);
|
||||
c._layout = { ...c._layout, [litLight.id]: { s: spId, x: c1[0] / 1000, y: c1[1] / (1000 / aspect) } };
|
||||
c._layout = { ...c._layout, [litLight.id]: { s: spId, x: c1[0] / 1000, y: c1[1] / 1000 } };
|
||||
// радиус 6 м, чтобы дверь заведомо была в зоне досягаемости
|
||||
c._serverCfg = { ...c._serverCfg, settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 600 } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
@@ -73,7 +72,7 @@ const res = await page.evaluate(async () => {
|
||||
const minX = Math.min(...poly1.map((p) => p[0]));
|
||||
const yMid = (Math.min(...poly1.map((p) => p[1])) + Math.max(...poly1.map((p) => p[1]))) / 2;
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||||
...s, openings: [{ id: 'gd2', type: 'door', x: minX / 1000, y: yMid / (1000 / aspect), angle: 90, length: 0.09 }] })) };
|
||||
...s, openings: [{ id: 'gd2', type: 'door', x: minX / 1000, y: yMid / 1000, angle: 90, length: 0.09 }] })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const clipEls2 = [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"]')];
|
||||
out.entranceNoSector = clipEls2.every((cp) => cp.querySelectorAll('path').length === 1);
|
||||
|
||||
@@ -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);
|
||||
@@ -38,8 +38,7 @@ const res = await page.evaluate(async () => {
|
||||
const vid = c._serverCfg.markers.find((m) => m.name === 'Тест')?.id;
|
||||
const center = c._roomCenter(room);
|
||||
const vpos = c._layout[vid];
|
||||
const aspect = c._curSpaceCfg.aspect || 1;
|
||||
out.newCentered = vpos && Math.abs(vpos.x * 1000 - center[0]) < 1 && Math.abs(vpos.y * (1000 / aspect) - center[1]) < 1;
|
||||
out.newCentered = vpos && Math.abs(vpos.x * 1000 - center[0]) < 1 && Math.abs(vpos.y * 1000 - center[1]) < 1;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
|
||||
@@ -9,7 +9,7 @@ const restore = () => page.evaluate((s) => {
|
||||
}, snap);
|
||||
// norm→render helper mirrors what _markupClick passes to handlers
|
||||
const R = (nx, ny) => page.evaluate(([nx, ny]) => {
|
||||
const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H];
|
||||
return [nx * 1000, ny * 1000]; // the canvas is square (v1.48.0)
|
||||
}, [nx, ny]);
|
||||
const S = () => page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
|
||||
@@ -6,7 +6,7 @@ const res = await page.evaluate(async () => {
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
|
||||
// r1|r2 делят стену x=0.55 → клик по ней открывает границу
|
||||
const H = 1000 / (c._curSpaceCfg.aspect || 1);
|
||||
const H = 1000; // square canvas
|
||||
c._openWallClick([550, 0.25 * H]);
|
||||
await c.updateComplete;
|
||||
const r1 = c._curSpaceCfg.rooms.find((r) => r.id === 'r1');
|
||||
@@ -53,8 +53,7 @@ const res = await page.evaluate(async () => {
|
||||
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
|
||||
const litLight = c._devices.find((d) => d.space === c._space && d.entities.some((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on'));
|
||||
const c1 = c._roomCenter(c._spaceModel().rooms.find((r) => r.id === 'r1'));
|
||||
const aspect = c._curSpaceCfg.aspect || 1;
|
||||
c._layout = { ...c._layout, [litLight.id]: { s: c._space, x: c1[0] / 1000, y: c1[1] / (1000 / aspect) } };
|
||||
c._layout = { ...c._layout, [litLight.id]: { s: c._space, x: c1[0] / 1000, y: c1[1] / 1000 } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const clip = sr().querySelector('defs clipPath[id^="hp-glowclip"]');
|
||||
out.zoneClip = clip ? clip.querySelectorAll('path').length >= 2 : false;
|
||||
|
||||
@@ -6,7 +6,7 @@ const res = await page.evaluate(async () => {
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const stage = () => sr().querySelector('.stage');
|
||||
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
|
||||
const H = 1000 / (c._curSpaceCfg.aspect || 1);
|
||||
const H = 1000; // square canvas
|
||||
// 1) без наведения: курсор default, превью нет
|
||||
c._cursorPt = null; c.requestUpdate(); await c.updateComplete;
|
||||
out.idleCursor = getComputedStyle(stage()).cursor === 'default';
|
||||
|
||||
@@ -39,7 +39,7 @@ const res = await page.evaluate(async () => {
|
||||
const sentF1 = (c.__sent?.spaces || []).find((s) => s.id === 'f1');
|
||||
const liveF1 = (c._serverCfg?.spaces || []).find((s) => s.id === 'f1');
|
||||
out.sentPlanUrl = sentF1?.plan_url;
|
||||
out.sentAspect = sentF1?.aspect;
|
||||
out.sentPlanAspect = sentF1?.plan_aspect; // the IMAGE's ratio; the canvas is square
|
||||
out.sentTitle = sentF1?.title;
|
||||
out.livePlanUrl = liveF1?.plan_url;
|
||||
out.dialogClosed = c._spaceDialog === null;
|
||||
@@ -52,19 +52,19 @@ const res = await page.evaluate(async () => {
|
||||
const attic = (c.__sent?.spaces || []).find((s) => s.title === 'Attic');
|
||||
out.atticSaved = !!attic;
|
||||
out.atticHasPlan = !!attic && typeof attic.plan_url === 'string' && attic.plan_url.includes('/content/plans/');
|
||||
out.atticAspect = attic?.aspect;
|
||||
out.atticPlanAspect = attic?.plan_aspect;
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.44.8 и сверено с кодом
|
||||
checkAll(res, {
|
||||
reloadHappened: true,
|
||||
sentPlanUrl: '/api/houseplan/content/plans/_/f1.png?v=42',
|
||||
sentAspect: 1.6,
|
||||
sentPlanAspect: 1.6,
|
||||
sentTitle: 'Ground',
|
||||
livePlanUrl: '/api/houseplan/content/plans/_/f1.png?v=42',
|
||||
dialogClosed: true,
|
||||
atticSaved: true,
|
||||
atticHasPlan: true,
|
||||
atticAspect: 0.8,
|
||||
atticPlanAspect: 0.8,
|
||||
});
|
||||
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,93 @@
|
||||
// «Уже загруженные»: план, который не удаляется за ненадобностью, обязан быть
|
||||
// находимым. Иначе обещание «отцепил — файл остался» неполноценно: вернуть его
|
||||
// из карточки было нельзя, старый URL нигде не хранится (HP-1466-02).
|
||||
// Заодно это единственный способ удалить план — явным действием.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 1000 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const base = c.hass.callWS;
|
||||
|
||||
let serverPlans = [
|
||||
{ name: 'f1.aaa.png', url: '/api/houseplan/content/plans/_/f1.aaa.png', size: 121335, modified: 2, used_by: [] },
|
||||
{ name: 'f2.bbb.png', url: '/api/houseplan/content/plans/_/f2.bbb.png', size: 26931, modified: 1, used_by: ['2 этаж'] },
|
||||
];
|
||||
const deleted = [];
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/plans/list') return { plans: serverPlans };
|
||||
if (m.type === 'houseplan/plans/delete') {
|
||||
const p = serverPlans.find((x) => x.name === m.name);
|
||||
if (p?.used_by.length) { const e = new Error('in_use'); e.code = 'in_use'; throw e; }
|
||||
deleted.push(m.name);
|
||||
serverPlans = serverPlans.filter((x) => x.name !== m.name);
|
||||
return { ok: true, removed: true };
|
||||
}
|
||||
if (m.type === 'houseplan/content/sign') {
|
||||
const urls = {}; for (const p of m.paths) urls[p] = p + '?authSig=X'; return { urls };
|
||||
}
|
||||
return base(m);
|
||||
} };
|
||||
window.confirm = () => true;
|
||||
|
||||
// пространство без плана — как после отцепления
|
||||
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, source: 'file', planUrl: null, planFile: null };
|
||||
await c.updateComplete;
|
||||
out.saveBlockedWithoutPlan = !!sr().querySelector('.dialog .btn.on[disabled]');
|
||||
|
||||
// открываем список сохранённых
|
||||
await c._toggleServerPlans();
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
await c.updateComplete;
|
||||
const rows = [...sr().querySelectorAll('.savedplan')];
|
||||
out.listed = rows.length;
|
||||
out.showsUsage = (rows[1]?.textContent || '').includes('2 этаж');
|
||||
out.deleteDisabledForUsed = !!rows[1]?.querySelector('.btn.danger[disabled]');
|
||||
out.deleteEnabledForFree = !rows[0]?.querySelector('.btn.danger[disabled]');
|
||||
out.thumbnailSigned = (rows[0]?.querySelector('img')?.getAttribute('src') || '').includes('authSig=');
|
||||
|
||||
// выбираем свободный план — он подставляется в диалог
|
||||
c._useServerPlan(serverPlans[0].url);
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
await c.updateComplete;
|
||||
out.picked = c._spaceDialog.planUrl === '/api/houseplan/content/plans/_/f1.aaa.png';
|
||||
out.listClosed = !c._spaceDialog.pickSaved;
|
||||
out.saveEnabledAfterPick = !sr().querySelector('.dialog .btn.on[disabled]');
|
||||
|
||||
// выбранный в этом же диалоге удалить нельзя: сохранение записало бы ссылку
|
||||
// на несуществующий файл (HP-1470-02)
|
||||
await c._toggleServerPlans();
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
await c.updateComplete;
|
||||
const rows2 = [...sr().querySelectorAll('.savedplan')];
|
||||
const picked = rows2.find((r) => r.textContent.includes('f1.aaa.png'));
|
||||
out.deleteDisabledForPicked = !!picked?.querySelector('.btn.danger[disabled]');
|
||||
c._spaceDialog = { ...c._spaceDialog, planUrl: null };
|
||||
await c.updateComplete;
|
||||
await c._deleteServerPlan('f2.bbb.png').catch(() => {});
|
||||
out.usedNotDeleted = !deleted.includes('f2.bbb.png');
|
||||
await c._deleteServerPlan('f1.aaa.png');
|
||||
await c.updateComplete;
|
||||
out.freeDeleted = deleted.includes('f1.aaa.png');
|
||||
out.rowGone = !(c._spaceDialog.saved || []).some((p) => p.name === 'f1.aaa.png');
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.47.0 и сверено с кодом
|
||||
checkAll(res, {
|
||||
saveBlockedWithoutPlan: true,
|
||||
listed: 2,
|
||||
showsUsage: true,
|
||||
deleteDisabledForUsed: true,
|
||||
deleteEnabledForFree: true,
|
||||
thumbnailSigned: true,
|
||||
picked: true,
|
||||
listClosed: true,
|
||||
saveEnabledAfterPick: true,
|
||||
deleteDisabledForPicked: true,
|
||||
usedNotDeleted: true,
|
||||
freeDeleted: true,
|
||||
rowGone: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -42,7 +42,7 @@ const res = await page.evaluate(async () => {
|
||||
out.saveEnabled = !sr().querySelector('.dialog .btn.on[disabled]');
|
||||
await c._saveSpaceDialog(); await c.updateComplete;
|
||||
const attic = c._serverCfg.spaces.find((s) => s.title === 'Attic');
|
||||
out.atticAspect = attic?.aspect;
|
||||
out.atticSquare = attic?.aspect === undefined; // no per-space ratio any more
|
||||
out.atticSettings = attic?.settings;
|
||||
out.atticNoPlan = attic ? attic.plan_url === null : null;
|
||||
return out;
|
||||
@@ -55,7 +55,7 @@ checkAll(res, {
|
||||
"labels": ["Living room", "Kitchen", "Bedroom", "Hallway"],
|
||||
"livingStyle": "--room-stroke:#ff8800;--room-stroke-op:0.8;--room-fill:#ffd45c;--room-fill-op:0.180",
|
||||
"lqiFills": 0,
|
||||
"atticAspect": 1,
|
||||
"atticSquare": true,
|
||||
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#3ea6ff", "room_opacity": 0.55, "fill_mode": "none", "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const R = (nx, ny) => page.evaluate(([nx, ny]) => {
|
||||
const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H];
|
||||
return [nx * 1000, ny * 1000]; // square canvas (v1.48.0)
|
||||
}, [nx, ny]);
|
||||
const out = {};
|
||||
await page.evaluate(()=>{const c=window.__card; if(!c._markup)c._setMode('plan'); c._tool='split';});
|
||||
// living room (r1) has walls at y=0.05 which are NOT grid nodes; click near the wall
|
||||
// living room (r1) has walls at y=0.14 which are NOT grid nodes; click near the wall
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // pick living
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.052)); // near top wall (off grid)
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.142)); // near top wall (off grid)
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.58)); // near bottom wall
|
||||
out.pending = await page.evaluate(()=>!!window.__card._pendingSplit);
|
||||
out.dialog = await page.evaluate(()=>!!window.__card._roomDialog);
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,64 @@
|
||||
// v1.49.x: zoom goes below the base fit, the editor does not shift the plan.
|
||||
// - the stage height follows the MEASURED header, not a hard-coded 118px, so
|
||||
// entering an editor keeps the plan inside the viewport;
|
||||
// - zoom < 1 centres the content instead of pinning it to a corner;
|
||||
// - the content frame (default zoom) includes devices standing outside rooms.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = {};
|
||||
|
||||
// -- editor entry keeps the stage inside the viewport --------------------
|
||||
const stageBox = () => page.evaluate(() => {
|
||||
const sr = window.__card.shadowRoot || window.__card.renderRoot;
|
||||
const b = sr.querySelector('.stage').getBoundingClientRect();
|
||||
return { top: Math.round(b.top), bottom: Math.round(b.bottom) };
|
||||
});
|
||||
const vh = await page.evaluate(() => window.innerHeight);
|
||||
const inView = await stageBox();
|
||||
await page.evaluate(() => window.__card._setMode('plan'));
|
||||
await page.waitForTimeout(400);
|
||||
const inPlan = await stageBox();
|
||||
out.viewFitsViewport = inView.bottom <= vh + 2;
|
||||
out.editorFitsViewport = inPlan.bottom <= vh + 2; // used to overflow by ~90px
|
||||
out.editorStageShrinks = inPlan.top > inView.top && inPlan.bottom <= inView.bottom + 2;
|
||||
await page.evaluate(() => window.__card._setMode('view'));
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// -- zoom out below the base fit -----------------------------------------
|
||||
out.zoomOut = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
c._resetZoom();
|
||||
const fit = { ...c._viewOr(c._baseVb()) };
|
||||
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
|
||||
c._zoomAt(stage.clientWidth / 2, stage.clientHeight / 2, 0.5);
|
||||
const v = { ...c._view };
|
||||
const base = c._baseVb();
|
||||
const cx = v.x + v.w / 2, cy = v.y + v.h / 2;
|
||||
return {
|
||||
zoom: c._zoom,
|
||||
wider: v.w > fit.w * 1.9, // actually zoomed out
|
||||
centredX: Math.abs(cx - (base[0] + base[2] / 2)) < 1, // not pinned to a corner
|
||||
centredY: Math.abs(cy - (base[1] + base[3] / 2)) < 1,
|
||||
};
|
||||
});
|
||||
out.zoomOutWorks = out.zoomOut.zoom === 0.5 && out.zoomOut.wider
|
||||
&& out.zoomOut.centredX && out.zoomOut.centredY;
|
||||
delete out.zoomOut;
|
||||
out.floorIsHalf = await page.evaluate(() => { window.__card._resetZoom(); const c = window.__card;
|
||||
c._applyView(0.1); return c._zoom; }) === 0.4; // clamped at the floor
|
||||
await page.evaluate(() => window.__card._resetZoom());
|
||||
|
||||
// -- devices outside rooms stretch the default frame ---------------------
|
||||
out.devicesStretchFrame = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.spaces[0].plan_url = null; cfg.spaces[0].plan_aspect = null;
|
||||
c._serverCfg = cfg; c._model = null;
|
||||
const before = c._baseVb();
|
||||
// walk one lamp far outside every room
|
||||
c._layout = { ...c._layout, d_lamp: { s: 'f1', x: 0.99, y: 0.5 } };
|
||||
const after = c._baseVb();
|
||||
return after[0] + after[2] > before[0] + before[2] + 20; // right edge follows the lamp
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="200" viewBox="0 0 800 200"><rect width="800" height="200" fill="#eee"/></svg>
|
||||
|
After Width: | Height: | Size: 137 B |
+7
-7
@@ -51,17 +51,17 @@ customElements.define('ha-card',HaCard);
|
||||
<script type="module">
|
||||
const CFG = {
|
||||
spaces: [
|
||||
{ id:'f1', title:'Ground floor', plan_url:'/assets/f1.svg', aspect:1.25,
|
||||
{ id:'f1', title:'Ground floor', plan_url:'/assets/f1.svg', plan_aspect:1.25,
|
||||
view_box:[0,0,1,1],
|
||||
rooms:[
|
||||
{id:'r1', name:'Living room', area:'living_room', poly:[[0.04,0.05],[0.55,0.05],[0.55,0.6],[0.04,0.6]]},
|
||||
{id:'r2', name:'Kitchen', area:'kitchen', poly:[[0.55,0.05],[0.96,0.05],[0.96,0.45],[0.55,0.45]]},
|
||||
{id:'r3', name:'Bedroom', area:'bedroom', poly:[[0.55,0.45],[0.96,0.45],[0.96,0.95],[0.55,0.95]]},
|
||||
{id:'r4', name:'Hallway', area:'hallway', poly:[[0.04,0.6],[0.55,0.6],[0.55,0.95],[0.04,0.95]]}
|
||||
{id:'r1', name:'Living room', area:'living_room', poly:[[0.04,0.14],[0.55,0.14],[0.55,0.58],[0.04,0.58]]},
|
||||
{id:'r2', name:'Kitchen', area:'kitchen', poly:[[0.55,0.14],[0.96,0.14],[0.96,0.46],[0.55,0.46]]},
|
||||
{id:'r3', name:'Bedroom', area:'bedroom', poly:[[0.55,0.46],[0.96,0.46],[0.96,0.86],[0.55,0.86]]},
|
||||
{id:'r4', name:'Hallway', area:'hallway', poly:[[0.04,0.58],[0.55,0.58],[0.55,0.86],[0.04,0.86]]}
|
||||
], segments:[] },
|
||||
{ id:'garden', title:'Garden', plan_url:'/assets/garden.svg', aspect:1.4286,
|
||||
{ id:'garden', title:'Garden', plan_url:'/assets/garden.svg', plan_aspect:1.4286,
|
||||
view_box:[0,0,1,1],
|
||||
rooms:[ {id:'g1', name:'Garden', area:'garden', poly:[[0.03,0.04],[0.97,0.04],[0.97,0.96],[0.03,0.96]]} ], segments:[] }
|
||||
rooms:[ {id:'g1', name:'Garden', area:'garden', poly:[[0.03,0.178],[0.97,0.178],[0.97,0.822],[0.03,0.822]]} ], segments:[] }
|
||||
],
|
||||
markers: [],
|
||||
settings: {}
|
||||
|
||||
Vendored
+112
-45
File diff suppressed because one or more lines are too long
+95
-8
@@ -10,7 +10,7 @@ houseplan-card/
|
||||
├─ src/ # card sources (TypeScript + Lit 3)
|
||||
│ ├─ houseplan-card.ts # the card: rendering, states, drag, tooltip, sticky header
|
||||
│ ├─ editor.ts # GUI config editor (ha-form + selectors)
|
||||
│ ├─ rules.ts # icon rules (iconFor), curation, groups, domain priority
|
||||
│ ├─ rules.ts # icon rules (iconFor), filtering, groups, domain priority
|
||||
│ └─ data/
|
||||
│ ├─ house.ts # geometry: ROOMS (rooms→area), FLOOR_VB (viewBox), names
|
||||
│ └─ backgrounds.ts # VECTOR plans (SVG base64) + FLOOR_BG_RECT (positioning)
|
||||
@@ -172,14 +172,92 @@ 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/plans/list` | — | `{plans: [{name, url, size, modified, used_by}]}` |
|
||||
| `houseplan/plans/delete` | `name` | `{ok, removed}` / err `in_use` |
|
||||
| `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) |
|
||||
|
||||
**The canvas is square, the image is not** (v1.48.0). A space used to carry an
|
||||
`aspect`, and coordinates were normalised against it — x by the width, y by the
|
||||
height. That made every geometric question depend on a per-space number for no
|
||||
benefit. Now the render space is `NORM_W × NORM_W` and a plan image is fitted
|
||||
inside it by its own ratio (`fitInSquare`, shared by both renderers), which is
|
||||
stored as `plan_aspect` so the layout does not jump before the file loads.
|
||||
Upgrading runs `geometry_migration.migrate_config` once: it pads the old box out
|
||||
to a square and re-expresses every coordinate against it — a uniform scale plus
|
||||
an offset in render units, so angles and proportions are exact — and scales
|
||||
`cell_cm` for tall plans, since the grid pitch is a fraction of the width.
|
||||
|
||||
**User content is served inert** (HP-1454-01). An uploaded SVG is the only
|
||||
thing here that a browser will happily treat as a *document* rather than an
|
||||
image, and it would be a document of Home Assistant's own origin. Inside the
|
||||
card that never matters — `<image>` does not run scripts — but the url is
|
||||
reachable directly, and uploading needs only write access, which by default
|
||||
every user has. `HouseplanContentView` therefore sends a `sandbox` CSP with SVG
|
||||
and only with SVG: a CSP on a PDF response can break the browser's built-in
|
||||
viewer, and a raster image has no execution model to disable.
|
||||
|
||||
**Attachments follow the same commit-scoped lifecycle as plans** (HP-1454-02).
|
||||
An upload takes a free name and never overwrites, because the bytes under an
|
||||
existing name may be referenced by the stored configuration and an upload is
|
||||
not part of that transaction. `reserve_filename` *claims* the name as it picks
|
||||
it (`O_CREAT | O_EXCL`) — asking `exists()` and returning a string let two
|
||||
uploads agree on one name and quietly overwrite each other. It also budgets the
|
||||
length so the result survives the sanitiser the content view applies to the
|
||||
request, since a name the view rewrites is a file written and never served.
|
||||
Streaming temporaries live in the files root under `.upload-`, are removed on
|
||||
every exit path of the request (including cancellation, which is a
|
||||
BaseException and slips past `except Exception`), and are swept at startup and
|
||||
daily. That scheduled pass also runs the two collectors with the stored
|
||||
configuration as *both* sides — nothing superseded, so every referenced file is
|
||||
kept and only aged unreferenced ones go. Without it, collection would only ever
|
||||
happen when somebody saves, and a file uploaded into a dialog that was then
|
||||
cancelled would wait for a write that may never come. A new icon has no id yet, so its
|
||||
files go to a per-dialog staging folder and move to the real id once the config
|
||||
write is accepted — the same copy → save → cleanup order as a rebind.
|
||||
`config/set` collects what its commit superseded — that much a commit knows for
|
||||
certain. *Unreferenced* is a far weaker signal, and the policy follows from one
|
||||
asymmetry: **a few unnecessary megabytes can always be removed by hand; a file
|
||||
we should not have removed cannot be brought back.** When the evidence is weak,
|
||||
keep the file. Owner's decision, 2026-07-28, after the one-hour rule applied to
|
||||
every unreferenced file destroyed two detached plans.
|
||||
|
||||
The classification is by **owner**, not by "is it referenced". A file leaving
|
||||
the configuration looks identical whether the plan was replaced, detached, or
|
||||
its space deleted — and only the first is a deletion the user asked for. Reading
|
||||
`old_refs - new_refs` and calling it "superseded" deleted a plan the moment it
|
||||
was detached, under documentation promising the opposite (HP-1465-01).
|
||||
|
||||
| Case | What it means | Rule |
|
||||
|---|---|---|
|
||||
| Space in both, plan A → plan B | the user picked another image | removed immediately |
|
||||
| Space in both, plan → none | detached; one click undoes it | **kept** |
|
||||
| Space gone | deliberate, but the image was imported and may be nowhere else | **kept** |
|
||||
| Space has a plan, plus another file of its own | an upload whose save was rejected | **kept** — ageing these out raced the retry that referenced them |
|
||||
| Marker in both, attachment dropped from its list | a trash button, promising nothing | removed immediately |
|
||||
| Marker gone | same call as a deleted space's plan | **kept** |
|
||||
| Attachment in `up_*` | a dialog that was never saved; no device owns it | `PLAN_ORPHAN_TTL_S` (1 h) |
|
||||
| Marker there, file it never listed | a rejected upload | **kept**, same reason |
|
||||
|
||||
Nothing is deleted for being old, with one exception: a per-dialog staging
|
||||
folder (`up_*`), which by construction can only hold an upload from a dialog
|
||||
that was never saved. The disk therefore stays bounded by the user, not by a
|
||||
timer — `houseplan/plans/list` shows every stored plan with its size and which
|
||||
space uses it, and `houseplan/plans/delete` removes one on request, refusing
|
||||
while a space still references it. That listing is what makes "we never delete"
|
||||
livable: a detached plan is not lost, it is one click away in the space dialog.
|
||||
|
||||
**Config writes are serialized** (HP-1454-03). `_writeConfig()` chains onto a
|
||||
single promise: one `config/set` in flight, each carrying the revision the
|
||||
previous one returned. The debounce still spaces out *when* a write starts;
|
||||
what it cannot do — and used to be relied on for — is keep two writes from
|
||||
overlapping, which produced a self-inflicted conflict and lost the newer edit.
|
||||
|
||||
**Plan uploads are copy-on-write, and collection belongs to the commit**
|
||||
(reviews R2-1, R3-1). The file system is not part of the config's
|
||||
optimistic-locking transaction, so nothing referenced may be overwritten or
|
||||
@@ -188,12 +266,21 @@ removes nothing. Deciding what may then go is *not* a client's call — a cleanu
|
||||
request cannot be ordered against another client's commit, and a delayed one
|
||||
deletes a plan that was just saved. So `config/set` collects itself, inside its
|
||||
write lock, from the pair of configurations that bracket the commit
|
||||
(`plans.collect_plans`): superseded files go immediately, other unreferenced
|
||||
uploads only once `PLAN_ORPHAN_TTL_S` has passed, since a fresh one may belong
|
||||
to a transaction still in flight. The `.` between id and token is load-bearing —
|
||||
(`plans.collect_plans`): a file the commit REPLACED goes immediately, and
|
||||
nothing else goes at all — see the table above; only a per-dialog staging folder
|
||||
ages out. Growth is bounded at the door instead, by `plans.check_quota` on every
|
||||
upload (store size, file count, free disk), because a limit that deletes is how
|
||||
plans were lost twice. The `.` between id and token is load-bearing —
|
||||
a space id cannot contain one, so `<space>.<token>.<ext>` can never be confused
|
||||
with the files of a space whose name merely starts the same way.
|
||||
|
||||
**An internal plan url must exist when it is stored** (HP-1470-02). The picker
|
||||
can attach a plan and then delete it, and two clients can do the same in either
|
||||
order — the write lock orders the requests but says nothing about whether the
|
||||
file survived. `config/set` therefore checks every `/api/houseplan/content/plans/`
|
||||
url against the disk before saving, and refuses with `missing_plan`. External and
|
||||
legacy urls are the user's own and are never second-guessed.
|
||||
|
||||
**Signed content urls are batched, aged and deduplicated** (reviews R2-2, R3-2, R4-2). `ContentSigner`
|
||||
in `src/signing.ts` is the single implementation, used by both cards; the
|
||||
duplicate inside houseplan-space-card signed correctly and never handed the
|
||||
@@ -234,7 +321,7 @@ Shared, framework-light modules keep the two views from diverging:
|
||||
`roomCenter`, `defaultPositions`, `markerPos`, `labelPos`; no Lit import) — unit-tested,
|
||||
mirrors the full card's private geometry.
|
||||
- `src/space-render.ts` — `renderSpaceStatic()` draws the plan + configured room
|
||||
borders/names + device markers (via `buildDevices`, same curation) with NO handlers,
|
||||
borders/names + device markers (via `buildDevices`, same filtering) with NO handlers,
|
||||
NO live states, NO status/temperature fills. Uses the same CSS classes as the full card
|
||||
(the space-card imports `cardStyles`) for visual parity.
|
||||
- `src/config-store.ts` — module-level `{config, rev, layout}` cache shared by all embedded
|
||||
|
||||
@@ -1,5 +1,353 @@
|
||||
# Changelog
|
||||
|
||||
## v1.50.0 — 2026-07-28
|
||||
|
||||
**Owner's batch**
|
||||
|
||||
- **The default zoom counts devices as content.** They are allowed to stand
|
||||
outside every room — a gate sensor by the fence, a camera on a pole — and the
|
||||
opening view now includes them, even on a space with no rooms at all.
|
||||
- **Entering an editor no longer shifts the plan.** The stage height assumed a
|
||||
fixed 118px of header, and the editor header is taller: the scene slid down
|
||||
by the difference and its bottom went below the fold. The card measures where
|
||||
the stage actually starts and gives it the rest of the viewport.
|
||||
- **The zoom goes out as well as in.** Down to 0.4×, and zoomed out the plan
|
||||
floats centred instead of being pinned to a corner.
|
||||
|
||||
**From the v1.49.0 review**
|
||||
|
||||
- **The square-canvas migration survives a crash between its two writes
|
||||
(HP-1490-01).** Config and layout live in separate stores, written one after
|
||||
the other, and the first write deleted the very fields the second needed — a
|
||||
failure between them stranded markers in the old coordinates for good. The
|
||||
migration intent is durable now, saved before anything moves and cleared by
|
||||
the layout write itself; whichever half is missing after a crash, the next
|
||||
start finishes exactly that half, once.
|
||||
- **Parallel uploads cannot slip past the store quota together (HP-1490-02).**
|
||||
N uploads all measured the store before any of them wrote, and all passed a
|
||||
limit only one of them fit under. The measure-and-write pair is one atomic
|
||||
step under its own lock — separate from the config lock, so a slow directory
|
||||
scan does not stall saves.
|
||||
- **The editors see the whole canvas again (HP-1490-03).** The content frame
|
||||
also bounded pan, zoom and pointer maths, so after the first room there was
|
||||
nowhere left to draw the second one. Edit modes now measure from the full
|
||||
square; the view keeps its content fit, and switching modes refits instead of
|
||||
carrying a view clamped against the wrong base.
|
||||
- **Save waits for the proportions of a picked plan (HP-1490-04).** Saving
|
||||
before the image had answered used to ship the PREVIOUS file's ratio, and the
|
||||
new plan kept the old shape for good. Picking a plan clears the old ratio at
|
||||
once, and Save awaits the bounded read; if it fails, "unknown" is stored —
|
||||
a square fallback is honest, an inherited ratio is not.
|
||||
- Release hygiene from §5: package-lock.json caught up with the package
|
||||
version, and a duplicated comment in space-geometry.ts is gone.
|
||||
|
||||
## v1.49.0 — 2026-07-28
|
||||
|
||||
**The canvas is square** (see v1.48.0, released together with this one).
|
||||
|
||||
- **Zoom opens on what is drawn, not on the whole canvas.** A space without a
|
||||
background image now fits its rooms with a 5% margin, so a small plan on a big
|
||||
canvas fills the screen instead of sitting in the middle of it as a speck.
|
||||
With a background image nothing changes: the image is the plan, and cropping
|
||||
to the rooms would hide the parts nobody has outlined yet.
|
||||
- **Switching spaces by swipe, or on the kiosk carousel, slides.** The plan
|
||||
leaves the way the finger went and the next one arrives from the other side.
|
||||
Respects "reduce motion".
|
||||
- The room settings button says "Room settings" rather than just "Room", and
|
||||
lightens slightly under the cursor.
|
||||
- "Curation" is called filtering everywhere — the interface, the documentation
|
||||
and the code.
|
||||
|
||||
**From the v1.47.0 review**
|
||||
|
||||
- **A plan you have just picked can no longer be deleted from the same dialog
|
||||
(HP-1470-02).** It was not saved yet, so the server correctly considered it
|
||||
free — and the save then stored a url with no file behind it. The button is
|
||||
disabled now, and, because two clients can do the same in either order, the
|
||||
server checks every internal plan url a configuration adds against the disk
|
||||
and refuses a new reference that is already broken. A url the stored
|
||||
configuration already carries is let through — a file can vanish from outside
|
||||
Home Assistant, and refusing then would block the very edit that detaches it.
|
||||
Urls that are not ours are left alone.
|
||||
- **Uploads are bounded (HP-1470-01).** Nothing is deleted for being old — that
|
||||
cost real plans twice — so the limit sits where a decision is being made
|
||||
anyway: an upload is refused if the store would pass 256 MB or 200 plans
|
||||
(1 GB / 1000 for attachments), or if the disk would drop below 512 MB free.
|
||||
The plan list is capped at the 60 newest and its thumbnails load lazily.
|
||||
- **Picking a saved plan reads its real proportions (HP-1470-03).** The card
|
||||
waited for nothing and, when the signature for the protected url had not
|
||||
arrived yet, saved a fallback ratio — a square plan came out stretched. It now
|
||||
waits for the signature, ties the result to the dialog that asked, and the
|
||||
preview in the dialog is signed like everything else.
|
||||
|
||||
## v1.48.0 — 2026-07-28 (the canvas is always square)
|
||||
- **A space no longer has proportions of its own.** The drawing area is a square;
|
||||
a plan image keeps its own shape and is centred inside it, so a wide plan gets
|
||||
margins above and below and a tall one gets them at the sides. There is
|
||||
nothing left to choose — the canvas orientation setting for hand-drawn spaces
|
||||
is gone with it.
|
||||
- **Existing plans are migrated once, on upgrade.** Nothing about a drawing
|
||||
changes: the box is padded out to a square and every coordinate is
|
||||
re-expressed against it — rooms, doors and windows, decor, marker positions
|
||||
and the saved viewport. Angles, room proportions and relative positions are
|
||||
preserved exactly. For a tall plan the scale in centimetres per grid cell is
|
||||
adjusted along with it, because the grid is tied to the width; without that a
|
||||
wall would silently measure less than it does.
|
||||
|
||||
## v1.47.0 — 2026-07-28 (pick a plan you already uploaded)
|
||||
- **The space dialog can now show the plans stored on the server.** Detaching a
|
||||
plan keeps the image on disk — that has been the rule since v1.46.4, but until
|
||||
now the only way back was to find the original file on your computer and
|
||||
upload it again. "Already uploaded" lists what is there, with a thumbnail, the
|
||||
file size and which space uses it. One click attaches it; the aspect ratio is
|
||||
read from the image, exactly as on upload.
|
||||
- **And it is where you delete one.** A plan file is never removed automatically
|
||||
— not for being detached, not for being old — which is only a sensible policy
|
||||
if you can see what is being kept and get rid of it deliberately. The trash
|
||||
button does that, and refuses while a space still uses the plan: the answer to
|
||||
"may this go" comes from the stored configuration, not from the browser.
|
||||
- Documentation caught up with the code: several comments still described the
|
||||
age-based collection that v1.46.6 removed.
|
||||
|
||||
## v1.46.6 — 2026-07-28 (the detach promise, actually kept this time)
|
||||
- **Switching a space to "draw" no longer deletes its image.** v1.46.4 and
|
||||
v1.46.5 said it did not, and the scheduled cleanup indeed left detached plans
|
||||
alone — but the save itself deleted the file the moment the reference was
|
||||
cleared, before any of those guards were reached. The cause: a file that left
|
||||
the configuration was called "superseded", and from that difference alone
|
||||
replacing a plan, detaching one and deleting its space are indistinguishable.
|
||||
Only the first is a deletion anybody asked for. The transition is now
|
||||
classified by the space that owned the file, and the same distinction applies
|
||||
to attachments: dropping one from a device that still exists removes it,
|
||||
deleting the device keeps its manuals.
|
||||
- **A plan whose space was deleted is kept**, rather than the thirty days
|
||||
v1.46.5 promised — thirty days measured from the file's age is meaningless
|
||||
anyway, since it was usually uploaded months earlier.
|
||||
- **Nothing is deleted for being old any more**, except a per-dialog staging
|
||||
folder. The rule that aged out "rejected uploads" turned out to race a retry:
|
||||
the cleanup removed the file from a failed save while the next attempt was
|
||||
committing a reference to it. A rule that can delete a file somebody is about
|
||||
to point at is not worth the disk it reclaims. Files therefore go when an
|
||||
action says so, and otherwise stay.
|
||||
|
||||
## v1.46.5 — 2026-07-28 (audit of every automatic deletion)
|
||||
- **A detached plan is never deleted, at any age.** v1.46.4 gave it a month;
|
||||
this makes it permanent and writes the reason down where the next change will
|
||||
see it. The rule, now in docs/SCOPE.md: the component may delete a file only
|
||||
when a user action says so — replacing a plan, removing an attachment,
|
||||
deleting a device. "Nothing points at this any more" is not such an action.
|
||||
The errors are not symmetrical: wasted disk is visible, cheap and reversible;
|
||||
a deleted file is none of those.
|
||||
- **`houseplan/files/cleanup` no longer takes a folder on the client's word.**
|
||||
After a device is rebound its files are copied to the new id and the old
|
||||
folder is dropped — with `rmtree`, on whatever id the card sent. Two ways that
|
||||
ends badly: a partial copy leaves some urls still pointing into that folder
|
||||
(the migration deliberately does not rewrite those, so they were live links to
|
||||
files being deleted), and a wrong or stale id from any client would destroy a
|
||||
live device's manuals. The server now checks the stored configuration itself,
|
||||
under the config lock, and removes only files nothing references.
|
||||
- **A plan of a space that was deleted waits thirty days instead of an hour.**
|
||||
Deleting a space is deliberate, but an hour is a short window in which to
|
||||
notice it was a misclick.
|
||||
|
||||
## v1.46.4 — 2026-07-28 (data loss: detached plans were collected as garbage)
|
||||
- **A plan you detach is no longer deleted an hour later.** Switching a space to
|
||||
"draw" clears the reference and, as the editor has always said, leaves the
|
||||
image on disk so you can put it back. The collection added in v1.46.0 did not
|
||||
make that distinction: it treated "nothing points at this right now" as
|
||||
abandoned and applied a one-hour rule. On the author's own instance the
|
||||
scheduled pass then removed two floor plans that had been detached weeks
|
||||
earlier, with no way to get them back. If you have detached a plan since
|
||||
v1.46.0 and your instance restarted or ran for a day, check
|
||||
`config/houseplan/plans/` before updating anything else — and please report it
|
||||
in the Telegram chat if a file is missing.
|
||||
The rule now: **a commit still removes exactly what it replaced**, because
|
||||
that it knows for certain. Beyond that the question is whether "unreferenced"
|
||||
means "abandoned", and the answer depends on the case. A space with no plan at
|
||||
all has had one detached and may want it back — its files are never collected.
|
||||
A space that does have a plan can only be holding rejected uploads of its own,
|
||||
so those still go after an hour. Attachments outside a per-dialog staging
|
||||
folder wait a month; a staging folder, which by construction only ever holds
|
||||
an upload from a dialog that was never saved, keeps the one-hour rule.
|
||||
|
||||
## v1.46.3 — 2026-07-28 (re-check of v1.46.2: HP-1462-01)
|
||||
- **The cleanup at startup now actually cleans up.** It looked its own runtime
|
||||
data up by domain, and during startup Home Assistant does not yet consider
|
||||
the integration loaded — so the lookup came back empty and the pass quietly
|
||||
degraded to removing half-finished transfers, leaving the real work to a timer
|
||||
24 hours away. Restart more often than that and it never ran at all. It uses
|
||||
the object it was given at startup now.
|
||||
- **The test that was supposed to prove this was passing for the wrong
|
||||
reason.** It created the stray files *before* saving the configuration — and
|
||||
saving collects too, so everything was already gone by the time the restart
|
||||
happened. Rewritten to seed after the save, plus a second test that fires the
|
||||
scheduled timer on its own, and a third that runs a restart and a save at the
|
||||
same time and asserts the accepted configuration never points at a file the
|
||||
cleanup removed.
|
||||
|
||||
## v1.46.2 — 2026-07-28 (re-check of v1.46.1: HP-1461-01, -02)
|
||||
- **A file nobody ended up using is now cleaned up even if nothing is ever
|
||||
saved again (HP-1461-01).** Collection is tied to a configuration write,
|
||||
which is right for what a write supersedes but leaves a gap: cancel a dialog
|
||||
after the file has already uploaded, lose the connection just after, or call
|
||||
the upload API directly, and nothing references the file and no future write
|
||||
notices it. The daily sweep added in v1.46.1 only removed half-finished
|
||||
transfers, so the promise that a cancelled attachment disappears after an hour
|
||||
did not hold on an instance nobody edits. The sweep now compares against the
|
||||
stored configuration — under the same lock a write uses — and collects aged
|
||||
unreferenced attachments and plans as well.
|
||||
- **A drag is no longer undone by someone else's move (HP-1461-02).** When the
|
||||
full card learned to follow position changes in v1.46.1, it protected the
|
||||
positions you had moved but not yet sent — except it read that list *after*
|
||||
flushing the pending write, and flushing empties it first. In a real drag,
|
||||
where a write is already scheduled, the list was therefore empty and the
|
||||
server's older position was painted over your move. The card now takes the
|
||||
snapshot before flushing and also holds on to positions that are sent but not
|
||||
yet acknowledged: until the server confirms a position, the card that moved it
|
||||
is the authority on it.
|
||||
- Two tests grew up to their docstrings: the upload test now actually cancels
|
||||
the request task instead of only exercising error paths, and the position-sync
|
||||
smoke schedules a real debounced write and delays it, which is the ordering
|
||||
that lost the drag.
|
||||
|
||||
## v1.46.1 — 2026-07-28 (re-check of v1.46.0: HP-1460-01 … -03)
|
||||
- **Two uploads of the same file name can no longer collide (HP-1460-01).**
|
||||
v1.46.0 stopped overwriting attachments, but choosing a free name and taking
|
||||
it were two steps: two uploads racing between them agreed on the same name,
|
||||
both reported success, and one set of bytes replaced the other. The name is
|
||||
now claimed atomically as it is chosen — twenty simultaneous uploads of
|
||||
`manual.pdf` produce twenty files. The same helper is used when rebinding
|
||||
moves files, which had the same gap.
|
||||
Also fixed there: a name at the length limit lost its extension, and the
|
||||
collision suffix pushed it past the limit, so the attachment was stored under
|
||||
a name the server would not serve back — a permanent 404 on a file the UI
|
||||
reported as attached.
|
||||
- **An interrupted upload no longer leaves a temporary file forever
|
||||
(HP-1460-02).** Cleanup ran in an `except Exception`, which a cancelled
|
||||
request walks straight past, and the collector only ever looks inside marker
|
||||
folders — so an aborted transfer left a `.upload-*` in place with nothing able
|
||||
to remove it. Every exit path now cleans up, a request carrying two files is
|
||||
refused outright, and abandoned temporaries are swept at startup and daily.
|
||||
Uploads also write in 1 MB batches instead of one disk task per 64 KB.
|
||||
- **Two full cards side by side keep the same positions (HP-1460-03).** v1.46.0
|
||||
taught the static card to follow position changes and left the full one
|
||||
behind, so dragging an icon in one window did not move it in another until a
|
||||
reload. It follows now, without disturbing a drag of its own: a revision
|
||||
arriving mid-drag is merged rather than applied over the top, and a card does
|
||||
not re-read what it just wrote itself.
|
||||
|
||||
## v1.46.0 — 2026-07-28 (full external audit of v1.45.4: HP-1454-01 … -10)
|
||||
|
||||
**Security**
|
||||
|
||||
- **An uploaded SVG plan is no longer a live document of your Home Assistant
|
||||
origin (HP-1454-01, high — release blocker).** Inside the card a plan is
|
||||
referenced by `<image>`, where scripts never run; but the same url opened
|
||||
directly became a top-level document of HA's own origin, and a `<script>` in
|
||||
it could read the session's `localStorage` and call the API. Uploading needs
|
||||
write access, which by default every authenticated user has, and a signed url
|
||||
is easy to hand to an administrator. Plan responses for SVG now carry a
|
||||
`sandbox` Content-Security-Policy, which drops the document into an opaque
|
||||
origin. Only SVG gets it — a CSP on a PDF can break the browser's built-in
|
||||
viewer, and a raster image cannot execute anything. Nothing changes for
|
||||
existing plans: the card renders them exactly as before.
|
||||
|
||||
**Data integrity**
|
||||
|
||||
- **A manual attached to a device no longer overwrites the previous one
|
||||
(HP-1454-02).** The upload wrote straight to `<marker>/<filename>`, outside
|
||||
the configuration transaction: cancelling the dialog, or a rejected save, left
|
||||
the stored url serving the new bytes. And every new icon uploaded into one
|
||||
shared folder, so two of them attaching `manual.pdf` ended up pointing at the
|
||||
same physical file. Uploads now take a free name and never overwrite, a new
|
||||
icon gets its own staging folder whose files move to the real icon when the
|
||||
save is accepted, and an upload nobody saved is collected an hour later. The
|
||||
name a collision falls back to changed from `manual (2).pdf` to `manual-2.pdf`
|
||||
— the old one was sanitised on the way back in, so a renamed attachment was
|
||||
written and then never served (found by the new test, and it applied to
|
||||
rebind collisions before this release too).
|
||||
- **Two quick edits can no longer lose the second one (HP-1454-03).** The
|
||||
debounce spaced out the starts of a save, not the saves themselves. If one
|
||||
took longer than half a second — a busy instance, a slow link — the next edit
|
||||
went out with the same revision, the server accepted the first and rejected
|
||||
the second, and the conflict handler reloaded the server copy over the local
|
||||
one. The edit was gone, with a message blaming another window when there was
|
||||
none. Writes are now serialized: one at a time, each carrying the revision the
|
||||
previous one returned.
|
||||
|
||||
**Correctness and limits**
|
||||
|
||||
- **Open boundaries follow the geometry again (HP-1454-04).** Their cache was
|
||||
keyed on room ids and links only, so changing a space's aspect ratio or
|
||||
dragging a vertex left the open boundaries — and the light spilling through
|
||||
them — at their old coordinates until a reload. The cache is now keyed on the
|
||||
rendered model itself, which is exactly what it is computed from.
|
||||
- **The configuration can no longer be made arbitrarily heavy (HP-1454-05).**
|
||||
The outer collections were capped; the ones inside them were not. A polygon
|
||||
with 150 000 points, or a list of 100 000 device ids, validated fine and then
|
||||
made every render walk it. There are now limits on polygon vertices, open-to
|
||||
links, controls, attachments, text and url lengths, plus a cap on the whole
|
||||
serialized configuration. The obsolete `segments` field is dropped by the
|
||||
server instead of trusting the card to strip it.
|
||||
- **Large files stream instead of being held in memory (HP-1454-06).** A 50 MB
|
||||
manual was read whole into memory on the way in and again on the way out; a
|
||||
couple of parallel downloads were real pressure on a small Home Assistant
|
||||
host. Uploads stream to a temporary file, downloads stream from disk.
|
||||
|
||||
**Consistency**
|
||||
|
||||
- **The static space card honours per-room fill settings (HP-1454-07).** It
|
||||
builds its model with a different function, and room settings were not carried
|
||||
into it, so a room you had set to "no fill" was still painted.
|
||||
- **Moving an icon updates the static card immediately (HP-1454-08).** Layout is
|
||||
separate state with no revision and no event: a drag on the full card left a
|
||||
static card next to it showing the old position until the configuration
|
||||
changed or the page was reloaded. Layout writes now keep a revision, return
|
||||
it, and announce themselves — which also makes the optimistic locking on a
|
||||
wholesale layout write mean something, since a point-wise write used to reset
|
||||
the counter.
|
||||
- **A repair warning about a missing plan disappears with its space
|
||||
(HP-1454-09).** The cleanup only looked at spaces that still exist, so
|
||||
deleting or renaming one left its warning in Repairs with nothing able to
|
||||
clear it.
|
||||
- Build chain: `serialize-javascript` pinned past two advisories (HP-1454-10).
|
||||
Not reachable at runtime and production dependencies were already clean, but
|
||||
it is one line.
|
||||
|
||||
## v1.45.4 — 2026-07-28 (review of v1.45.3: R5-1, R5-2)
|
||||
- **A partly successful signing answer no longer skips the backoff (R5-1).**
|
||||
The backend signs each path independently: one it cannot sign is logged,
|
||||
skipped, and the call still succeeds with the remaining urls. The card took
|
||||
any successful call as "the whole batch is done", cleared the backoff for
|
||||
every path in it, and then wrote only the urls that came back — so a path the
|
||||
backend kept skipping was requested again on every single render, which is
|
||||
exactly the amplification v1.45.2 added the backoff to prevent. A path is now
|
||||
counted as signed only if the answer actually carries a url for it; the rest
|
||||
back off individually, keys nobody asked for are ignored, and a re-render is
|
||||
only triggered when at least one new signature arrived.
|
||||
- **The status snapshot no longer contradicts the repository (R5-2).** It still
|
||||
described `main` as carrying releases up to v1.40.1 and quoted test counts
|
||||
from several releases back, while the version line right beside them was kept
|
||||
current — a maintainer or an agent reading it for handoff got a wrong branch
|
||||
model and a smaller picture of the coverage than exists. The branch roles are
|
||||
described accurately, and the counts are gone: `npm run inventory` prints them
|
||||
from the tree, so there is nothing left to go stale.
|
||||
|
||||
## v1.45.3 — 2026-07-27
|
||||
- **"Value instead of an icon" could not be saved (issue #3).** The option was
|
||||
added to the device editor in v1.26.0, but the server-side schema only ever
|
||||
accepted `badge`, `ripple` and `icon_ripple`. Choosing it produced
|
||||
`not a valid value for dictionary value @ data['config']['markers'][n]['display']`
|
||||
— and because a single rejected marker fails the whole configuration write,
|
||||
the plan could not be saved at all until the setting was undone. Thanks to
|
||||
@RemyRoux for the report and the exact error text.
|
||||
- **The option lists now live in one place and are checked across languages.**
|
||||
`DISPLAY_MODES`, `TAP_ACTIONS`, `SPACE_FILL_MODES` and `ROOM_FILL_MODES` are
|
||||
exported from the card and read by a backend test that asserts the schema
|
||||
accepts every value a user can actually pick. Adding an option to an editor
|
||||
and forgetting the schema now fails the test suite instead of surfacing
|
||||
through somebody's error message.
|
||||
|
||||
## v1.45.2 — 2026-07-27 (hardening from the v1.45.1 review: R4-1, R4-2)
|
||||
- **A failed cleanup no longer reports an accepted save as an error (R4-1).**
|
||||
Collecting superseded plan files runs after the configuration is already
|
||||
|
||||
@@ -6,6 +6,358 @@
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## v1.50.0 — 2026-07-28
|
||||
|
||||
**Задачи владельца**
|
||||
|
||||
- **Масштаб по умолчанию считает устройства содержимым.** Им можно стоять вне
|
||||
комнат — датчик калитки у забора, камера на столбе — и стартовый вид теперь
|
||||
включает их, даже в пространстве совсем без комнат.
|
||||
- **Вход в редактор больше не сдвигает план.** Высота сцены считалась как
|
||||
«экран минус 118px шапки», а шапка редактора выше: сцена уезжала вниз на
|
||||
разницу, низ пропадал за краём. Карточка измеряет, где сцена начинается на
|
||||
самом деле, и отдаёт ей остаток экрана.
|
||||
- **Масштаб теперь и отдаляется.** До 0.4×; в отдалении план висит по центру,
|
||||
а не прилипает к углу.
|
||||
|
||||
**По ревью v1.49.0**
|
||||
|
||||
- **Миграция на квадратный холст переживает сбой между двумя записями
|
||||
(HP-1490-01).** Конфиг и позиции живут в разных хранилищах, пишутся по
|
||||
очереди, и первая запись удаляла именно те поля, которые нужны второй — сбой
|
||||
между ними навсегда оставлял значки в старых координатах. Теперь намерение
|
||||
миграции сохраняется до того, как что-либо меняется, и снимается той же
|
||||
записью, что сохраняет позиции: какая половина не успела — ту следующий
|
||||
запуск и доделает, ровно один раз.
|
||||
- **Параллельные загрузки не проскакивают квоту вместе (HP-1490-02).** N
|
||||
загрузок мерили хранилище до того, как любая из них записала файл, и все
|
||||
проходили предел, под который помещалась одна. Замер и запись — один
|
||||
атомарный шаг под отдельным замком; отдельным — чтобы медленный обход папки
|
||||
не тормозил сохранения конфига.
|
||||
- **Редакторы снова видят весь холст (HP-1490-03).** Рамка содержимого
|
||||
ограничивала и панорамирование, и координаты указателя, так что после первой
|
||||
комнаты рисовать вторую было негде. Режимы редактирования меряют от полного
|
||||
квадрата; просмотр остаётся по содержимому, а смена режима пересчитывает
|
||||
вид, вместо того чтобы тащить его прижатым не к той основе.
|
||||
- **Сохранение ждёт пропорции выбранного плана (HP-1490-04).** Save до ответа
|
||||
картинки записывал пропорции ПРЕДЫДУЩЕГО файла, и новый план навсегда
|
||||
оставался в чужой форме. Выбор плана сразу стирает старое значение, Save
|
||||
дожидается ограниченного по времени чтения; не дождался — записывается
|
||||
«неизвестно»: честный квадрат лучше унаследованной формы.
|
||||
- Гигиена релиза из §5: package-lock.json догнал версию пакета, задвоенный
|
||||
комментарий в space-geometry.ts убран.
|
||||
|
||||
## v1.49.0 — 2026-07-28
|
||||
|
||||
**Холст стал квадратным** (см. v1.48.0, выпущена вместе с этой).
|
||||
|
||||
- **Масштаб открывается по нарисованному, а не по всему холсту.** Пространство
|
||||
без подложки теперь вписывается по границам своих комнат с полями 5%: маленький
|
||||
план на большом холсте заполняет экран, а не сидит посередине точкой. С
|
||||
подложкой ничего не меняется — картинка и есть план, и обрезать её по комнатам
|
||||
значило бы спрятать то, что ещё не обведено.
|
||||
- **Переключение пространств свайпом и в киоске стало с анимацией.** План
|
||||
уезжает туда, куда пошёл палец, следующий приходит с другой стороны.
|
||||
Уважает системную настройку «уменьшить движение».
|
||||
- Кнопка настроек комнаты подписана «Настройки комнаты», а не просто «Комната»,
|
||||
и слегка светлеет под курсором.
|
||||
- Слово «курирование» заменено на «фильтрацию» — в интерфейсе, документации и
|
||||
коде.
|
||||
|
||||
**По ревью v1.47.0**
|
||||
|
||||
- **Только что выбранный план больше нельзя удалить из того же диалога
|
||||
(HP-1470-02).** Он ещё не сохранён, поэтому сервер справедливо считал его
|
||||
свободным — а сохранение потом записывало ссылку, за которой нет файла.
|
||||
Кнопка заблокирована, и, поскольку два клиента могут сделать это в любом
|
||||
порядке, сервер сверяет с диском каждую внутреннюю ссылку, которую
|
||||
конфигурация добавляет, и отказывает, если файла нет. Ссылку, уже записанную
|
||||
в конфигурации, он пропускает: файл может исчезнуть и мимо Home Assistant, а
|
||||
отказ заблокировал бы ровно ту правку, которая его отцепляет. Чужие ссылки не
|
||||
трогаются.
|
||||
- **Загрузки ограничены (HP-1470-01).** По возрасту не удаляется ничего — это
|
||||
дважды стоило настоящих планов, — поэтому предел стоит там, где решение и так
|
||||
принимается: загрузка отклоняется, если хранилище перевалит за 256 МБ или 200
|
||||
планов (1 ГБ и 1000 для вложений) либо если на диске останется меньше 512 МБ.
|
||||
Список планов отдаётся по 60 самых свежих, миниатюры грузятся лениво.
|
||||
- **Выбор сохранённого плана читает его настоящие пропорции (HP-1470-03).**
|
||||
Карточка ничего не ждала и, если подпись для защищённой ссылки ещё не пришла,
|
||||
записывала пропорции «по умолчанию» — квадратный план получался растянутым.
|
||||
Теперь она дожидается подписи, привязывает результат к тому диалогу, который
|
||||
спрашивал, а превью в диалоге подписывается, как и всё остальное.
|
||||
|
||||
## v1.48.0 — 2026-07-28 (холст всегда квадратный)
|
||||
- **У пространства больше нет собственных пропорций.** Область рисования —
|
||||
квадрат, а картинка плана сохраняет свою форму и вписывается в него по
|
||||
центру: у широкого плана появляются поля сверху и снизу, у вытянутого — по
|
||||
бокам. Выбирать нечего, поэтому настройка ориентации холста для пространств
|
||||
без картинки убрана.
|
||||
- **Существующие планы переносятся один раз, при обновлении.** В самом рисунке
|
||||
ничего не меняется: коробка дополняется до квадрата, и все координаты
|
||||
пересчитываются относительно неё — комнаты, двери и окна, декор, позиции
|
||||
значков и сохранённая область просмотра. Углы, пропорции комнат и взаимное
|
||||
расположение сохраняются точно. Для вытянутых планов заодно пересчитывается
|
||||
масштаб в сантиметрах на клетку: сетка привязана к ширине, и без этого стены
|
||||
молча стали бы короче.
|
||||
|
||||
## v1.47.0 — 2026-07-28 (выбор из уже загруженных планов)
|
||||
- **Диалог пространства показывает планы, сохранённые на сервере.** Отцепление
|
||||
плана оставляет картинку на диске — так с v1.46.4, но вернуть её можно было
|
||||
только найдя исходный файл у себя и загрузив заново. «Уже загруженные»
|
||||
показывают, что есть: миниатюра, размер файла и то, какое пространство его
|
||||
использует. Клик прикрепляет, пропорции читаются из самой картинки — так же,
|
||||
как при загрузке.
|
||||
- **Там же план и удаляется.** Файл плана никогда не удаляется автоматически —
|
||||
ни за отцепление, ни за возраст, — и это разумная политика ровно до тех пор,
|
||||
пока видно, что именно хранится, и есть способ убрать это осознанно. Кнопка
|
||||
корзины делает это и отказывает, пока план используется пространством: ответ
|
||||
на вопрос «можно ли удалить» даёт сохранённая конфигурация, а не браузер.
|
||||
- Документация догнала код: несколько комментариев всё ещё описывали удаление по
|
||||
возрасту, убранное в v1.46.6.
|
||||
|
||||
## v1.46.6 — 2026-07-28 (обещание про отцепление, теперь выполненное)
|
||||
- **Переключение пространства в «нарисовать» больше не удаляет его картинку.**
|
||||
v1.46.4 и v1.46.5 утверждали, что не удаляет, и плановая уборка действительно
|
||||
отцеплённые планы не трогала — но само сохранение удаляло файл в тот момент,
|
||||
когда снималась ссылка, ещё до всех этих проверок. Причина: файл, покинувший
|
||||
конфигурацию, считался «заменённым», а по одной этой разнице замену плана,
|
||||
отцепление и удаление пространства различить невозможно. Удалением, о котором
|
||||
просили, является только первое. Теперь переход классифицируется по
|
||||
пространству-владельцу, и та же разница применяется к вложениям: убрали файл у
|
||||
существующего устройства — он удаляется, удалили устройство — его инструкции
|
||||
остаются.
|
||||
- **План удалённого пространства сохраняется**, а не тридцать дней, как обещала
|
||||
v1.46.5: тридцать дней по возрасту файла всё равно бессмысленны — обычно он
|
||||
загружен месяцы назад.
|
||||
- **По возрасту больше не удаляется ничего**, кроме промежуточной папки диалога.
|
||||
Правило, которое вычищало «отвергнутые загрузки», оказалось в гонке с
|
||||
повторной попыткой: уборка удаляла файл неудавшегося сохранения ровно тогда,
|
||||
когда следующая попытка коммитила ссылку на него. Правило, способное удалить
|
||||
файл, на который кто-то вот-вот сошлётся, не стоит освобождаемого места.
|
||||
Файлы уходят по действию, в остальных случаях остаются.
|
||||
|
||||
## v1.46.5 — 2026-07-28 (ревизия всех автоматических удалений)
|
||||
- **Отцеплённый план не удаляется никогда, ни в каком возрасте.** В v1.46.4 ему
|
||||
давался месяц; теперь это навсегда, и причина записана там, где её увидит
|
||||
следующая правка. Правило, оно же в docs/SCOPE.md: компонент вправе удалить
|
||||
файл только тогда, когда об этом говорит действие пользователя — замена
|
||||
плана, удаление вложения, удаление устройства. «На это больше никто не
|
||||
ссылается» таким действием не является. Ошибки несимметричны: занятое зря
|
||||
место видно, стоит копейки и обратимо; удалённый файл — ничего из этого.
|
||||
- **`houseplan/files/cleanup` больше не сносит папку по слову клиента.** После
|
||||
перепривязки устройства файлы копируются под новый id, а старая папка
|
||||
удалялась — через `rmtree`, по тому id, который прислала карточка. Два плохих
|
||||
исхода: при частичном копировании часть ссылок продолжает указывать внутрь
|
||||
этой папки (миграция намеренно их не переписывает — то есть это были живые
|
||||
ссылки на удаляемые файлы), а неверный или устаревший id от любого клиента
|
||||
уничтожил бы инструкции существующего устройства. Теперь сервер сам сверяется
|
||||
с сохранённой конфигурацией, под её блокировкой, и удаляет только то, на что
|
||||
никто не ссылается.
|
||||
- **План удалённого пространства ждёт тридцать дней вместо часа.** Удаление
|
||||
пространства осознанно, но час — короткое окно, чтобы заметить промах.
|
||||
|
||||
## v1.46.4 — 2026-07-28 (потеря данных: отцеплённые планы убирались как мусор)
|
||||
- **Отцеплённый план больше не удаляется через час.** Переключение пространства
|
||||
в режим «нарисовать» снимает ссылку и, как редактор всегда и говорил,
|
||||
оставляет картинку на диске, чтобы её можно было вернуть. Уборка, добавленная
|
||||
в v1.46.0, этой разницы не делала: считала «на файл сейчас никто не
|
||||
ссылается» синонимом «файл брошен» и применяла часовое правило. На установке
|
||||
автора плановый проход в итоге удалил два плана этажей, отцеплённых
|
||||
несколькими неделями раньше, — восстановить их было нечем. Если вы отцепляли
|
||||
план после v1.46.0 и инстанс перезапускался или проработал сутки — загляните в
|
||||
`config/houseplan/plans/` и напишите в Telegram-чат, если файла нет.
|
||||
Правило теперь такое: **коммит по-прежнему удаляет ровно то, что заменил**, —
|
||||
это он знает наверняка. Дальше вопрос в том, означает ли «непривязан»
|
||||
«брошен», и ответ зависит от случая. У пространства, у которого плана нет
|
||||
вовсе, его отцепили — и, возможно, вернут: его файлы не удаляются никогда. У
|
||||
пространства, у которого план есть, лишние файлы могут быть только его же
|
||||
отвергнутыми загрузками — они по-прежнему уходят через час. Вложения вне
|
||||
промежуточной папки диалога ждут месяц; сама промежуточная папка, где по
|
||||
построению лежит только загрузка из несохранённого диалога, сохраняет часовое
|
||||
правило.
|
||||
|
||||
## v1.46.3 — 2026-07-28 (перепроверка v1.46.2: HP-1462-01)
|
||||
- **Уборка при старте действительно убирает.** Она искала свои же runtime-данные
|
||||
по домену, а во время запуска Home Assistant ещё не считает интеграцию
|
||||
загруженной — поэтому поиск возвращал пустоту, и проход тихо вырождался в
|
||||
удаление незавершённых передач, оставляя настоящую работу таймеру через
|
||||
24 часа. При перезапусках чаще, чем раз в сутки, она не выполнялась вообще.
|
||||
Теперь используется объект, который у неё и так был на руках.
|
||||
- **Тест, который должен был это доказать, проходил по неверной причине.** Он
|
||||
создавал лишние файлы *до* сохранения конфигурации, а сохранение тоже
|
||||
собирает мусор — так что к моменту перезапуска убирать было уже нечего.
|
||||
Переписан: файлы создаются после сохранения; добавлен второй тест, который
|
||||
дёргает плановый таймер отдельно, и третий, который запускает перезапуск и
|
||||
сохранение одновременно и проверяет, что принятая конфигурация никогда не
|
||||
ссылается на удалённый уборкой файл.
|
||||
|
||||
## v1.46.2 — 2026-07-28 (перепроверка v1.46.1: HP-1461-01, -02)
|
||||
- **Файл, который в итоге никому не понадобился, убирается, даже если больше
|
||||
ничего не сохраняют (HP-1461-01).** Сборка привязана к записи конфигурации —
|
||||
это верно для того, что запись вытесняет, но оставляет зазор: отмените диалог
|
||||
после того, как файл уже загрузился, потеряйте связь сразу после, или
|
||||
вызовите API загрузки напрямую — и на файл никто не ссылается, а будущей
|
||||
записи, которая бы это заметила, нет. Добавленное в v1.46.1 ежедневное
|
||||
подметание убирало только незавершённые передачи, поэтому обещание «отменённое
|
||||
вложение исчезнет через час» не выполнялось там, где никто ничего не правит.
|
||||
Теперь подметание сверяется с сохранённой конфигурацией — под той же
|
||||
блокировкой, что и запись, — и собирает устаревшие непривязанные вложения и
|
||||
планы тоже.
|
||||
- **Перетаскивание больше не отменяется чужим перемещением (HP-1461-02).** Когда
|
||||
в v1.46.1 полная карточка научилась следить за позициями, она защищала те,
|
||||
что вы подвинули, но ещё не отправили, — вот только читала этот список *после*
|
||||
сброса отложенной записи, а сброс его первым делом опустошает. При настоящем
|
||||
перетаскивании, когда запись уже запланирована, список оказывался пустым, и
|
||||
старая серверная позиция закрашивала ваше движение. Теперь снимок снимается до
|
||||
сброса, и вдобавок удерживаются позиции, отправленные, но ещё не
|
||||
подтверждённые: пока сервер не подтвердил позицию, авторитет по ней — та
|
||||
карточка, которая её подвинула.
|
||||
- Два теста доросли до своих же описаний: тест загрузки теперь действительно
|
||||
отменяет задачу запроса, а не только проходит по путям ошибок, а смоук
|
||||
синхронизации позиций планирует настоящую отложенную запись и задерживает её —
|
||||
именно этот порядок и терял перетаскивание.
|
||||
|
||||
## v1.46.1 — 2026-07-28 (перепроверка v1.46.0: HP-1460-01 … -03)
|
||||
- **Две загрузки с одинаковым именем больше не сталкиваются (HP-1460-01).**
|
||||
v1.46.0 перестала затирать вложения, но выбор свободного имени и его занятие
|
||||
были двумя шагами: две загрузки, попавшие между ними, сходились на одном
|
||||
имени, обе рапортовали успех, и одни байты заменяли другие. Теперь имя
|
||||
занимается атомарно в момент выбора — двадцать одновременных загрузок
|
||||
`manual.pdf` дают двадцать файлов. Тот же механизм используется при переносе
|
||||
файлов на перепривязке, где был ровно такой же зазор.
|
||||
Заодно там же: имя предельной длины теряло расширение, а суффикс коллизии
|
||||
выталкивал его за предел, и вложение сохранялось под именем, которое сервер
|
||||
обратно не отдаёт — вечный 404 на файл, который интерфейс считал
|
||||
прикреплённым.
|
||||
- **Прерванная загрузка больше не оставляет временный файл навсегда
|
||||
(HP-1460-02).** Уборка стояла в `except Exception`, мимо которого отменённый
|
||||
запрос проходит насквозь, а сборщик заглядывает только в папки маркеров —
|
||||
поэтому оборванная передача оставляла `.upload-*`, и убрать его было некому.
|
||||
Теперь убирает любой путь выхода, запрос с двумя файлами отклоняется сразу, а
|
||||
брошенные временные подметаются при старте и раз в сутки. Запись идёт
|
||||
порциями по мегабайту, а не отдельной задачей на каждые 64 КБ.
|
||||
- **Две полные карточки рядом держат одинаковые позиции (HP-1460-03).** v1.46.0
|
||||
научила следить за перемещениями статическую карточку и оставила позади
|
||||
полную, поэтому перетаскивание иконки в одном окне не двигало её в другом до
|
||||
перезагрузки. Теперь следит — и не мешает собственному перетаскиванию: чужая
|
||||
ревизия, пришедшая в его разгар, сливается, а не накатывается сверху, и
|
||||
карточка не перечитывает то, что записала сама.
|
||||
|
||||
## v1.46.0 — 2026-07-28 (полный внешний аудит v1.45.4: HP-1454-01 … -10)
|
||||
|
||||
**Безопасность**
|
||||
|
||||
- **Загруженный SVG-план больше не является живым документом origin вашего
|
||||
Home Assistant (HP-1454-01, high — блокер релиза).** Внутри карточки план
|
||||
подключён через `<image>`, где скрипты не выполняются; но тот же URL,
|
||||
открытый напрямую, становился документом верхнего уровня в origin самого HA,
|
||||
и `<script>` в нём мог читать `localStorage` сессии и обращаться к API. Для
|
||||
загрузки нужно право записи, а оно по умолчанию есть у каждого
|
||||
аутентифицированного пользователя, и подписанную ссылку несложно передать
|
||||
администратору. Ответы с SVG теперь несут заголовок Content-Security-Policy
|
||||
`sandbox`, который помещает документ в opaque origin. Только SVG — CSP на PDF
|
||||
способен сломать встроенный просмотрщик браузера, а растровая картинка ничего
|
||||
выполнить не может. Для существующих планов ничего не меняется: карточка
|
||||
рисует их ровно как раньше.
|
||||
|
||||
**Целостность данных**
|
||||
|
||||
- **Инструкция, приложенная к устройству, больше не затирает предыдущую
|
||||
(HP-1454-02).** Загрузка писала прямо в `<маркер>/<имя файла>`, вне
|
||||
транзакции конфигурации: отмена диалога или отвергнутое сохранение оставляли
|
||||
сохранённую ссылку указывающей на новые байты. А все новые иконки грузили в
|
||||
одну общую папку, поэтому две с файлом `manual.pdf` начинали ссылаться на
|
||||
один физический файл. Теперь загрузка занимает свободное имя и никогда не
|
||||
перезаписывает, новая иконка получает собственную промежуточную папку, файлы
|
||||
из которой переезжают к настоящей иконке при принятом сохранении, а загрузка,
|
||||
которую никто не сохранил, убирается через час. Имя, на которое уходит
|
||||
коллизия, сменилось с `manual (2).pdf` на `manual-2.pdf`: старое санитайзилось
|
||||
на обратном пути, поэтому переименованное вложение записывалось и больше не
|
||||
отдавалось (нашёл новый тест; до этого релиза так же ломались коллизии при
|
||||
перепривязке).
|
||||
- **Две быстрые правки больше не теряют вторую (HP-1454-03).** Debounce
|
||||
разносил старты сохранения, а не сами сохранения. Если одно длилось дольше
|
||||
полусекунды — занятый сервер, медленная связь, — следующая правка уходила с
|
||||
той же ревизией, сервер принимал первую и отклонял вторую, а обработчик
|
||||
конфликта перечитывал серверную копию поверх локальной. Правка исчезала, и
|
||||
сообщение винило «другое окно», которого не было. Записи сериализованы: по
|
||||
одной за раз, каждая с ревизией, которую вернула предыдущая.
|
||||
|
||||
**Корректность и пределы**
|
||||
|
||||
- **Открытые границы снова следуют за геометрией (HP-1454-04).** Их кэш
|
||||
ключевался только по id комнат и связям, поэтому смена пропорций
|
||||
пространства или перетаскивание вершины оставляли открытые границы — и свет,
|
||||
который через них проходит, — в старых координатах до перезагрузки. Ключом
|
||||
стала сама отрисованная модель, из которой они и вычисляются.
|
||||
- **Конфигурацию больше нельзя сделать сколь угодно тяжёлой (HP-1454-05).**
|
||||
Внешние коллекции были ограничены, вложенные — нет. Полигон на 150 000 точек
|
||||
или список из 100 000 идентификаторов проходили валидацию, а потом каждый
|
||||
рендер по ним ходил. Появились пределы на вершины полигона, связи открытых
|
||||
границ, управляемые сущности, вложения, длину текста и ссылок, плюс общий
|
||||
предел размера сериализованной конфигурации. Устаревшее поле `segments`
|
||||
отбрасывает сервер, а не надежда на современный клиент.
|
||||
- **Большие файлы передаются потоком, а не через память (HP-1454-06).**
|
||||
Инструкция на 50 МБ целиком читалась в память на приёме и ещё раз на отдаче;
|
||||
пара параллельных скачиваний — заметная нагрузка на слабый хост Home
|
||||
Assistant. Загрузка пишется во временный файл потоком, отдача идёт с диска.
|
||||
|
||||
**Согласованность**
|
||||
|
||||
- **Статическая карточка пространства учитывает настройки заливки комнаты
|
||||
(HP-1454-07).** Она строит модель другой функцией, и настройки комнаты в неё
|
||||
не переносились — комната, которой вы выключили заливку, всё равно
|
||||
закрашивалась.
|
||||
- **Перемещение иконки сразу видно на статической карточке (HP-1454-08).**
|
||||
Layout — отдельное состояние без ревизии и без события: перетаскивание на
|
||||
полной карточке оставляло соседнюю статическую со старой позицией до
|
||||
изменения конфигурации или перезагрузки страницы. Теперь записи layout хранят
|
||||
ревизию, возвращают её и сообщают о себе — заодно оптимистическая блокировка
|
||||
на полной записи layout начала что-то значить, ведь точечная запись раньше
|
||||
сбрасывала счётчик.
|
||||
- **Предупреждение о пропавшем плане исчезает вместе с пространством
|
||||
(HP-1454-09).** Уборка смотрела только на существующие пространства, поэтому
|
||||
удаление или переименование оставляло предупреждение в «Ремонте» навсегда.
|
||||
- Сборка: `serialize-javascript` поднят выше двух advisory (HP-1454-10). В
|
||||
рантайме недостижим, production-зависимости и так были чисты, но это одна
|
||||
строка.
|
||||
|
||||
## v1.45.4 — 2026-07-28 (ревью v1.45.3: R5-1, R5-2)
|
||||
- **Частично успешный ответ на подпись больше не пропускает выдержку (R5-1).**
|
||||
Бэкенд подписывает каждый путь независимо: тот, что подписать не удалось,
|
||||
логируется, пропускается, и вызов всё равно завершается успешно с остальными
|
||||
ссылками. Карточка считала любой успешный вызов «весь батч готов», сбрасывала
|
||||
выдержку для всех путей в нём и записывала только вернувшиеся ссылки — и путь,
|
||||
который бэкенд стабильно пропускал, запрашивался заново на каждом рендере, то
|
||||
есть ровно то усиление, ради которого выдержка и вводилась в v1.45.2. Теперь
|
||||
путь считается подписанным, только если в ответе действительно есть ссылка на
|
||||
него; остальные уходят в выдержку по отдельности, ключи, которых не просили,
|
||||
игнорируются, а перерисовка запускается лишь при появлении хотя бы одной новой
|
||||
подписи.
|
||||
- **Снимок состояния больше не противоречит репозиторию (R5-2).** Там всё ещё
|
||||
было написано, что в `main` лежат релизы только до v1.40.1, и приводились
|
||||
счётчики тестов на несколько релизов назад — при том что строка версии рядом
|
||||
исправно обновлялась. Читающий его человек или агент получал неверную
|
||||
модель веток и заниженное представление о покрытии. Роли веток описаны точно,
|
||||
а счётчики убраны: `npm run inventory` печатает их из дерева, и устаревать
|
||||
больше нечему.
|
||||
|
||||
## v1.45.3 — 2026-07-27
|
||||
- **«Значение вместо иконки» невозможно было сохранить (issue #3).** Опция
|
||||
появилась в редакторе устройств ещё в v1.26.0, но серверная схема всё это
|
||||
время принимала только `badge`, `ripple` и `icon_ripple`. При её выборе
|
||||
сохранение падало с
|
||||
`not a valid value for dictionary value @ data['config']['markers'][n]['display']`,
|
||||
а поскольку один отвергнутый маркер валит всю запись конфигурации, план не
|
||||
сохранялся вообще, пока настройку не отменишь. Спасибо @RemyRoux за отчёт и
|
||||
точный текст ошибки.
|
||||
- **Списки опций теперь в одном месте и сверяются между языками.**
|
||||
`DISPLAY_MODES`, `TAP_ACTIONS`, `SPACE_FILL_MODES` и `ROOM_FILL_MODES`
|
||||
экспортируются из карточки, и backend-тест читает их, проверяя, что схема
|
||||
принимает каждое значение, которое пользователь реально может выбрать.
|
||||
Теперь добавить опцию в редактор и забыть про схему — значит уронить тесты, а
|
||||
не узнать об этом из чужого сообщения об ошибке.
|
||||
|
||||
## v1.45.2 — 2026-07-27 (закалка по ревью v1.45.1: R4-1, R4-2)
|
||||
- **Сбой уборки больше не превращает принятое сохранение в ошибку (R4-1).**
|
||||
Сборка вытесненных файлов плана идёт уже после того, как конфигурация
|
||||
|
||||
+13
-1
@@ -34,7 +34,7 @@ Editors are admin-only tools and must never leak interactions into View
|
||||
| J1 | "Show the whole home and what's happening right now" — live spatial overview: device states, room fills (light/temp/LQI), values, multi-floor tabs | **Closed** |
|
||||
| J2 | "Something is wrong — show me *where*" — leak/smoke/gas pulse, open doors/windows, unlocked locks, red dot on devices HA added silently | **Closed** |
|
||||
| J3 | "Let me act on the obvious right from the plan" — tap-to-toggle for safe domains, info cards, guarded lock action | **Closed** |
|
||||
| J4 | "From zero to a working plan in one evening, no Inkscape/YAML" — image/PDF/draw, floors-import wizard, room polygons bound to areas, curated auto-placement, editable icon rules | **Closed**; onboarding polish is *partial* (no registry-driven room suggestions) |
|
||||
| J4 | "From zero to a working plan in one evening, no Inkscape/YAML" — image/PDF/draw, floors-import wizard, room polygons bound to areas, filtered auto-placement, editable icon rules | **Closed**; onboarding polish is *partial* (no registry-driven room suggestions) |
|
||||
| J5 | "Room climate at a glance" — per-room temperature/humidity, comfort-range fills, room-card metrics | **Closed** |
|
||||
| J6 | "Keep the plan true as the home evolves" — new-device flag, two editors, drag/resize, merge/split, multi-client live sync, optimistic locking | **Closed** |
|
||||
| J7 | "Is my Zigbee mesh healthy *here*?" — LQI badges, per-room average, LQI fill | **Closed** (kept deliberately: cheap, spatial by nature, no in-plan competitor) |
|
||||
@@ -66,6 +66,18 @@ refuse locks or be added to this paragraph.
|
||||
- Plan-level "security glance": one badge for "all locked / N open" (J2).
|
||||
- Threshold colouring for room-card metrics (J5).
|
||||
|
||||
## Standing rule: never delete a user's file on an inference
|
||||
|
||||
Fixed with the owner on 2026-07-28, after automatic collection removed two
|
||||
detached floor plans. The component may delete a file only when the user's
|
||||
action says so — replacing a plan, removing an attachment, deleting a device.
|
||||
"Nothing points at this any more" is not such an action: detaching a plan is one
|
||||
click and reversible, and the editor tells the user the file stays.
|
||||
|
||||
The asymmetry is the whole argument. Wasted disk is visible, cheap and
|
||||
reversible; a deleted file is none of those. Where the evidence is weak, keep
|
||||
the file — and if a future version wants to reclaim that space, it asks.
|
||||
|
||||
## Out of scope — never build, point users to the right tool
|
||||
|
||||
- Automations, scenes, scripts, notifications → HA core.
|
||||
|
||||
+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.2** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Version | **v1.50.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — `main` = releases up to **v1.40.1**; `dev` ahead with v1.40.2+ (speaker icons, kiosk). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
|
||||
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 1–3 months (checked 2026-07-24) |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.45.2** via direct copy (HACS custom repo also installed) |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.50.0** 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 |
|
||||
|
||||
|
||||
+127
-2
@@ -66,7 +66,7 @@
|
||||
than a quarter of the plan outside the viewBox [auto: smoke_decor]
|
||||
|
||||
- [ ] Room climate counts hidden sensors (v1.44.5): a thermometer that is NOT
|
||||
placed on the plan (hidden by curation or by the user) still feeds the
|
||||
placed on the plan (hidden by filtering or by the user) still feeds the
|
||||
room card, the tooltip and the temperature fill; fridges/TRVs still do
|
||||
not; an explicit per-room source still wins [auto: unit devices.test]
|
||||
- [ ] Room tooltip wording (v1.44.5): hovering a room shows its name (plus
|
||||
@@ -195,7 +195,7 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
## Devices on the plan ★
|
||||
|
||||
- [ ] Auto devices appear only in rooms bound to their area [manual]
|
||||
- [ ] Curation hides bridges/groups/scenes/excluded integrations; 👁 "show all" reveals [manual]
|
||||
- [ ] Filtering hides bridges/groups/scenes/excluded integrations; 👁 "show all" reveals [manual]
|
||||
- [ ] Duplicate "name|area" numbered ("Lamp", "Lamp 2") [manual]
|
||||
- [ ] Light groups fold their single lamps; `group_lights=false` unfolds [manual]
|
||||
- [ ] Drag anywhere (no edit mode), snaps to grid, persists after reload, per space
|
||||
@@ -234,6 +234,131 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
are only reachable through /api/houseplan/content/… with a session; the
|
||||
old /houseplan_files/plans|files paths return 404 after a restart; old
|
||||
stored URLs keep working (rewritten on read) [auto+manual]
|
||||
- [ ] Every editor option is storable (v1.45.3, issue #3): set a sensor to
|
||||
"value instead of an icon" and save — no validation error, the value shows
|
||||
on the plan after a reload. Same for each tap action and each fill mode
|
||||
[auto: backend test_every_display_mode_the_editor_offers_is_accepted and
|
||||
neighbours, test_a_marker_showing_its_value_can_be_saved]
|
||||
- [ ] Editors see the whole canvas (v1.50.0, HP-1490-03): a hand-drawn space
|
||||
with one small room opens content-fit in View; switching to the plan
|
||||
editor shows the full square with room to draw a second room far away;
|
||||
back to View restores the content fit [auto: smoke_audit_1490]
|
||||
- [ ] Save waits for a picked plan's proportions (v1.50.0, HP-1490-04): pick a
|
||||
saved plan and hit Save before the thumbnail loads — the stored aspect is
|
||||
the real one, never the previous file's [auto: smoke_audit_1490]
|
||||
- [ ] Zoom goes below the fit (v1.50.0): minus past 100% floats the plan
|
||||
centred, floor at 0.4x; entering an editor keeps the stage inside the
|
||||
viewport [auto: smoke_zoom_out]
|
||||
- [ ] Migration crash recovery (v1.50.0, HP-1490-01): kill HA between the two
|
||||
store writes of the square migration — the next start finishes the layout
|
||||
half from the saved intent
|
||||
[auto: test_square_migration_finishes_after_a_crash_between_the_writes]
|
||||
- [ ] Parallel upload quota (v1.50.0, HP-1490-02): two simultaneous uploads
|
||||
with one slot left — exactly one succeeds
|
||||
[auto: test_parallel_uploads_cannot_slip_past_the_quota_together]
|
||||
- [ ] Zoom opens on the content (v1.49.0): a space with no background and one
|
||||
small room opens with that room filling the screen, with a small margin.
|
||||
With a background it still fits the whole image
|
||||
[auto: unit: contentBounds]
|
||||
- [ ] Deleting a picked plan is refused (v1.49.0, HP-1470-02): pick a saved
|
||||
plan, reopen the list — its delete button is disabled. Ask the server to
|
||||
store a plan url whose file is gone: `missing_plan`, and the revision does
|
||||
not move [auto: smoke_saved_plans + backend
|
||||
test_config_set_refuses_a_plan_that_no_longer_exists]
|
||||
- [ ] Uploads are bounded (v1.49.0, HP-1470-01): past the store quota an upload
|
||||
is refused with a clear error and the disk does not grow; the plan list
|
||||
returns the newest 60 with a total
|
||||
[auto: unit: test_check_quota_counts_the_whole_store_not_one_request,
|
||||
backend test_uploads_are_bounded_by_a_store_quota]
|
||||
- [ ] Square canvas migration (v1.48.0): after the upgrade every existing plan
|
||||
looks exactly as before, just with margins where the canvas was extended.
|
||||
Measure a wall in the plan editor — the length in cm is unchanged. Marker
|
||||
positions, doors, decor and the saved zoom are all where they were
|
||||
[auto: unit: test_a_wide_plan_gains_margins_above_and_below and neighbours,
|
||||
test_migration_preserves_real_lengths_and_shapes]
|
||||
- [ ] A plan image is centred (v1.48.0): a wide image sits in the middle with
|
||||
empty bands above and below, a tall one with bands at the sides, and it is
|
||||
never stretched [auto: unit: fitInSquare + smoke_space_settings]
|
||||
- [ ] Re-attaching a detached plan (v1.47.0): detach a plan, save, RELOAD THE
|
||||
PAGE, open space settings → "Already uploaded" → the image is listed with
|
||||
its size and no "in use" note → attach it → it renders. The one a space
|
||||
uses shows that space and cannot be deleted; a free one can, with a
|
||||
confirm, and disappears from the list
|
||||
[auto: smoke_saved_plans + backend test_stored_plans_can_be_listed_and_deleted_on_request]
|
||||
- [ ] Detaching a plan keeps the file (v1.46.6): switch a space to "draw" and
|
||||
SAVE — the image is still in `config/houseplan/plans/` right afterwards,
|
||||
and after a restart, and can be re-attached. Deleting the space keeps it
|
||||
too. Replacing a plan still removes the one it replaced, immediately.
|
||||
Check straight after the save: the earlier bug deleted the file at that
|
||||
moment, while every scheduled-pass test passed
|
||||
[auto: unit: test_plan_collection_matrix, test_attachment_collection_matrix,
|
||||
backend test_detaching_a_plan_keeps_the_file]
|
||||
- [ ] Rebinding a device does not eat its manuals (v1.46.5): attach two files to
|
||||
a device, rebind it to another HA device — both are readable afterwards.
|
||||
If a copy failed, the file it failed on is still there rather than deleted
|
||||
with the folder [auto: backend test_files_cleanup_keeps_referenced_files]
|
||||
- [ ] Nothing accumulates on an idle instance (v1.46.2/v1.46.3, HP-1461-01,
|
||||
HP-1462-01): attach a file, cancel the dialog, and do not save anything
|
||||
else — the file is gone after a restart AND after the daily pass, while
|
||||
every file the configuration still references is untouched. Seed the
|
||||
strays AFTER the last save, or `config/set` collects them and the check
|
||||
proves nothing
|
||||
[auto: backend test_startup_sweep_collects_what_no_commit_will,
|
||||
test_daily_sweep_callback_collects_too, test_sweep_and_a_config_write_do_not_race]
|
||||
- [ ] A drag wins over a concurrent remote move (v1.46.2, HP-1461-02): drag an
|
||||
icon and, while the save is still in flight, have another window move a
|
||||
different icon — your icon stays where you put it and the other one
|
||||
updates [auto: smoke_layout_sync]
|
||||
- [ ] Concurrent uploads of one name (v1.46.1, HP-1460-01): attach the same
|
||||
file from two browser tabs at once — two attachments, two sets of bytes,
|
||||
neither lost. A file whose name is at the length limit still downloads
|
||||
[auto: unit: test_reserve_filename_is_safe_under_concurrency and neighbours]
|
||||
- [ ] No temporary files survive (v1.46.1, HP-1460-02): abort a large upload
|
||||
mid-transfer, send two files in one request, make promotion fail — in each
|
||||
case the files folder holds no `.upload-*`. An old one is swept at startup
|
||||
[auto: backend test_upload_leaves_no_temporary_behind + unit: sweep_upload_temps]
|
||||
- [ ] Two full cards agree on positions (v1.46.1, HP-1460-03): open the plan in
|
||||
two windows, drag an icon in one — it moves in the other without a reload;
|
||||
a drag in progress in the second window is not thrown away
|
||||
[auto: smoke_layout_sync]
|
||||
- [ ] Uploaded SVG is inert as a document (v1.46.0, HP-1454-01): open a plan's
|
||||
signed url directly in a tab — a `<script>` inside it must not run and must
|
||||
not reach the HA session's localStorage; the same plan still renders in the
|
||||
card. PDFs still open in the browser viewer
|
||||
[auto: smoke_svg_sandbox + backend test_uploaded_svg_is_sandboxed_and_a_pdf_is_not]
|
||||
- [ ] An attachment never overwrites another (v1.46.0, HP-1454-02): attach a file,
|
||||
cancel the dialog — the previously stored file is byte-identical. Attach
|
||||
`manual.pdf` to two NEW icons — two independent files. A cancelled upload is
|
||||
gone an hour later
|
||||
[auto: backend test_upload_never_overwrites_an_existing_attachment + unit: collect_attachments]
|
||||
- [ ] Two quick edits both survive (v1.46.0, HP-1454-03): with a slow connection,
|
||||
make an edit and another one before the first save answers — both are in the
|
||||
stored config, only one write is ever in flight, and no conflict toast fires
|
||||
[auto: smoke_config_writer]
|
||||
- [ ] Open boundaries follow geometry (v1.46.0, HP-1454-04): change a space's
|
||||
aspect or drag a room vertex — the open boundary and the light through it
|
||||
move with the walls, without a reload [auto: smoke via model-identity key]
|
||||
- [ ] Inner limits (v1.46.0, HP-1454-05): max and max+1 for polygon points,
|
||||
open_to, controls, pdfs, text and url lengths; an oversized config as a
|
||||
whole is refused with `too_large`
|
||||
[auto: unit: test_inner_collection_limits + backend test_config_write_is_capped_by_total_size]
|
||||
- [ ] Big files stream (v1.46.0, HP-1454-06): upload a ~50 MB manual and download
|
||||
it twice in parallel — HA's memory does not grow by a file per transfer
|
||||
[manual]
|
||||
- [ ] Static card parity (v1.46.0, HP-1454-07): a room whose fill is set to "none"
|
||||
under a space filled by light is transparent on BOTH cards
|
||||
[auto: smoke_render_parity]
|
||||
- [ ] Layout reaches the static card (v1.46.0, HP-1454-08): drag an icon on the
|
||||
full card — a static card on the same dashboard moves it too, with no
|
||||
config write and no reload
|
||||
[auto: backend test_layout_keeps_its_revision_and_announces_changes + manual]
|
||||
- [ ] Repair issues are not immortal (v1.46.0, HP-1454-09): create a missing-plan
|
||||
warning, then delete the space — the warning disappears [manual]
|
||||
- [ ] A path the backend cannot sign does not become a request loop (v1.45.4,
|
||||
review R5-1): when `content/sign` answers successfully but omits a path,
|
||||
the card backs that path off individually and keeps the urls it did get;
|
||||
a re-render asks only for what is still missing, and only after the wait
|
||||
[auto: unit: signing.test + backend test_signing_one_path_may_fail_without_failing_the_request]
|
||||
- [ ] Signing does not amplify on a bad connection (v1.45.2, review R4-2): with
|
||||
the WebSocket slow or refusing, the card issues ONE sign request per url
|
||||
and backs off after a failure instead of asking again on every render; a
|
||||
|
||||
Generated
+7
-38
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.41.2",
|
||||
"version": "1.50.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.41.2",
|
||||
"version": "1.50.0",
|
||||
"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.2",
|
||||
"version": "1.50.0",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -8,7 +8,8 @@
|
||||
"build": "tsc --noEmit && rollup -c",
|
||||
"watch": "rollup -c --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/*.test.mjs"
|
||||
"test": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/*.test.mjs",
|
||||
"inventory": "node scripts/inventory.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
@@ -24,5 +25,8 @@
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3",
|
||||
"polyclip-ts": "^0.16.8"
|
||||
},
|
||||
"overrides": {
|
||||
"serialize-javascript": "^7.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Current test inventory, printed on demand.
|
||||
//
|
||||
// docs/STATUS.md used to carry these numbers inline; they went stale within a
|
||||
// couple of releases while the version line next to them was kept current,
|
||||
// which is worse than no number at all — a maintainer reading the snapshot
|
||||
// underestimates the coverage that exists (review R5-2). The counts live here
|
||||
// now, one command away, and STATUS.md describes the layers instead.
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
|
||||
const count = (dir, match, re) =>
|
||||
readdirSync(dir)
|
||||
.filter((f) => match.test(f))
|
||||
.reduce((n, f) => n + (readFileSync(`${dir}/${f}`, 'utf8').match(re) || []).length, 0);
|
||||
|
||||
const files = (dir, match) => readdirSync(dir).filter((f) => match.test(f)).length;
|
||||
|
||||
const rows = [
|
||||
['frontend unit (node:test)', count('test', /\.test\.mjs$/, /^test\(/gm)],
|
||||
['pure backend (pytest, no HA)', count('tests_backend', /^test_validation\.py$/, /^def test_/gm)],
|
||||
['HA-harness backend (CI, py3.13)', count('tests_backend', /^test_ha_.*\.py$/, /^async def test_|^def test_/gm)],
|
||||
['browser smokes (headless chromium)', files('demo', /^smoke_.*\.mjs$/)],
|
||||
];
|
||||
const w = Math.max(...rows.map(([n]) => n.length));
|
||||
for (const [name, n] of rows) console.log(`${name.padEnd(w)} ${n}`);
|
||||
+10
-4
@@ -47,11 +47,17 @@ async function fetchFresh(hass: any): Promise<HpConfigSnapshot> {
|
||||
};
|
||||
if (!subscribed && hass.connection?.subscribeEvents) {
|
||||
subscribed = true;
|
||||
const invalidate = () => {
|
||||
cache = null; // invalidate; listeners reload
|
||||
listeners.forEach((l) => l());
|
||||
};
|
||||
try {
|
||||
await hass.connection.subscribeEvents(() => {
|
||||
cache = null; // invalidate; listeners reload
|
||||
listeners.forEach((l) => l());
|
||||
}, 'houseplan_config_updated');
|
||||
await hass.connection.subscribeEvents(invalidate, 'houseplan_config_updated');
|
||||
// Layout is separate state: dragging an icon on the full card writes only
|
||||
// the layout, so a static card on the same dashboard kept showing the old
|
||||
// position until the config changed or the page was reloaded — possibly
|
||||
// forever on a wall tablet (HP-1454-08).
|
||||
await hass.connection.subscribeEvents(invalidate, 'houseplan_layout_updated');
|
||||
} catch {
|
||||
subscribed = false;
|
||||
}
|
||||
|
||||
+7
-7
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Building the device list from HA registries: curation, light groups,
|
||||
* Building the device list from HA registries: filtering, light groups,
|
||||
* markers (overrides/virtual). No Lit/DOM — only the hass object.
|
||||
*/
|
||||
import { iconFor, iconFromDeviceClasses, DOMAIN_PRIORITY, FALLBACK_ICON, type CompiledIconRule, EXCLUDED_DOMAINS } from './rules';
|
||||
@@ -184,7 +184,7 @@ function applyMarker(item: DevItem, m: Marker): void {
|
||||
item.tapAction = m.tap_action ?? null;
|
||||
}
|
||||
|
||||
/** Curation + light groups + markers (metadata/rebinding) + virtual ones. A hybrid. */
|
||||
/** Filtering + light groups + markers (metadata/rebinding) + virtual ones. A hybrid. */
|
||||
export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
const { hass: h, areaToSpace, markers, settings, excluded, showAll, firstSpaceId, loc, iconRules } = ctx;
|
||||
const groupLights = settings.group_lights !== false;
|
||||
@@ -210,7 +210,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
if (marker && marker.hidden) continue;
|
||||
const entIds = entsBy[dev.id] || [];
|
||||
const dom = domainOfDevice(h, dev, entIds);
|
||||
// curation (can be turned off with the “show all” toggle)
|
||||
// filtering (can be turned off with the “show all” toggle)
|
||||
if (!showAll) {
|
||||
if (excluded.has(dom)) continue;
|
||||
if (dev.model === 'Group') continue;
|
||||
@@ -391,7 +391,7 @@ export function areaHum(
|
||||
const vals: number[] = [];
|
||||
for (const dv of devices) {
|
||||
if (dv.area !== area) continue;
|
||||
// same curation idea as areaTemp: climate sensors only, not fridges/plugs
|
||||
// same filtering idea as areaTemp: climate sensors only, not fridges/plugs
|
||||
if (dv.icon !== 'mdi:thermometer' && dv.icon !== 'mdi:air-filter' && dv.icon !== 'mdi:water-percent') continue;
|
||||
const h = humFor(hass, dv.entities);
|
||||
if (h != null) vals.push(h);
|
||||
@@ -416,11 +416,11 @@ const NON_AIR_RE = new RegExp(
|
||||
|
||||
/**
|
||||
* Room climate from EVERY sensor of the area — including devices that are not
|
||||
* placed on the plan (hidden by curation or by the user). The old helpers read
|
||||
* placed on the plan (hidden by filtering or by the user). The old helpers read
|
||||
* the visible-icon list, so hiding a thermometer silently removed it from the
|
||||
* room card (field report, 2026-07-27).
|
||||
*
|
||||
* Curation is kept: only devices the card itself recognises as thermometers /
|
||||
* Filtering is kept: only devices the card itself recognises as thermometers /
|
||||
* air monitors count, so fridges, TRVs and chip-temperature plugs stay out.
|
||||
* The AUTO icon is used on purpose — a custom marker icon must not change what
|
||||
* a device measures.
|
||||
@@ -451,7 +451,7 @@ export function areaClimateMap(
|
||||
// 90 C and a virtual better_thermostat duplicating the real sensor (field
|
||||
// question, 2026-07-27). Three guards, cheapest first:
|
||||
if (reg.entity_category) continue; // diagnostic/config readings
|
||||
if (EXCLUDED_DOMAINS.has(reg.platform)) continue; // curated-out integrations
|
||||
if (EXCLUDED_DOMAINS.has(reg.platform)) continue; // filtered-out integrations
|
||||
if (NON_AIR_RE.test(eid)) continue; // water/chip/flow/target/...
|
||||
let groups = byArea.get(area);
|
||||
if (!groups) { groups = new Map(); byArea.set(area, groups); }
|
||||
|
||||
+466
-121
File diff suppressed because it is too large
Load Diff
+13
-3
@@ -21,7 +21,7 @@
|
||||
"title.zoom_out": "Zoom out",
|
||||
"title.zoom_reset": "Reset zoom",
|
||||
"title.add_device": "Add a device to the plan",
|
||||
"title.show_all": "Show all area devices (no curation)",
|
||||
"title.show_all": "Show all area devices (no filtering)",
|
||||
"title.markup": "Room markup: grid, lines, outlines",
|
||||
"title.configure_space": "Configure space",
|
||||
"title.add_space": "Add space",
|
||||
@@ -323,10 +323,20 @@
|
||||
"room.label_scale": "Metrics size",
|
||||
"preview.room_name": "Living room",
|
||||
"toast.cfg_reload_failed": "Could not reload the plan from the server: {err}",
|
||||
"room.settings_short": "Room",
|
||||
"room.settings_short": "Room settings",
|
||||
"room.unnamed": "Unnamed room",
|
||||
"marker.is_light": "This device is a light source",
|
||||
"marker.is_light_tip": "Makes the icon glow in the “Light sources” fill even without a light entity — for a smart switch driving ordinary fixtures. The glow follows the switch (or the lights bound above).",
|
||||
"confirm.unlock": "Unlock “{name}”?",
|
||||
"toast.files_migrate_failed": "Attachments could not be moved to the new binding, links keep pointing at the old files: {err}"
|
||||
"toast.files_migrate_failed": "Attachments could not be moved to the new binding, links keep pointing at the old files: {err}",
|
||||
"space.pick_saved": "Already uploaded",
|
||||
"space.pick_saved_hint": "Plans stored on the server, including ones you detached earlier",
|
||||
"space.no_saved": "No plans stored on the server yet.",
|
||||
"space.loading": "Loading…",
|
||||
"space.used_by": "in use: {list}",
|
||||
"space.in_use": "A space still uses this plan — detach it first",
|
||||
"btn.use": "Use",
|
||||
"confirm.delete_plan": "Delete the plan file \"{name}\" from the server? This cannot be undone.",
|
||||
"toast.plans_list_failed": "Could not list the stored plans: {err}",
|
||||
"toast.plan_delete_failed": "Could not delete the plan: {err}"
|
||||
}
|
||||
|
||||
+13
-3
@@ -21,7 +21,7 @@
|
||||
"title.zoom_out": "Отдалить",
|
||||
"title.zoom_reset": "Сбросить масштаб",
|
||||
"title.add_device": "Добавить устройство на план",
|
||||
"title.show_all": "Показывать все устройства зоны (без курирования)",
|
||||
"title.show_all": "Показывать все устройства зоны (без фильтрации)",
|
||||
"title.markup": "Разметка комнат: сетка, линии, контуры",
|
||||
"title.configure_space": "Настроить пространство",
|
||||
"title.add_space": "Добавить пространство",
|
||||
@@ -323,10 +323,20 @@
|
||||
"room.label_scale": "Размер подписей",
|
||||
"preview.room_name": "Гостиная",
|
||||
"toast.cfg_reload_failed": "Не удалось перечитать план с сервера: {err}",
|
||||
"room.settings_short": "Комната",
|
||||
"room.settings_short": "Настройки комнаты",
|
||||
"room.unnamed": "Комната без имени",
|
||||
"marker.is_light": "Это устройство — источник света",
|
||||
"marker.is_light_tip": "Даёт ореол в заливке «Свет по источникам» даже без light-сущности — для умного выключателя с обычными светильниками. Ореол следует за выключателем (или за привязанными выше лампами).",
|
||||
"confirm.unlock": "Открыть замок «{name}»?",
|
||||
"toast.files_migrate_failed": "Не удалось перенести вложения к новой привязке, ссылки остались на старые файлы: {err}"
|
||||
"toast.files_migrate_failed": "Не удалось перенести вложения к новой привязке, ссылки остались на старые файлы: {err}",
|
||||
"space.pick_saved": "Уже загруженные",
|
||||
"space.pick_saved_hint": "Планы, сохранённые на сервере, включая отцеплённые ранее",
|
||||
"space.no_saved": "На сервере пока нет сохранённых планов.",
|
||||
"space.loading": "Загрузка…",
|
||||
"space.used_by": "используется: {list}",
|
||||
"space.in_use": "План используется пространством — сначала отцепите его",
|
||||
"btn.use": "Выбрать",
|
||||
"confirm.delete_plan": "Удалить файл плана «{name}» с сервера? Действие необратимо.",
|
||||
"toast.plans_list_failed": "Не удалось получить список планов: {err}",
|
||||
"toast.plan_delete_failed": "Не удалось удалить план: {err}"
|
||||
}
|
||||
|
||||
@@ -531,6 +531,27 @@ export function safeUrl(url: string | null | undefined): string | null {
|
||||
export type TapAction = 'info' | 'more-info' | 'toggle';
|
||||
|
||||
/** Domains a card-wide `tap_action: toggle` may toggle (accidental-tap safe). */
|
||||
/**
|
||||
* The option lists the editors offer, in one place — and the reason they are
|
||||
* here rather than inline in the templates.
|
||||
*
|
||||
* `display` gained 'value' in v1.26.0 ("show the measurement instead of the
|
||||
* icon") but the backend schema still only accepted badge/ripple/icon_ripple,
|
||||
* so saving any marker configured that way was rejected outright — and since
|
||||
* one bad marker fails the whole config write, the plan could not be saved at
|
||||
* all. Shipped 2026-07-21, found by a user on 2026-07-27: six days, and only
|
||||
* because they pasted the error text. Nothing in the suite could have caught
|
||||
* it, because the option list and the schema that stores it were written in
|
||||
* two languages and never compared. They are exported here so a backend test
|
||||
* can read them and assert the schema accepts every value a user can pick.
|
||||
* Adding an option here and forgetting the schema now fails the test suite.
|
||||
*/
|
||||
export const DISPLAY_MODES = ['badge', 'ripple', 'icon_ripple', 'value'] as const;
|
||||
export const TAP_ACTIONS = ['info', 'more-info', 'toggle'] as const;
|
||||
/** Space-level fill: 'glow' is a whole-space light model, not a per-room one. */
|
||||
export const SPACE_FILL_MODES = ['none', 'lqi', 'light', 'temp', 'glow'] as const;
|
||||
export const ROOM_FILL_MODES = ['none', 'lqi', 'light', 'temp'] as const;
|
||||
|
||||
export const TOGGLE_SAFE_DOMAINS = new Set(['light', 'switch', 'fan', 'humidifier']);
|
||||
|
||||
/**
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Device curation and icon rules.
|
||||
* Device filtering and icon rules.
|
||||
*
|
||||
* Icon rules are DATA, not code: the built-in defaults below can be overridden
|
||||
* per instance via `config.settings.icon_rules` (edited in the card UI).
|
||||
@@ -8,7 +8,7 @@
|
||||
* skipped silently at compile time (and flagged in the rules editor).
|
||||
*/
|
||||
|
||||
/** Integration domains whose devices are hidden by default (curation). */
|
||||
/** Integration domains whose devices are hidden by default (filtering). */
|
||||
export const EXCLUDED_DOMAINS = new Set([
|
||||
'hacs', 'sun', 'backup', 'hassio', 'met', 'telegram_bot', 'mobile_app',
|
||||
'systemmonitor', 'better_thermostat', 'adaptive_lighting', 'yandex_pogoda',
|
||||
|
||||
+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
|
||||
|
||||
+65
-8
@@ -6,20 +6,41 @@
|
||||
import { declump, contentUrl } from './logic';
|
||||
import type { ServerConfig, SpaceModel, RoomCfg, DevItem } from './types';
|
||||
|
||||
export const NORM_W = 1000; // width of the render space for normalized configs
|
||||
export const NORM_W = 1000; // side of the render space — the canvas is square
|
||||
|
||||
/**
|
||||
* Where a plan image sits inside the square canvas (v1.48.0).
|
||||
*
|
||||
* The canvas has no proportions of its own any more; the image keeps its own
|
||||
* and is centred, so a wide plan gets margins above and below and a tall one
|
||||
* gets them at the sides. `ratio` is the image's width/height; without it we
|
||||
* assume square, which is only ever a brief guess before the file loads.
|
||||
*/
|
||||
export function fitInSquare(ratio: number | null | undefined, side: number) {
|
||||
const r = Number(ratio);
|
||||
const a = Number.isFinite(r) && r > 0 ? r : 1;
|
||||
const w = a >= 1 ? side : side * a;
|
||||
const h = a >= 1 ? side / a : side;
|
||||
return { x: (side - w) / 2, y: (side - h) / 2, w, h };
|
||||
}
|
||||
|
||||
export type Pt = { x: number; y: number };
|
||||
export type Layout = Record<string, { s?: string; x: number; y: number } | undefined>;
|
||||
|
||||
/** Build render-space models (NORM_W × NORM_W/aspect) from a server config. */
|
||||
/** Build render-space models (NORM_W × NORM_W) from a server config. */
|
||||
export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
|
||||
if (!cfg || !Array.isArray(cfg.spaces)) return [];
|
||||
return cfg.spaces.map((s: any) => {
|
||||
const H = NORM_W / s.aspect;
|
||||
const H = NORM_W; // square canvas
|
||||
const scale = (r: any): RoomCfg => ({
|
||||
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,
|
||||
@@ -30,12 +51,50 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
|
||||
bg: s.plan_url ? { href: contentUrl(s.plan_url), x: 0, y: 0, w: NORM_W, h: H } : null,
|
||||
bg: s.plan_url ? { href: contentUrl(s.plan_url), ...fitInSquare(s.plan_aspect, NORM_W) } : null,
|
||||
rooms: (s.rooms || []).map(scale),
|
||||
} as SpaceModel;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* What the plan actually occupies, padded by `pad` of the larger side.
|
||||
*
|
||||
* The canvas is a square big enough for any house, so a small hand-drawn plan
|
||||
* used to open as a speck in the middle of it. Zooming to the CONTENT instead
|
||||
* of the canvas is what people expect — but only when there is no background
|
||||
* image: with one, the image is the plan, and cropping to the rooms would hide
|
||||
* the parts of it nobody has outlined yet.
|
||||
*
|
||||
* Returns null when there is nothing drawn, so the caller keeps the full canvas.
|
||||
*/
|
||||
export function contentBounds(
|
||||
space: SpaceModel, pad = 0.05, extra?: ReadonlyArray<readonly [number, number]>,
|
||||
): { x: number; y: number; w: number; h: number } | null {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
const add = (x: number, y: number) => {
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||||
if (x < minX) minX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y > maxY) maxY = y;
|
||||
};
|
||||
for (const r of space.rooms || []) {
|
||||
if (r.poly) for (const p of r.poly) add(p[0], p[1]);
|
||||
else if (r.x != null && r.y != null) {
|
||||
add(r.x, r.y);
|
||||
add(r.x + (r.w || 0), r.y + (r.h || 0));
|
||||
}
|
||||
}
|
||||
// things that live outside any room still count as content — a gate sensor
|
||||
// by the fence, a camera on a pole (the card passes device positions here)
|
||||
for (const p of extra || []) add(p[0], p[1]);
|
||||
if (minX > maxX || minY > maxY) return null;
|
||||
const m = Math.max(maxX - minX, maxY - minY) * pad;
|
||||
const x = minX - m, y = minY - m;
|
||||
return { x, y, w: (maxX - minX) + m * 2, h: (maxY - minY) + m * 2 };
|
||||
}
|
||||
|
||||
/** Bounding rectangle of a room (rect or polygon) in render units. */
|
||||
export function roomBounds(r: RoomCfg): { x: number; y: number; w: number; h: number } {
|
||||
if (r.poly && r.poly.length) {
|
||||
@@ -86,8 +145,7 @@ export function defaultPositions(devs: DevItem[], model: SpaceModel, iconPct: nu
|
||||
export function markerPos(d: DevItem, layout: Layout, cfg: ServerConfig, defPos: Record<string, Pt>, model: SpaceModel): Pt {
|
||||
const saved = layout[d.id];
|
||||
if (saved && saved.s === d.space) {
|
||||
const aspect = cfg.spaces.find((x: any) => x.id === d.space)?.aspect || 1;
|
||||
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
|
||||
return { x: saved.x * NORM_W, y: saved.y * NORM_W };
|
||||
}
|
||||
if (defPos[d.id]) return defPos[d.id];
|
||||
const vb = model.vb;
|
||||
@@ -98,8 +156,7 @@ export function markerPos(d: DevItem, layout: Layout, cfg: ServerConfig, defPos:
|
||||
export function labelPos(r: RoomCfg, spaceId: string, layout: Layout, cfg: ServerConfig): Pt {
|
||||
const saved = layout['rl_' + (r.id || '')];
|
||||
if (saved && saved.s === spaceId) {
|
||||
const aspect = cfg.spaces.find((x: any) => x.id === spaceId)?.aspect || 1;
|
||||
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
|
||||
return { x: saved.x * NORM_W, y: saved.y * NORM_W };
|
||||
}
|
||||
const c = roomCenter(r);
|
||||
return { x: c[0], y: c[1] };
|
||||
|
||||
+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),
|
||||
|
||||
+51
-1
@@ -383,6 +383,21 @@ export const cardStyles = css`
|
||||
gap: 0.25em;
|
||||
font-size: calc(1em * var(--rl-name, 1));
|
||||
}
|
||||
/* Switching spaces by swipe or on the kiosk carousel: the plan flies out
|
||||
the way the finger went and the next one arrives from the other side. */
|
||||
@keyframes hp-slide-left {
|
||||
0% { transform: translateX(22%); opacity: 0; }
|
||||
100% { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes hp-slide-right {
|
||||
0% { transform: translateX(-22%); opacity: 0; }
|
||||
100% { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
.zoomwrap.slide-left { animation: hp-slide-left 0.26s cubic-bezier(0.22, 0.61, 0.36, 1); }
|
||||
.zoomwrap.slide-right { animation: hp-slide-right 0.26s cubic-bezier(0.22, 0.61, 0.36, 1); }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.zoomwrap.slide-left, .zoomwrap.slide-right { animation: none; }
|
||||
}
|
||||
.rlgearbtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -402,7 +417,8 @@ export const cardStyles = css`
|
||||
opacity: 0.92;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.rlgearbtn:hover { opacity: 1; }
|
||||
.rlgearbtn { transition: opacity 0.15s, filter 0.15s; }
|
||||
.rlgearbtn:hover { opacity: 1; filter: brightness(1.18); }
|
||||
.rlgearbtn ha-icon { --mdc-icon-size: 14px; display: inline-flex; }
|
||||
.rlgear {
|
||||
--mdc-icon-size: 0.9em;
|
||||
@@ -1062,6 +1078,40 @@ export const cardStyles = css`
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
/* the "already uploaded" picker: a plan is never deleted for being
|
||||
unreferenced, so it has to be findable again */
|
||||
.savedplans {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
margin: 6px 0 2px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--hp-line);
|
||||
border-radius: 8px;
|
||||
background: var(--hp-bg2, rgba(255, 255, 255, 0.03));
|
||||
}
|
||||
.savedplan {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.savedplan.cur { outline: 1px solid var(--hp-accent); border-radius: 6px; }
|
||||
.savedplan img {
|
||||
width: 56px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--hp-line);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
flex: none;
|
||||
}
|
||||
.savedmeta { display: flex; flex-direction: column; min-width: 0; flex: 1; }
|
||||
.savedmeta b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.savedmeta .muted { font-size: 11px; }
|
||||
.savedplan .btn.danger ha-icon { color: #f25a4a; }
|
||||
.savedplan .btn[disabled] { opacity: 0.4; pointer-events: none; }
|
||||
.planprev {
|
||||
max-width: 120px;
|
||||
max-height: 70px;
|
||||
|
||||
+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]);
|
||||
});
|
||||
|
||||
@@ -1,34 +1,47 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
NORM_W, spaceModels, roomBounds, roomCenter, defaultPositions, markerPos, labelPos,
|
||||
NORM_W, spaceModels, roomBounds, roomCenter, defaultPositions, markerPos, labelPos, fitInSquare, contentBounds,
|
||||
} from '../test-build/space-geometry.js';
|
||||
|
||||
const cfg = {
|
||||
spaces: [{
|
||||
id: 'f1', title: '1st', aspect: 2, plan_url: '/plans/f1.svg', view_box: [0, 0, 1, 1],
|
||||
id: 'f1', title: '1st', plan_aspect: 2, plan_url: '/plans/f1.svg', view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'r1', name: 'Room', area: 'a1', poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]] }],
|
||||
}, {
|
||||
id: 'yard', title: 'Yard', aspect: 1, view_box: [0, 0, 1, 1], rooms: [],
|
||||
id: 'yard', title: 'Yard', view_box: [0, 0, 1, 1], rooms: [],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
|
||||
test('spaceModels: scales vb/rooms by NORM_W and H=NORM_W/aspect; bg only with plan_url', () => {
|
||||
test('spaceModels: the canvas is square; the image is centred by its own ratio', () => {
|
||||
const m = spaceModels(cfg);
|
||||
assert.equal(m.length, 2);
|
||||
const f1 = m[0];
|
||||
assert.deepEqual(f1.vb, [0, 0, 1000, 500]); // aspect 2 → H 500
|
||||
assert.deepEqual(f1.vb, [0, 0, 1000, 1000]);
|
||||
assert.equal(f1.bg.href, '/plans/f1.svg');
|
||||
assert.deepEqual(f1.rooms[0].poly, [[100, 50], [500, 50], [500, 250], [100, 250]]);
|
||||
// a plan twice as wide as it is tall: full width, half height, margins above
|
||||
// and below — the canvas has no proportions of its own any more (v1.48.0)
|
||||
assert.deepEqual(f1.bg, { href: '/plans/f1.svg', x: 0, y: 250, w: 1000, h: 500 });
|
||||
assert.deepEqual(f1.rooms[0].poly, [[100, 100], [500, 100], [500, 500], [100, 500]]);
|
||||
assert.equal(m[1].bg, null); // no plan_url
|
||||
assert.equal(spaceModels(null).length, 0);
|
||||
});
|
||||
|
||||
test('fitInSquare: wide gets top/bottom margins, tall gets side margins', () => {
|
||||
assert.deepEqual(fitInSquare(2, 1000), { x: 0, y: 250, w: 1000, h: 500 });
|
||||
assert.deepEqual(fitInSquare(0.5, 1000), { x: 250, y: 0, w: 500, h: 1000 });
|
||||
assert.deepEqual(fitInSquare(1, 1000), { x: 0, y: 0, w: 1000, h: 1000 });
|
||||
// unknown ratio (image not loaded yet, old config): assume square
|
||||
for (const bad of [null, undefined, 0, -3, NaN, 'x']) {
|
||||
assert.deepEqual(fitInSquare(bad, 1000), { x: 0, y: 0, w: 1000, h: 1000 });
|
||||
}
|
||||
});
|
||||
|
||||
test('roomBounds + roomCenter for a polygon', () => {
|
||||
const r = spaceModels(cfg)[0].rooms[0];
|
||||
assert.deepEqual(roomBounds(r), { x: 100, y: 50, w: 400, h: 200 });
|
||||
assert.deepEqual(roomCenter(r), [300, 150]);
|
||||
assert.deepEqual(roomBounds(r), { x: 100, y: 100, w: 400, h: 400 });
|
||||
assert.deepEqual(roomCenter(r), [300, 300]);
|
||||
});
|
||||
|
||||
test('markerPos: saved layout → default grid → space centre', () => {
|
||||
@@ -37,7 +50,7 @@ test('markerPos: saved layout → default grid → space centre', () => {
|
||||
// saved layout (normalized) → render units
|
||||
assert.deepEqual(
|
||||
markerPos(dev, { d1: { s: 'f1', x: 0.2, y: 0.3 } }, cfg, {}, model),
|
||||
{ x: 200, y: 150 }, // 0.2*1000, 0.3*(1000/2)
|
||||
{ x: 200, y: 300 }, // 0.2*1000, 0.3*1000
|
||||
);
|
||||
// default grid position (inside the room)
|
||||
const defPos = defaultPositions([dev], model, 2.5);
|
||||
@@ -46,14 +59,14 @@ test('markerPos: saved layout → default grid → space centre', () => {
|
||||
const b = roomBounds(model.rooms[0]);
|
||||
assert.ok(defPos.d1.x >= b.x && defPos.d1.x <= b.x + b.w && defPos.d1.y >= b.y && defPos.d1.y <= b.y + b.h);
|
||||
// no layout, no defPos → space centre
|
||||
assert.deepEqual(markerPos(dev, {}, cfg, {}, model), { x: 500, y: 250 });
|
||||
assert.deepEqual(markerPos(dev, {}, cfg, {}, model), { x: 500, y: 500 });
|
||||
});
|
||||
|
||||
test('labelPos: saved rl_<id> → render units; else room centre', () => {
|
||||
const model = spaceModels(cfg)[0];
|
||||
const r = model.rooms[0];
|
||||
assert.deepEqual(labelPos(r, 'f1', { rl_r1: { s: 'f1', x: 0.3, y: 0.4 } }, cfg), { x: 300, y: 200 });
|
||||
assert.deepEqual(labelPos(r, 'f1', {}, cfg), { x: 300, y: 150 }); // room centre
|
||||
assert.deepEqual(labelPos(r, 'f1', { rl_r1: { s: 'f1', x: 0.3, y: 0.4 } }, cfg), { x: 300, y: 400 });
|
||||
assert.deepEqual(labelPos(r, 'f1', {}, cfg), { x: 300, y: 300 }); // room centre
|
||||
});
|
||||
|
||||
test('defaultPositions: several devices in one room are spread (declumped, distinct)', () => {
|
||||
@@ -70,3 +83,43 @@ test('defaultPositions: several devices in one room are spread (declumped, disti
|
||||
});
|
||||
|
||||
test('NORM_W is 1000', () => assert.equal(NORM_W, 1000));
|
||||
|
||||
test('contentBounds: fits what is drawn, with a 5% margin', () => {
|
||||
const one = spaceModels({ spaces: [{
|
||||
id: 's', view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'r', poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }],
|
||||
}], markers: [] })[0];
|
||||
// 200x200 render units in the middle of a 1000x1000 canvas, +5% of 200 each side
|
||||
assert.deepEqual(contentBounds(one), { x: 390, y: 390, w: 220, h: 220 });
|
||||
|
||||
// rectangles count too, and the margin follows the LARGER side
|
||||
const rect = spaceModels({ spaces: [{
|
||||
id: 's', view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'r', x: 0.1, y: 0.4, w: 0.6, h: 0.1 }],
|
||||
}], markers: [] })[0];
|
||||
const b = contentBounds(rect);
|
||||
assert.equal(Math.round(b.w), 660); // 600 + 2 * 5% of 600
|
||||
assert.equal(Math.round(b.h), 160); // 100 + the same absolute margin
|
||||
assert.equal(Math.round(b.x), 70);
|
||||
|
||||
// nothing drawn → the caller keeps the whole canvas
|
||||
const empty = spaceModels({ spaces: [{ id: 's', view_box: [0, 0, 1, 1], rooms: [] }], markers: [] })[0];
|
||||
assert.equal(contentBounds(empty), null);
|
||||
});
|
||||
|
||||
test('contentBounds: devices outside every room stretch the frame', () => {
|
||||
const one = spaceModels({ spaces: [{
|
||||
id: 's', view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'r', poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }],
|
||||
}], markers: [] })[0];
|
||||
// a gate sensor far to the right of the room
|
||||
const b = contentBounds(one, 0.05, [[900, 500]]);
|
||||
// span 400..900 wide, 400..600 tall; margin 5% of the larger side (500)
|
||||
assert.deepEqual(b, { x: 375, y: 375, w: 550, h: 250 });
|
||||
// devices alone are content enough — an empty yard with two cameras
|
||||
const empty = spaceModels({ spaces: [{ id: 's', view_box: [0, 0, 1, 1], rooms: [] }], markers: [] })[0];
|
||||
const only = contentBounds(empty, 0.05, [[100, 100], [300, 200]]);
|
||||
assert.equal(Math.round(only.w), 220);
|
||||
// and junk coordinates are ignored, not spread across the canvas
|
||||
assert.deepEqual(contentBounds(one, 0.05, [[NaN, 5]]), contentBounds(one));
|
||||
});
|
||||
|
||||
@@ -34,3 +34,73 @@ async def test_unload(hass: HomeAssistant) -> None:
|
||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert entry.state.value == "not_loaded"
|
||||
|
||||
|
||||
async def test_square_migration_finishes_after_a_crash_between_the_writes(
|
||||
hass: HomeAssistant, hass_storage, monkeypatch
|
||||
) -> None:
|
||||
"""HP-1490-01, end to end on the real stores.
|
||||
|
||||
The layout write is made to fail once, AFTER the config write succeeded —
|
||||
the exact boundary that used to strand the layout in the old coordinates
|
||||
forever, because the config write had already deleted the `aspect` fields
|
||||
the layout half needed. The durable intent must finish the job on the next
|
||||
setup.
|
||||
"""
|
||||
from custom_components.houseplan.store import HouseplanStore
|
||||
|
||||
hass_storage["houseplan.config"] = {
|
||||
"version": 1, "data": {
|
||||
"config": {"spaces": [{"id": "f1", "aspect": 2.0, "rooms": []}],
|
||||
"markers": [], "settings": {}},
|
||||
"rev": 3,
|
||||
},
|
||||
}
|
||||
hass_storage["houseplan.layout"] = {
|
||||
"version": 1, "data": {"layout": {"m": {"s": "f1", "x": 0.1, "y": 0.1}}, "rev": 7},
|
||||
}
|
||||
|
||||
real_save = HouseplanStore.async_save
|
||||
state = {"layout_saves": 0}
|
||||
|
||||
async def failing_save(self, data):
|
||||
if self.key == "houseplan.layout" and "geom_pending" not in data:
|
||||
state["layout_saves"] += 1
|
||||
if state["layout_saves"] == 1:
|
||||
raise OSError("disk full at the worst possible moment")
|
||||
await real_save(self, data)
|
||||
|
||||
monkeypatch.setattr(HouseplanStore, "async_save", failing_save)
|
||||
|
||||
entry = MockConfigEntry(domain=DOMAIN, title="House Plan", data={}, options={})
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# the crash boundary: config migrated, layout not, intent saved
|
||||
cfg = hass_storage["houseplan.config"]["data"]["config"]
|
||||
assert "aspect" not in cfg["spaces"][0], "the config half committed"
|
||||
lay = hass_storage["houseplan.layout"]["data"]
|
||||
assert lay["layout"]["m"]["y"] == 0.1, "the layout half did NOT commit"
|
||||
assert lay.get("geom_pending") == {"f1": 2.0}, "but the intent is durable"
|
||||
|
||||
# next start: the store write works again
|
||||
monkeypatch.setattr(HouseplanStore, "async_save", real_save)
|
||||
if entry.state.value == "loaded":
|
||||
await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
lay = hass_storage["houseplan.layout"]["data"]
|
||||
assert lay["layout"]["m"] == {"s": "f1", "x": 0.1, "y": 0.3}, (
|
||||
"the saved intent finished the layout half"
|
||||
)
|
||||
assert "geom_pending" not in lay, "and left with the layout write"
|
||||
cfg = hass_storage["houseplan.config"]["data"]["config"]
|
||||
assert cfg["spaces"][0]["view_box"] == [0.0, 0.0, 1.0, 1.0]
|
||||
|
||||
# a third start changes nothing — both triggers are gone
|
||||
before = repr(hass_storage["houseplan.layout"]) + repr(hass_storage["houseplan.config"])
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert repr(hass_storage["houseplan.layout"]) + repr(hass_storage["houseplan.config"]) == before
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -176,10 +176,12 @@ async def test_files_migrate_copies_and_reports_mapping(
|
||||
assert src_kept, "migrate must COPY, not move (review CR-2)"
|
||||
assert other == b"OTHER" and copied == b"SOURCE"
|
||||
|
||||
# cleanup runs only after the config is safely committed
|
||||
# cleanup runs only after the config is safely committed, and since v1.46.5
|
||||
# reports how many files it removed rather than a bare boolean — it now also
|
||||
# keeps anything the stored configuration still references
|
||||
await client.send_json_auto_id({"type": "houseplan/files/cleanup", "marker_id": "old1"})
|
||||
resp2 = await client.receive_json()
|
||||
assert resp2["success"] and resp2["result"]["removed"] is True
|
||||
assert resp2["success"] and resp2["result"]["removed"] >= 1 and resp2["result"]["kept"] == 0
|
||||
assert not await hass.async_add_executor_job(lambda: os.path.isdir(src))
|
||||
|
||||
|
||||
@@ -340,34 +342,41 @@ async def test_commit_does_not_collect_another_client_s_uncommitted_upload(
|
||||
assert not (plans / pa).exists()
|
||||
|
||||
|
||||
async def test_abandoned_uploads_are_collected_once_old(
|
||||
async def test_a_rejected_upload_is_kept_not_aged_out(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""A rejected upload must not accumulate forever — but only age may free it."""
|
||||
"""v1.46.6: age is never a reason to delete a plan file.
|
||||
|
||||
It used to be, for "a file of a space that has a plan and never was one" —
|
||||
an upload whose save had failed. That raced the retry: the sweep removed the
|
||||
file while the next save was committing a reference to it. Keeping it costs
|
||||
a few megabytes nobody can lose.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from custom_components.houseplan.const import PLANS_DIR, PLAN_ORPHAN_TTL_S
|
||||
from custom_components.houseplan.const import PLANS_DIR, SCHEDULED_GRACE_S
|
||||
from custom_components.houseplan.store import get_data
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
plans = Path(hass.config.path(PLANS_DIR))
|
||||
plans.mkdir(parents=True, exist_ok=True)
|
||||
for stale in plans.glob("r3.*"):
|
||||
stale.unlink()
|
||||
plans = hass.config.path(PLANS_DIR)
|
||||
|
||||
url0, p0 = await _upload(client, "r3", b"zero")
|
||||
rev = (await _save(client, await _cfg([{"id": "r3", "plan_url": url0}]), 0))["result"]["rev"]
|
||||
|
||||
_url, orphan = await _upload(client, "r3", b"abandoned")
|
||||
old = time.time() - PLAN_ORPHAN_TTL_S - 60
|
||||
os.utime(plans / orphan, (old, old))
|
||||
_url, orphan = await _upload(client, "r3", b"never saved")
|
||||
old = time.time() - SCHEDULED_GRACE_S * 12
|
||||
os.utime(os.path.join(plans, orphan), (old, old))
|
||||
|
||||
ok = await _save(client, await _cfg([{"id": "r3", "plan_url": url0}]), rev)
|
||||
assert ok["success"]
|
||||
assert not (plans / orphan).exists(), "an aged, unreferenced upload is collected"
|
||||
assert (plans / p0).is_file(), "the referenced plan is never touched"
|
||||
# a commit, and the scheduled pass, and any amount of age: it stays
|
||||
assert (await _save(client, await _cfg([{"id": "r3", "plan_url": url0}]), rev))["success"]
|
||||
data = get_data(hass)
|
||||
await data.sweep()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, orphan))
|
||||
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, p0))
|
||||
|
||||
|
||||
async def test_collection_ignores_files_that_are_not_plans(
|
||||
@@ -396,6 +405,31 @@ async def test_collection_ignores_files_that_are_not_plans(
|
||||
assert (plans / "readme").is_file()
|
||||
|
||||
|
||||
async def test_a_marker_showing_its_value_can_be_saved(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""issue #3: display='value' was rejected, and one bad marker fails the lot.
|
||||
|
||||
A user could not save the configuration at all after setting any sensor to
|
||||
"value instead of an icon" — the editor offered the option, the schema had
|
||||
never heard of it.
|
||||
"""
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
cfg = await _cfg([{"id": "f1", "plan_url": None}])
|
||||
cfg["markers"] = [
|
||||
{"id": "sensor.t", "binding": "entity:sensor.t", "display": "value"},
|
||||
{"id": "sensor.h", "binding": "entity:sensor.h", "display": "badge"},
|
||||
]
|
||||
ok = await _save(client, cfg, 0)
|
||||
assert ok["success"], ok.get("error")
|
||||
|
||||
await client.send_json_auto_id({"type": "houseplan/config/get"})
|
||||
got = await client.receive_json()
|
||||
assert [m["display"] for m in got["result"]["config"]["markers"]] == ["value", "badge"]
|
||||
|
||||
|
||||
async def test_a_failing_collector_does_not_undo_an_accepted_save(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch
|
||||
) -> None:
|
||||
@@ -479,3 +513,773 @@ 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()
|
||||
# the bus does not promise ordering between separately fired events
|
||||
assert sorted(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)
|
||||
|
||||
# A reference can only go bad AFTER it is stored — config/set refuses a new
|
||||
# one that is already broken (HP-1470-02). So: attach a real plan, then let
|
||||
# the file disappear the way it does in life, from outside Home Assistant.
|
||||
import os
|
||||
|
||||
from custom_components.houseplan.const import PLANS_DIR
|
||||
|
||||
url, name = await _upload(client, "r7", b"PLAN")
|
||||
rev = (await _save(client, await _cfg([{"id": "r7", "plan_url": url}]), 0))["result"]["rev"]
|
||||
await hass.async_block_till_done()
|
||||
assert registry.async_get_issue(HP_DOMAIN, "broken_plan_r7") is None, "the file is there"
|
||||
|
||||
await hass.async_add_executor_job(
|
||||
os.remove, os.path.join(hass.config.path(PLANS_DIR), name)
|
||||
)
|
||||
rev = (await _save(client, await _cfg([{"id": "r7", "plan_url": url}]), rev))["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 _seed_aged(hass, names) -> None:
|
||||
"""Create the given files and backdate them past the orphan TTL."""
|
||||
import os
|
||||
import time
|
||||
|
||||
from custom_components.houseplan.const import SCHEDULED_GRACE_S
|
||||
|
||||
old = time.time() - SCHEDULED_GRACE_S - 60 # past every grace
|
||||
|
||||
def _do() -> None:
|
||||
for path in names:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(b"x")
|
||||
os.utime(path, (old, old))
|
||||
|
||||
await hass.async_add_executor_job(_do)
|
||||
|
||||
|
||||
def _paths(hass):
|
||||
import os
|
||||
|
||||
from custom_components.houseplan.const import FILES_DIR, PLANS_DIR
|
||||
|
||||
files = hass.config.path(FILES_DIR)
|
||||
plans = hass.config.path(PLANS_DIR)
|
||||
return files, plans, {
|
||||
"kept_file": os.path.join(files, "m5", "kept.pdf"),
|
||||
"kept_plan": os.path.join(plans, "s5.tok.png"),
|
||||
"orphan_file": os.path.join(files, "up_cancelled", "manual.pdf"),
|
||||
# a plan file is never collected by age any more; keep one around and
|
||||
# assert exactly that
|
||||
"kept_reject": os.path.join(plans, "s5.reject.png"),
|
||||
}
|
||||
|
||||
|
||||
async def _referenced_config() -> dict:
|
||||
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"}]}
|
||||
]
|
||||
return cfg
|
||||
|
||||
|
||||
async def _assert_swept(hass, p) -> None:
|
||||
import os
|
||||
|
||||
assert await hass.async_add_executor_job(os.path.isfile, p["kept_file"]), "referenced file kept"
|
||||
assert await hass.async_add_executor_job(os.path.isfile, p["kept_plan"]), "referenced plan kept"
|
||||
assert not await hass.async_add_executor_job(os.path.isfile, p["orphan_file"]), (
|
||||
"a staging folder from a dialog nobody saved is the one thing age collects"
|
||||
)
|
||||
assert await hass.async_add_executor_job(os.path.isfile, p["kept_reject"]), (
|
||||
"a plan file is never removed for being old"
|
||||
)
|
||||
|
||||
|
||||
async def test_startup_sweep_collects_what_no_commit_will(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""HP-1461-01 / HP-1462-01: collection must not depend on a future save.
|
||||
|
||||
The files are seeded AFTER the configuration is stored. The previous version
|
||||
of this test seeded them before, and `config/set` collects too — so it
|
||||
passed without the startup pass doing anything, hiding HP-1462-01: during
|
||||
setup the entry is not "loaded" yet, so looking its runtime data up by
|
||||
domain returned None and the pass degraded to removing streaming
|
||||
temporaries only.
|
||||
"""
|
||||
import os
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
files, _plans, p = _paths(hass)
|
||||
# the plan it names has to exist: config/set refuses a NEW broken
|
||||
# reference (HP-1470-02). The orphans still arrive after the save.
|
||||
await _seed_aged(hass, [p["kept_plan"]])
|
||||
assert (await _save(client, await _referenced_config(), 0))["success"]
|
||||
|
||||
await _seed_aged(hass, list(p.values()))
|
||||
|
||||
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
assert await hass.config_entries.async_reload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await _assert_swept(hass, p)
|
||||
assert not await hass.async_add_executor_job(
|
||||
os.path.isdir, os.path.join(files, "up_cancelled")
|
||||
), "the emptied staging folder goes with its last file"
|
||||
|
||||
|
||||
async def test_periodic_sweep_collects_too(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""The scheduled pass, invoked directly rather than by faking a 24 h jump.
|
||||
|
||||
Firing a time change proves the timer fires; awaiting the callback proves it
|
||||
does the work. This asserts the second, which is the part that regressed.
|
||||
"""
|
||||
from custom_components.houseplan.store import get_data
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
_files, _plans, p = _paths(hass)
|
||||
# the plan it names has to exist: config/set refuses a NEW broken
|
||||
# reference (HP-1470-02). The orphans still arrive after the save.
|
||||
await _seed_aged(hass, [p["kept_plan"]])
|
||||
assert (await _save(client, await _referenced_config(), 0))["success"]
|
||||
|
||||
await _seed_aged(hass, list(p.values()))
|
||||
|
||||
data = get_data(hass)
|
||||
assert data is not None and data.sweep is not None, "setup must publish the sweep"
|
||||
await data.sweep()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await _assert_swept(hass, p)
|
||||
|
||||
|
||||
async def test_sweep_and_a_config_write_do_not_race(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""Both take the same write lock, so an accepted config cannot lose a file.
|
||||
|
||||
Without it the sweep could decide a file is unreferenced, a commit could
|
||||
start referencing it, and the file would go — leaving the accepted revision
|
||||
pointing at nothing.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
_files, plans, p = _paths(hass)
|
||||
# the plan it names has to exist: config/set refuses a NEW broken
|
||||
# reference (HP-1470-02). The orphans still arrive after the save.
|
||||
await _seed_aged(hass, [p["kept_plan"]])
|
||||
rev = (await _save(client, await _referenced_config(), 0))["result"]["rev"]
|
||||
|
||||
# an aged, currently unreferenced plan that the commit below adopts
|
||||
newcomer = os.path.join(plans, "s5.newcomer.png")
|
||||
await _seed_aged(hass, [p["kept_file"], p["kept_plan"], newcomer])
|
||||
|
||||
cfg2 = await _referenced_config()
|
||||
cfg2["spaces"][0]["plan_url"] = "/api/houseplan/content/plans/_/s5.newcomer.png"
|
||||
|
||||
# Drive the sweep directly rather than through a reload: an entry reload has
|
||||
# an unload window in which any WS call legitimately answers `not_ready`, so
|
||||
# a save racing THAT proves nothing about the lock and fails at random.
|
||||
from custom_components.houseplan.store import get_data
|
||||
|
||||
data = get_data(hass)
|
||||
assert data is not None and data.sweep is not None
|
||||
_swept, saved = await asyncio.gather(data.sweep(), _save(client, cfg2, rev))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# assert the CONCRETE outcome: a save that came back `not_ready` would leave
|
||||
# the old config pointing at the old file and satisfy a vaguer check
|
||||
assert saved["success"], saved.get("error")
|
||||
await client.send_json_auto_id({"type": "houseplan/config/get"})
|
||||
stored = (await client.receive_json())["result"]["config"]
|
||||
assert stored["spaces"][0]["plan_url"].endswith("s5.newcomer.png")
|
||||
assert await hass.async_add_executor_job(
|
||||
os.path.isfile, os.path.join(plans, "s5.newcomer.png")
|
||||
), "the file the accepted config points at must exist"
|
||||
|
||||
|
||||
async def test_files_cleanup_keeps_referenced_files(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""v1.46.5: the client may say what it no longer needs, never what may go.
|
||||
|
||||
`files/cleanup` used to rmtree the folder it was handed. A partial migration
|
||||
leaves some urls pointing into that folder — the copy deliberately does not
|
||||
rewrite the ones it could not confirm — so those were live links to files
|
||||
being deleted. A wrong id from any client had the same effect on a device's
|
||||
manuals.
|
||||
"""
|
||||
import os
|
||||
|
||||
from custom_components.houseplan.const import FILES_DIR
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
folder = os.path.join(hass.config.path(FILES_DIR), "m7")
|
||||
|
||||
def _seed() -> None:
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
for n in ("kept.pdf", "orphan.pdf"):
|
||||
with open(os.path.join(folder, n), "wb") as fh:
|
||||
fh.write(b"x")
|
||||
|
||||
await hass.async_add_executor_job(_seed)
|
||||
|
||||
cfg = await _cfg([{"id": "s7", "plan_url": None}])
|
||||
cfg["markers"] = [
|
||||
{"id": "other", "binding": "virtual",
|
||||
"pdfs": [{"name": "k", "url": "/api/houseplan/content/files/m7/kept.pdf"}]}
|
||||
]
|
||||
assert (await _save(client, cfg, 0))["success"]
|
||||
|
||||
await client.send_json_auto_id({"type": "houseplan/files/cleanup", "marker_id": "m7"})
|
||||
resp = await client.receive_json()
|
||||
assert resp["success"] and resp["result"] == {"ok": True, "removed": 1, "kept": 1}
|
||||
|
||||
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(folder, "kept.pdf")), (
|
||||
"a file the configuration still references survives a cleanup of its folder"
|
||||
)
|
||||
assert not await hass.async_add_executor_job(os.path.isfile, os.path.join(folder, "orphan.pdf"))
|
||||
|
||||
|
||||
async def test_detaching_a_plan_keeps_the_file(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""HP-1465-01, through the real save — where the earlier tests never looked.
|
||||
|
||||
Every check for this lived in the pure collector with old and new config
|
||||
equal, i.e. the scheduled pass. The transition that matters is a save, and
|
||||
there the file was deleted the moment the reference was cleared.
|
||||
"""
|
||||
import os
|
||||
|
||||
from custom_components.houseplan.const import PLANS_DIR
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
plans = hass.config.path(PLANS_DIR)
|
||||
|
||||
url, name = await _upload(client, "d1", b"PLAN", ext="png")
|
||||
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": url}]), 0))["result"]["rev"]
|
||||
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name))
|
||||
|
||||
# detach: the space stays, its plan does not
|
||||
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": None}]), rev))["result"]["rev"]
|
||||
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name)), (
|
||||
"the editor says the image stays on disk — it has to actually stay"
|
||||
)
|
||||
|
||||
# a restart does not change its mind either
|
||||
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
assert await hass.config_entries.async_reload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name))
|
||||
|
||||
# re-attach, then replace: THAT removes the one it replaced
|
||||
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": url}]), rev))["result"]["rev"]
|
||||
url2, name2 = await _upload(client, "d1", b"NEWPLAN", ext="png")
|
||||
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": url2}]), rev))["result"]["rev"]
|
||||
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name2))
|
||||
assert not await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name))
|
||||
|
||||
# and deleting the space keeps its plan
|
||||
await _save(client, await _cfg([]), rev)
|
||||
await hass.async_block_till_done()
|
||||
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name2))
|
||||
|
||||
|
||||
async def test_stored_plans_can_be_listed_and_deleted_on_request(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""HP-1466-01/-02: "we never delete" needs the files to be findable.
|
||||
|
||||
A detached plan stays on disk. Without a way to see it, that is neither a
|
||||
recovery path nor a disk policy — it is just accumulation. Listing gives
|
||||
both: the user attaches it again, or deletes it on purpose, which is the
|
||||
only way a plan file is ever removed.
|
||||
"""
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
used_url, used = await _upload(client, "p1", b"USED", ext="png")
|
||||
_free_url, free = await _upload(client, "p2", b"FREE", ext="png")
|
||||
rev = (await _save(client, await _cfg([{"id": "p1", "plan_url": used_url}]), 0))["result"]["rev"]
|
||||
|
||||
await client.send_json_auto_id({"type": "houseplan/plans/list"})
|
||||
# the HA test config dir is shared across the module: look at ours, not all
|
||||
plans = {p["name"]: p for p in (await client.receive_json())["result"]["plans"]}
|
||||
assert {used, free} <= set(plans)
|
||||
assert plans[used]["used_by"] and not plans[free]["used_by"]
|
||||
assert plans[used]["size"] == 4 and plans[free]["url"].endswith(free)
|
||||
|
||||
# a plan a space still uses is refused — the config decides, not the client
|
||||
await client.send_json_auto_id({"type": "houseplan/plans/delete", "name": used})
|
||||
resp = await client.receive_json()
|
||||
assert not resp["success"] and resp["error"]["code"] == "in_use"
|
||||
|
||||
# an unused one goes on request
|
||||
await client.send_json_auto_id({"type": "houseplan/plans/delete", "name": free})
|
||||
assert (await client.receive_json())["result"]["removed"] is True
|
||||
|
||||
# detach the other, and now it is deletable — and listed as free until then
|
||||
await _save(client, await _cfg([{"id": "p1", "plan_url": None}]), rev)
|
||||
await client.send_json_auto_id({"type": "houseplan/plans/list"})
|
||||
plans = {p["name"]: p for p in (await client.receive_json())["result"]["plans"]}
|
||||
assert used in plans, "the detached plan is still there, ready to re-attach"
|
||||
assert not plans[used]["used_by"]
|
||||
assert free not in plans, "and the one we deleted is gone"
|
||||
|
||||
# nothing outside the plans folder can be reached through the name
|
||||
await client.send_json_auto_id(
|
||||
{"type": "houseplan/plans/delete", "name": "../../configuration.yaml"}
|
||||
)
|
||||
bad = await client.receive_json()
|
||||
assert not bad["success"] and bad["error"]["code"] == "invalid_name"
|
||||
|
||||
|
||||
async def test_config_set_refuses_a_plan_that_no_longer_exists(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""HP-1470-02: a stored internal plan url must name a file that exists.
|
||||
|
||||
The card can pick a plan and delete it from the same dialog, and two clients
|
||||
can do the same in either order. The lock serialises them; it says nothing
|
||||
about whether the file survived, so the check has to be here.
|
||||
"""
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
url, name = await _upload(client, "x1", b"PLAN", ext="png")
|
||||
await client.send_json_auto_id({"type": "houseplan/plans/delete", "name": name})
|
||||
assert (await client.receive_json())["result"]["removed"] is True
|
||||
|
||||
resp = await _save(client, await _cfg([{"id": "x1", "plan_url": url}]), 0)
|
||||
assert not resp["success"] and resp["error"]["code"] == "missing_plan"
|
||||
|
||||
# an external or legacy url is the user's business, not ours to verify
|
||||
assert (await _save(client, await _cfg([{"id": "x1", "plan_url": "/local/mine.png"}]), 0))["success"]
|
||||
|
||||
|
||||
async def test_uploads_are_bounded_by_a_store_quota(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch
|
||||
) -> None:
|
||||
"""HP-1470-01: nothing is deleted for being old, so growth stops at the door."""
|
||||
from custom_components.houseplan import websocket_api as wsapi
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from custom_components.houseplan.const import PLANS_DIR
|
||||
from custom_components.houseplan.plans import dir_usage
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
# every test in this module shares one config directory, so the plans folder
|
||||
# is not empty here — budget two more from whatever is already stored
|
||||
stored, _b = await hass.async_add_executor_job(
|
||||
lambda: dir_usage(Path(hass.config.path(PLANS_DIR)))[::-1]
|
||||
)
|
||||
monkeypatch.setattr(wsapi, "MAX_PLANS_FILES", stored + 2)
|
||||
|
||||
await _upload(client, "q1", b"one")
|
||||
await _upload(client, "q1", b"two")
|
||||
|
||||
import base64
|
||||
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/plan/set", "space_id": "q1", "ext": "png",
|
||||
"data": base64.b64encode(b"three").decode(),
|
||||
})
|
||||
resp = await client.receive_json()
|
||||
assert not resp["success"] and resp["error"]["code"] == "too_many_files"
|
||||
|
||||
|
||||
async def test_parallel_uploads_cannot_slip_past_the_quota_together(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch
|
||||
) -> None:
|
||||
"""HP-1490-02: N uploads used to measure the store before any of them
|
||||
wrote, so all N passed a quota only one of them fits under. The
|
||||
check→write pair is one job under upload_lock now, so whatever the
|
||||
interleaving, at most ONE of two competing uploads can take the last slot.
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
|
||||
from custom_components.houseplan import websocket_api as wsapi
|
||||
from custom_components.houseplan.const import PLANS_DIR
|
||||
from custom_components.houseplan.plans import dir_usage
|
||||
from pathlib import Path
|
||||
|
||||
await _setup(hass)
|
||||
c1 = await hass_ws_client(hass)
|
||||
c2 = await hass_ws_client(hass)
|
||||
_bytes, stored = await hass.async_add_executor_job(
|
||||
dir_usage, Path(hass.config.path(PLANS_DIR))
|
||||
)
|
||||
monkeypatch.setattr(wsapi, "MAX_PLANS_FILES", stored + 1) # room for ONE
|
||||
|
||||
payload = base64.b64encode(b"PLAN").decode()
|
||||
|
||||
async def upload(client, sid):
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/plan/set", "space_id": sid, "ext": "png", "data": payload,
|
||||
})
|
||||
return await client.receive_json()
|
||||
|
||||
r1, r2 = await asyncio.gather(upload(c1, "pa"), upload(c2, "pb"))
|
||||
oks = [r for r in (r1, r2) if r["success"]]
|
||||
errs = [r for r in (r1, r2) if not r["success"]]
|
||||
assert len(oks) == 1, "exactly one takes the last slot"
|
||||
assert errs and errs[0]["error"]["code"] == "too_many_files"
|
||||
_bytes2, after = await hass.async_add_executor_job(
|
||||
dir_usage, Path(hass.config.path(PLANS_DIR))
|
||||
)
|
||||
assert after == stored + 1, "the store holds what the quota promised, not more"
|
||||
|
||||
@@ -90,11 +90,19 @@ def test_room_schema_poly_or_rect():
|
||||
v.ROOM_SCHEMA({"id": "r4", "name": "D", "poly": [[0, 0], [1, 1]]})
|
||||
|
||||
|
||||
def test_space_schema_aspect_range():
|
||||
ok = {"id": "f1", "title": "1", "aspect": 1.4, "view_box": [0, 0, 1, 1], "rooms": []}
|
||||
v.SPACE_SCHEMA(ok)
|
||||
def test_space_schema_drops_the_old_aspect_and_bounds_the_image_ratio():
|
||||
"""v1.48.0: the canvas is square; only the IMAGE keeps proportions.
|
||||
|
||||
A stale tab may still send `aspect`. It is dropped rather than trusted —
|
||||
the coordinates it arrives with were normalised against a different box, so
|
||||
honouring the field would not make them right anyway.
|
||||
"""
|
||||
ok = {"id": "f1", "title": "1", "view_box": [0, 0, 1, 1], "rooms": []}
|
||||
assert "aspect" not in v.SPACE_SCHEMA({**ok, "aspect": 1.4})
|
||||
v.SPACE_SCHEMA({**ok, "plan_aspect": 1.4})
|
||||
v.SPACE_SCHEMA({**ok, "plan_aspect": None})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.SPACE_SCHEMA({**ok, "aspect": 0})
|
||||
v.SPACE_SCHEMA({**ok, "plan_aspect": 0})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.SPACE_SCHEMA({**ok, "view_box": [0, 0, 1]})
|
||||
|
||||
@@ -265,17 +273,6 @@ def test_collect_plans_keeps_a_fresh_unreferenced_upload(tmp_path):
|
||||
assert (d / "f1.inflight.png").is_file()
|
||||
|
||||
|
||||
def test_collect_plans_takes_an_aged_orphan(tmp_path):
|
||||
PLAN_ORPHAN_TTL_S = const.PLAN_ORPHAN_TTL_S
|
||||
collect_plans = plans.collect_plans
|
||||
|
||||
d = _plans(tmp_path, ["f1.keep.png"])
|
||||
_plans(tmp_path, ["f1.abandoned.png"], age=PLAN_ORPHAN_TTL_S + 60)
|
||||
removed = collect_plans(d, _cfg("/p/f1.keep.png"), _cfg("/p/f1.keep.png"))
|
||||
assert removed == 1
|
||||
assert (d / "f1.keep.png").is_file() and not (d / "f1.abandoned.png").exists()
|
||||
|
||||
|
||||
def test_collect_plans_never_touches_a_referenced_or_foreign_file(tmp_path):
|
||||
PLAN_ORPHAN_TTL_S = const.PLAN_ORPHAN_TTL_S
|
||||
collect_plans = plans.collect_plans
|
||||
@@ -305,3 +302,568 @@ def test_collect_plans_never_raises_when_the_directory_disappears(tmp_path, monk
|
||||
|
||||
monkeypatch.setattr(type(d), "iterdir", _boom, raising=False)
|
||||
assert collect_plans(d, _cfg("/p/a.png"), _cfg("/p/b.png")) == 0
|
||||
|
||||
|
||||
# ---------- the editor's options must be storable (issue #3) ----------
|
||||
|
||||
|
||||
def _ts_list(name):
|
||||
"""Read one `export const NAME = [...] as const;` list out of src/logic.ts.
|
||||
|
||||
Deliberately reads the TypeScript source rather than duplicating the values:
|
||||
a list that lives in two places drifts, which is exactly what happened here.
|
||||
"""
|
||||
import re
|
||||
|
||||
src = os.path.join(os.path.dirname(os.path.dirname(__file__)), "src", "logic.ts")
|
||||
with open(src, encoding="utf-8") as fh:
|
||||
text = fh.read()
|
||||
m = re.search(rf"export const {name} = \[(.*?)\] as const;", text, re.S)
|
||||
assert m, f"{name} not found in src/logic.ts"
|
||||
return re.findall(r"'([^']+)'", m.group(1))
|
||||
|
||||
|
||||
def _marker(**extra):
|
||||
return {"id": "m1", "binding": "entity:sensor.x", **extra}
|
||||
|
||||
|
||||
def test_every_display_mode_the_editor_offers_is_accepted():
|
||||
"""issue #3: 'value' was added to the card in v1.26.0 and never to the schema.
|
||||
|
||||
Saving a sensor set to "value instead of an icon" failed with
|
||||
"not a valid value for dictionary value @ data['config']['markers'][n]['display']",
|
||||
and because one bad marker rejects the whole config, the user could not save
|
||||
at all. Reported 2026-07-27.
|
||||
"""
|
||||
modes = _ts_list("DISPLAY_MODES")
|
||||
assert "value" in modes, "the regression this test exists for"
|
||||
for mode in modes:
|
||||
v.MARKER_SCHEMA(_marker(display=mode))
|
||||
v.MARKER_SCHEMA(_marker(display=None))
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.MARKER_SCHEMA(_marker(display="wat"))
|
||||
|
||||
|
||||
def test_every_tap_action_the_editor_offers_is_accepted():
|
||||
for action in _ts_list("TAP_ACTIONS"):
|
||||
v.MARKER_SCHEMA(_marker(tap_action=action))
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.MARKER_SCHEMA(_marker(tap_action="launch-missiles"))
|
||||
|
||||
|
||||
def _space(**settings):
|
||||
return {
|
||||
"id": "f1", "title": "F1", "aspect": 1.4, "view_box": [0, 0, 1, 1],
|
||||
"rooms": [], "settings": settings,
|
||||
}
|
||||
|
||||
|
||||
def test_every_fill_mode_the_editor_offers_is_accepted():
|
||||
for mode in _ts_list("SPACE_FILL_MODES"):
|
||||
v.SPACE_SCHEMA(_space(fill_mode=mode))
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.SPACE_SCHEMA(_space(fill_mode="rainbow"))
|
||||
|
||||
|
||||
def test_every_room_fill_mode_the_editor_offers_is_accepted():
|
||||
def room(mode):
|
||||
return {
|
||||
"id": "f1", "title": "F1", "aspect": 1.4, "view_box": [0, 0, 1, 1],
|
||||
"rooms": [{"id": "r1", "name": "R", "x": 0.1, "y": 0.1, "w": 0.2, "h": 0.2,
|
||||
"settings": {"fill_mode": mode}}],
|
||||
}
|
||||
|
||||
for mode in _ts_list("ROOM_FILL_MODES"):
|
||||
v.SPACE_SCHEMA(room(mode))
|
||||
v.SPACE_SCHEMA(room(None)) # inherit from the space
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.SPACE_SCHEMA(room("rainbow"))
|
||||
|
||||
|
||||
# ---------- attachments & inner limits (HP-1454-02, -05) ----------
|
||||
|
||||
|
||||
def test_reserve_filename_claims_the_name_atomically(tmp_path):
|
||||
"""HP-1460-01: choosing a name and taking it must be one operation.
|
||||
|
||||
The old helper asked `exists()` and returned a string; two uploads racing
|
||||
between the check and the write agreed on the same name and one overwrote
|
||||
the other, both reporting success.
|
||||
"""
|
||||
reserve = plans.reserve_filename
|
||||
d = tmp_path / "m1"
|
||||
|
||||
first = reserve(d, "manual.pdf")
|
||||
assert first == "manual.pdf"
|
||||
assert (d / first).is_file(), "the name is taken, not merely picked"
|
||||
second = reserve(d, "manual.pdf")
|
||||
assert second == "manual-2.pdf" and (d / second).is_file()
|
||||
assert reserve(d, "manual.pdf") == "manual-3.pdf"
|
||||
|
||||
assert reserve(d, "readme") == "readme"
|
||||
assert reserve(d, "readme") == "readme-2"
|
||||
assert reserve(d, "../../etc/passwd") == "passwd"
|
||||
|
||||
|
||||
def test_reserve_filename_result_survives_the_content_sanitizer(tmp_path):
|
||||
"""A name the view would rewrite is a file written and then never served."""
|
||||
reserve = plans.reserve_filename
|
||||
d = tmp_path / "m2"
|
||||
long_stem = "x" * 200 # far past the limit
|
||||
names = [reserve(d, f"{long_stem}.pdf") for _ in range(12)]
|
||||
assert len(set(names)) == 12, "each call takes its own name"
|
||||
for n in names:
|
||||
assert len(n) <= v.MAX_FILENAME
|
||||
assert v.sanitize_filename(n) == n, n
|
||||
assert n.endswith(".pdf")
|
||||
|
||||
# a name already exactly at the limit still leaves room for the tag
|
||||
exact = "y" * (v.MAX_FILENAME - 4) + ".pdf"
|
||||
assert len(exact) == v.MAX_FILENAME
|
||||
a = reserve(d, exact)
|
||||
b = reserve(d, exact)
|
||||
assert a != b and len(b) <= v.MAX_FILENAME and v.sanitize_filename(b) == b
|
||||
|
||||
|
||||
def test_reserve_filename_is_safe_under_concurrency(tmp_path):
|
||||
"""Twenty threads, one filename: twenty distinct files, nothing overwritten."""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
reserve = plans.reserve_filename
|
||||
d = tmp_path / "m3"
|
||||
d.mkdir(parents=True)
|
||||
with ThreadPoolExecutor(max_workers=20) as pool:
|
||||
names = list(pool.map(lambda _: reserve(d, "manual.pdf"), range(20)))
|
||||
assert len(set(names)) == 20
|
||||
assert sorted(p.name for p in d.iterdir()) == sorted(names)
|
||||
|
||||
|
||||
def test_sweep_upload_temps(tmp_path):
|
||||
"""HP-1460-02: a crashed transfer leaves a temp no other collector sees."""
|
||||
import os
|
||||
import time
|
||||
|
||||
files = tmp_path / "files"
|
||||
files.mkdir()
|
||||
fresh = files / f"{plans.TMP_PREFIX}fresh"
|
||||
old = files / f"{plans.TMP_PREFIX}old"
|
||||
keep = files / "notes.txt"
|
||||
for f in (fresh, old, keep):
|
||||
f.write_bytes(b"x")
|
||||
t = time.time() - const.PLAN_ORPHAN_TTL_S - 60
|
||||
os.utime(old, (t, t))
|
||||
|
||||
assert plans.sweep_upload_temps(files) == 1
|
||||
assert not old.exists(), "an aged temporary goes"
|
||||
assert fresh.is_file(), "a fresh one may belong to a request in flight"
|
||||
assert keep.is_file(), "nothing else is touched"
|
||||
assert plans.sweep_upload_temps(tmp_path / "nope") == 0
|
||||
|
||||
|
||||
def _acfg(*pairs):
|
||||
return {"markers": [{"id": f"m{i}", "pdfs": [{"url": f"/api/houseplan/content/files/{p}"}]}
|
||||
for i, p in enumerate(pairs)]}
|
||||
|
||||
|
||||
def test_attachment_refs_reads_marker_urls():
|
||||
attachment_refs = plans.attachment_refs
|
||||
assert attachment_refs(None) == set()
|
||||
assert attachment_refs(_acfg("m1/a.pdf", "m2/b.pdf")) == {"m1/a.pdf", "m2/b.pdf"}
|
||||
# legacy and foreign urls are not ours to collect against
|
||||
cfg = {"markers": [{"id": "m", "pdfs": [{"url": "/local/x.pdf"}, {"url": "/api/houseplan/content/files/deep/a/b.pdf"}]}]}
|
||||
assert plans.attachment_refs(cfg) == set()
|
||||
|
||||
|
||||
def test_collect_attachments_removes_the_empty_folder_and_never_raises(tmp_path):
|
||||
import os
|
||||
import time
|
||||
|
||||
files = tmp_path / "files"
|
||||
(files / "up_x").mkdir(parents=True)
|
||||
f = files / "up_x" / "orphan.pdf"
|
||||
f.write_bytes(b"x")
|
||||
old = time.time() - const.PLAN_ORPHAN_TTL_S - 60
|
||||
os.utime(f, (old, old))
|
||||
assert plans.collect_attachments(files, {}, {}) == 1
|
||||
assert not (files / "up_x").exists(), "the staging folder goes with its last file"
|
||||
assert plans.collect_attachments(tmp_path / "nope", {}, {}) == 0
|
||||
|
||||
|
||||
def test_inner_collection_limits():
|
||||
room = {"id": "r", "name": "R", "poly": [[0.1, 0.1]] * v.MAX_POLY_POINTS}
|
||||
v.ROOM_SCHEMA(room)
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.ROOM_SCHEMA({**room, "poly": [[0.1, 0.1]] * (v.MAX_POLY_POINTS + 1)})
|
||||
|
||||
rect = {"id": "r", "name": "R", "x": 0.1, "y": 0.1, "w": 0.2, "h": 0.2}
|
||||
v.ROOM_SCHEMA({**rect, "open_to": ["x"] * v.MAX_OPEN_TO})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.ROOM_SCHEMA({**rect, "open_to": ["x"] * (v.MAX_OPEN_TO + 1)})
|
||||
|
||||
m = {"id": "m", "binding": "virtual"}
|
||||
v.MARKER_SCHEMA({**m, "controls": ["light.x"] * v.MAX_CONTROLS})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.MARKER_SCHEMA({**m, "controls": ["light.x"] * (v.MAX_CONTROLS + 1)})
|
||||
|
||||
pdf = {"name": "n", "url": "/api/houseplan/content/files/m/a.pdf"}
|
||||
v.MARKER_SCHEMA({**m, "pdfs": [pdf] * v.MAX_PDFS})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.MARKER_SCHEMA({**m, "pdfs": [pdf] * (v.MAX_PDFS + 1)})
|
||||
|
||||
v.MARKER_SCHEMA({**m, "name": "n" * v.MAX_TEXT})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.MARKER_SCHEMA({**m, "name": "n" * (v.MAX_TEXT + 1)})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.MARKER_SCHEMA({**m, "link": "u" * (v.MAX_URL + 1)})
|
||||
|
||||
|
||||
def test_legacy_segments_are_dropped_by_the_server():
|
||||
"""A limit that depends on the client stripping the field is not a limit."""
|
||||
out = v.SPACE_SCHEMA({
|
||||
"id": "f1", "title": "F", "aspect": 1.4, "view_box": [0, 0, 1, 1], "rooms": [],
|
||||
"segments": [[1, 2, 3, 4]] * 100000,
|
||||
})
|
||||
assert "segments" not in out
|
||||
|
||||
|
||||
def _aged(path, seconds):
|
||||
import os
|
||||
import time
|
||||
|
||||
t = time.time() - seconds
|
||||
os.utime(path, (t, t))
|
||||
|
||||
|
||||
def _sp(sid, url):
|
||||
return {"id": sid, "plan_url": f"/api/houseplan/content/plans/_/{url}" if url else None}
|
||||
|
||||
|
||||
def test_plan_collection_matrix(tmp_path):
|
||||
"""Which config transition means "the user asked for this file to go"?
|
||||
|
||||
`old_refs - new_refs` cannot tell replace, detach and delete-space apart —
|
||||
they look identical. v1.46.4/v1.46.5 added guards for detach and only ever
|
||||
reached them on the scheduled pass, so the commit itself still deleted a
|
||||
detached plan the moment it was detached (HP-1465-01). One case is a
|
||||
deletion the user asked for; the rest are kept.
|
||||
"""
|
||||
collect = plans.collect_plans
|
||||
d = tmp_path / "plans"
|
||||
d.mkdir()
|
||||
|
||||
def seed(*names):
|
||||
for n in names:
|
||||
(d / n).write_bytes(b"x")
|
||||
_aged(d / n, const.SCHEDULED_GRACE_S * 2) # old enough for any rule
|
||||
|
||||
# 1. replace: the user picked a different image for the same space
|
||||
seed("f1.old.png", "f1.new.png")
|
||||
assert collect(d, {"spaces": [_sp("f1", "f1.old.png")]},
|
||||
{"spaces": [_sp("f1", "f1.new.png")]}) == 1
|
||||
assert not (d / "f1.old.png").exists() and (d / "f1.new.png").is_file()
|
||||
|
||||
# 2. detach: same space, switched to "draw"
|
||||
seed("f2.png")
|
||||
assert collect(d, {"spaces": [_sp("f2", "f2.png")]},
|
||||
{"spaces": [_sp("f2", None)]}) == 0
|
||||
assert (d / "f2.png").is_file(), "the editor says the file stays — it stays"
|
||||
|
||||
# 3. the space is deleted outright
|
||||
seed("f3.png")
|
||||
assert collect(d, {"spaces": [_sp("f3", "f3.png")]}, {"spaces": []}) == 0
|
||||
assert (d / "f3.png").is_file()
|
||||
|
||||
# 4. the scheduled pass, later, still keeps both
|
||||
cfg = {"spaces": [_sp("f2", None)]}
|
||||
assert collect(d, cfg, cfg) == 0
|
||||
assert (d / "f2.png").is_file() and (d / "f3.png").is_file()
|
||||
|
||||
# 5. an upload whose save was rejected is kept too, at any age. Ageing those
|
||||
# out raced the retry: the sweep deleted a file the save was committing a
|
||||
# reference to (caught by test_sweep_and_a_config_write_do_not_race).
|
||||
seed("f4.current.png", "f4.reject.png")
|
||||
live = {"spaces": [_sp("f4", "f4.current.png")]}
|
||||
assert collect(d, live, live) == 0
|
||||
assert (d / "f4.current.png").is_file() and (d / "f4.reject.png").is_file()
|
||||
|
||||
# 6. the same file still referenced by another space is never touched
|
||||
seed("shared.png")
|
||||
assert collect(d, {"spaces": [_sp("a", "shared.png"), _sp("b", "shared.png")]},
|
||||
{"spaces": [_sp("a", None), _sp("b", "shared.png")]}) == 0
|
||||
assert (d / "shared.png").is_file()
|
||||
|
||||
|
||||
def test_attachment_collection_matrix(tmp_path):
|
||||
"""Removing an attachment is a trash button; deleting the device is not."""
|
||||
collect = plans.collect_attachments
|
||||
|
||||
def case(name):
|
||||
d = tmp_path / name
|
||||
d.mkdir()
|
||||
return d
|
||||
|
||||
def seed(root, folder, fname, age=None):
|
||||
(root / folder).mkdir(parents=True, exist_ok=True)
|
||||
p = root / folder / fname
|
||||
p.write_bytes(b"x")
|
||||
if age:
|
||||
_aged(p, age)
|
||||
return p
|
||||
|
||||
def cfg(*markers):
|
||||
return {"markers": [
|
||||
{"id": mid, "pdfs": [{"url": f"/api/houseplan/content/files/{mid}/{n}"} for n in names]}
|
||||
for mid, names in markers
|
||||
]}
|
||||
|
||||
# 1. the user removed one attachment from a device that still exists
|
||||
d = case("dropped")
|
||||
seed(d, "m1", "dropped.pdf")
|
||||
seed(d, "m1", "kept.pdf")
|
||||
assert collect(d, cfg(("m1", ["dropped.pdf", "kept.pdf"])), cfg(("m1", ["kept.pdf"]))) == 1
|
||||
assert not (d / "m1" / "dropped.pdf").exists()
|
||||
assert (d / "m1" / "kept.pdf").is_file()
|
||||
|
||||
# 2. the device itself is gone: its manuals are not ours to throw away
|
||||
d = case("device_gone")
|
||||
seed(d, "m2", "manual.pdf", age=const.SCHEDULED_GRACE_S * 2)
|
||||
assert collect(d, cfg(("m2", ["manual.pdf"])), cfg()) == 0
|
||||
assert (d / "m2" / "manual.pdf").is_file()
|
||||
# and the scheduled pass, later, agrees
|
||||
assert collect(d, cfg(), cfg()) == 0
|
||||
assert (d / "m2" / "manual.pdf").is_file()
|
||||
|
||||
# 3. a dialog that was never saved, in its own staging folder
|
||||
d = case("staging")
|
||||
seed(d, "up_x", "manual.pdf", age=const.PLAN_ORPHAN_TTL_S + 60)
|
||||
assert collect(d, cfg(), cfg()) == 1
|
||||
assert not (d / "up_x").exists()
|
||||
|
||||
# 4. an upload into a live device's folder whose save was rejected: kept,
|
||||
# for the same reason as a plan's — a retry may be about to reference it
|
||||
d = case("reject")
|
||||
seed(d, "m3", "current.pdf")
|
||||
seed(d, "m3", "rejected.pdf", age=const.SCHEDULED_GRACE_S + 60)
|
||||
live = cfg(("m3", ["current.pdf"]))
|
||||
assert collect(d, live, live) == 0
|
||||
assert (d / "m3" / "current.pdf").is_file()
|
||||
assert (d / "m3" / "rejected.pdf").is_file()
|
||||
|
||||
|
||||
def test_only_a_staging_folder_ages_out(tmp_path):
|
||||
"""The one age rule left. Everything else waits for the user to say so."""
|
||||
import os
|
||||
import time
|
||||
|
||||
files = tmp_path / "files"
|
||||
(files / "m1").mkdir(parents=True)
|
||||
(files / "up_abandoned").mkdir(parents=True)
|
||||
ancient = time.time() - const.SCHEDULED_GRACE_S * 12
|
||||
hour_ago = time.time() - const.PLAN_ORPHAN_TTL_S - 60
|
||||
|
||||
for path, when in (
|
||||
((files / "m1" / "ancient.pdf"), ancient),
|
||||
((files / "up_abandoned" / "manual.pdf"), hour_ago),
|
||||
):
|
||||
path.write_bytes(b"x")
|
||||
os.utime(path, (when, when))
|
||||
|
||||
cfg = {"markers": [{"id": "m1", "pdfs": []}]}
|
||||
assert plans.collect_attachments(files, cfg, cfg) == 1
|
||||
assert (files / "m1" / "ancient.pdf").is_file(), "age alone is never a reason"
|
||||
assert not (files / "up_abandoned").exists(), "a cancelled dialog goes after an hour"
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------- square canvas migration (v1.48.0) ----------
|
||||
|
||||
|
||||
gm = _load_pure("geometry_migration")
|
||||
|
||||
|
||||
def _sq(space, layout=None):
|
||||
cfg = {"spaces": [space]}
|
||||
gm.migrate_config(cfg, layout if layout is not None else {})
|
||||
return cfg["spaces"][0]
|
||||
|
||||
|
||||
def test_the_viewport_becomes_the_whole_square():
|
||||
"""The grid is drawn over the view box, and the fit fits it.
|
||||
|
||||
Transforming the old rectangle instead would leave the new margins outside
|
||||
the canvas — no grid there and nothing to draw on — which is the room the
|
||||
square canvas was meant to add.
|
||||
"""
|
||||
sp = _sq({"id": "f1", "aspect": 0.5, "view_box": [0.1, 0.2, 0.5, 0.5], "rooms": []})
|
||||
assert sp["view_box"] == [0.0, 0.0, 1.0, 1.0]
|
||||
|
||||
|
||||
def test_a_wide_plan_gains_margins_above_and_below():
|
||||
sp = _sq({
|
||||
"id": "f1", "aspect": 2.0, "cell_cm": 5, "view_box": [0, 0, 1, 1],
|
||||
"rooms": [{"id": "r", "x": 0.0, "y": 0.0, "w": 1.0, "h": 1.0}],
|
||||
})
|
||||
r = sp["rooms"][0]
|
||||
assert (r["x"], r["w"]) == (0.0, 1.0), "the width is untouched"
|
||||
assert r["y"] == 0.25 and r["h"] == 0.5, "half the height, centred"
|
||||
assert sp["cell_cm"] == 5, "the grid is tied to the width, which did not change"
|
||||
assert "aspect" not in sp
|
||||
|
||||
|
||||
def test_a_tall_plan_gains_margins_on_the_sides_and_rescales_the_grid():
|
||||
sp = _sq({
|
||||
"id": "f1", "aspect": 0.5, "cell_cm": 5, "view_box": [0, 0, 1, 1],
|
||||
"rooms": [{"id": "r", "poly": [[0, 0], [1, 0], [1, 1], [0, 1]]}],
|
||||
})
|
||||
poly = sp["rooms"][0]["poly"]
|
||||
assert [round(c, 6) for c in poly[0]] == [0.25, 0.0]
|
||||
assert [round(c, 6) for c in poly[2]] == [0.75, 1.0], "half the width, centred"
|
||||
assert sp["cell_cm"] == 10, "the canvas got twice as wide, so a cell is twice the cm"
|
||||
|
||||
|
||||
def test_a_square_plan_is_left_alone():
|
||||
before = {
|
||||
"id": "f1", "aspect": 1.0, "cell_cm": 5, "view_box": [0, 0, 1, 1],
|
||||
"rooms": [{"id": "r", "x": 0.1, "y": 0.2, "w": 0.3, "h": 0.4}],
|
||||
}
|
||||
sp = _sq({**before, "rooms": [dict(before["rooms"][0])]})
|
||||
assert sp["rooms"][0] == before["rooms"][0]
|
||||
assert sp["cell_cm"] == 5 and sp["view_box"] == [0.0, 0.0, 1.0, 1.0]
|
||||
|
||||
|
||||
def test_migration_preserves_real_lengths_and_shapes():
|
||||
"""A wall keeps its length in centimetres, and a square stays square."""
|
||||
GRID = 1000.0
|
||||
|
||||
def wall_cm(space, p, q):
|
||||
# render units per normalised unit is the canvas width, always 1000
|
||||
dx = (q[0] - p[0]) * GRID
|
||||
dy = (q[1] - p[1]) * GRID
|
||||
pitch = GRID / 40 # whatever the grid is, the same constant both sides
|
||||
return ((dx * dx + dy * dy) ** 0.5 / pitch) * float(space["cell_cm"])
|
||||
|
||||
for aspect in (2.0, 0.5, 0.8155784250916674, 1.4142):
|
||||
# a square room, 0.2 x 0.2 of the OLD box, i.e. 200 x 200/aspect render
|
||||
old = {"id": "f", "aspect": aspect, "cell_cm": 5,
|
||||
"rooms": [{"id": "r", "poly": [[0.2, 0.2], [0.4, 0.2], [0.4, 0.4], [0.2, 0.4]]}]}
|
||||
before_w = 0.2 * GRID
|
||||
before_h = 0.2 * GRID / aspect
|
||||
before_cm_w = (before_w / (GRID / 40)) * 5
|
||||
sp = _sq(old)
|
||||
poly = sp["rooms"][0]["poly"]
|
||||
after_w = (poly[1][0] - poly[0][0]) * GRID
|
||||
after_h = (poly[2][1] - poly[1][1]) * GRID
|
||||
assert abs(after_w / after_h - before_w / before_h) < 1e-9, "shape preserved"
|
||||
# cell_cm is stored rounded — a user reads it — so allow 0.01 cm on a
|
||||
# 40 cm wall rather than pretending the scale is infinitely precise
|
||||
assert abs(wall_cm(sp, poly[0], poly[1]) - before_cm_w) < 1e-2, "length in cm preserved"
|
||||
|
||||
|
||||
def test_migration_moves_marker_positions_of_that_space_only():
|
||||
layout = {
|
||||
"a": {"s": "f1", "x": 0.5, "y": 0.5},
|
||||
"b": {"s": "other", "x": 0.5, "y": 0.5},
|
||||
"c": "not a dict",
|
||||
}
|
||||
cfg = {"spaces": [{"id": "f1", "aspect": 2.0, "rooms": []},
|
||||
{"id": "other", "rooms": []}]}
|
||||
assert gm.migrate_config(cfg, layout) is True
|
||||
assert layout["a"] == {"s": "f1", "x": 0.5, "y": 0.5}, "x untouched for a wide plan"
|
||||
assert layout["a"]["y"] == 0.5
|
||||
assert layout["b"] == {"s": "other", "x": 0.5, "y": 0.5}, "another space is not touched"
|
||||
|
||||
|
||||
def test_migration_runs_once_and_only_when_needed():
|
||||
cfg = {"spaces": [{"id": "f1", "aspect": 2.0, "rooms": [], "cell_cm": 5}]}
|
||||
assert gm.migrate_config(cfg, {}) is True
|
||||
snapshot = repr(cfg)
|
||||
assert gm.migrate_config(cfg, {}) is False, "already square: nothing to do"
|
||||
assert repr(cfg) == snapshot
|
||||
|
||||
|
||||
def test_migration_survives_a_crash_between_the_two_store_writes():
|
||||
"""HP-1490-01: the two stores are written independently and either write
|
||||
can fail. The intent (space -> old aspect) is saved BEFORE anything moves
|
||||
and cleared with the layout write, so whichever half is missing after a
|
||||
crash, the next start finishes exactly it — once.
|
||||
"""
|
||||
cfg = {"spaces": [{"id": "f1", "aspect": 2.0, "rooms": []}]}
|
||||
layout = {"m": {"s": "f1", "x": 0.1, "y": 0.1}}
|
||||
|
||||
# start of the migration: the intent is computed from the config
|
||||
pending = gm.pending_from_config(cfg)
|
||||
assert pending == {"f1": 2.0}
|
||||
|
||||
# the config half commits; the process dies before the layout half
|
||||
assert gm.migrate_config(cfg) is True
|
||||
assert gm.pending_from_config(cfg) == {}, "the trigger left with the config write"
|
||||
|
||||
# next start: the config offers nothing, the SAVED intent still knows
|
||||
assert gm.migrate_layout(layout, pending) is True
|
||||
assert layout["m"] == {"s": "f1", "x": 0.1, "y": 0.3}, "y is re-centred for a wide plan"
|
||||
|
||||
# and the layout half never runs twice, because the intent is cleared by
|
||||
# the same write that stores the migrated layout — with no intent there is
|
||||
# nothing to apply
|
||||
assert gm.migrate_layout(layout, {}) is False
|
||||
assert layout["m"] == {"s": "f1", "x": 0.1, "y": 0.3}
|
||||
|
||||
|
||||
def test_migration_intent_is_the_union_of_saved_and_current():
|
||||
"""A crash BEFORE the config write leaves both the intent and the aspects;
|
||||
merging them must not double anything, and a space added to the config
|
||||
since (there cannot be one mid-crash, but the code should not care) still
|
||||
migrates."""
|
||||
cfg = {"spaces": [{"id": "f1", "aspect": 2.0, "rooms": []}]}
|
||||
saved = {"f1": 2.0}
|
||||
merged = {**saved, **gm.pending_from_config(cfg)}
|
||||
assert merged == {"f1": 2.0}
|
||||
layout = {"m": {"s": "f1", "x": 0.1, "y": 0.1}}
|
||||
gm.migrate_config(cfg)
|
||||
gm.migrate_layout(layout, merged)
|
||||
assert layout["m"]["y"] == 0.3
|
||||
assert cfg["spaces"][0]["view_box"] == [0.0, 0.0, 1.0, 1.0]
|
||||
|
||||
|
||||
# ---------- store-wide limits (HP-1470-01) ----------
|
||||
|
||||
|
||||
def test_check_quota_counts_the_whole_store_not_one_request(tmp_path):
|
||||
"""Per-request caps say nothing about how many requests there are."""
|
||||
d = tmp_path / "plans"
|
||||
d.mkdir()
|
||||
for i in range(3):
|
||||
(d / f"p{i}.png").write_bytes(b"x" * 1000)
|
||||
|
||||
plans.check_quota(d, 1000, max_bytes=10_000, max_files=10) # fits
|
||||
|
||||
with pytest.raises(plans.QuotaError) as e:
|
||||
plans.check_quota(d, 8000, max_bytes=10_000, max_files=10)
|
||||
assert e.value.reason == "quota_exceeded" and "MB" in e.value.detail
|
||||
|
||||
with pytest.raises(plans.QuotaError) as e:
|
||||
plans.check_quota(d, 1, max_bytes=10_000, max_files=3)
|
||||
assert e.value.reason == "too_many_files"
|
||||
|
||||
|
||||
def test_dir_usage_walks_subfolders_and_ignores_the_unreadable(tmp_path):
|
||||
d = tmp_path / "files"
|
||||
(d / "m1").mkdir(parents=True)
|
||||
(d / "m1" / "a.pdf").write_bytes(b"x" * 10)
|
||||
(d / "b.pdf").write_bytes(b"x" * 5)
|
||||
assert plans.dir_usage(d) == (15, 2)
|
||||
assert plans.dir_usage(tmp_path / "nope") == (0, 0)
|
||||
|
||||
|
||||
def test_check_quota_refuses_when_the_disk_is_nearly_full(tmp_path, monkeypatch):
|
||||
import shutil
|
||||
|
||||
d = tmp_path / "plans"
|
||||
d.mkdir()
|
||||
monkeypatch.setattr(
|
||||
shutil, "disk_usage", lambda _p: type("U", (), {"free": const.MIN_FREE_BYTES // 2})()
|
||||
)
|
||||
with pytest.raises(plans.QuotaError) as e:
|
||||
plans.check_quota(d, 1, max_bytes=10 ** 12, max_files=10 ** 6)
|
||||
assert e.value.reason == "low_disk_space"
|
||||
|
||||
Reference in New Issue
Block a user