mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 11:18:48 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9da96abb05 | ||
|
|
df65d25348 | ||
|
|
8db6b2673f | ||
|
|
c9030af900 | ||
|
|
5392dadeaa | ||
|
|
aa53b33dd6 | ||
|
|
a8ce6020f4 | ||
|
|
9282c28830 | ||
|
|
8c5d5ba5c5 | ||
|
|
6c90e03427 | ||
|
|
9b180c5917 | ||
|
|
3084472c75 | ||
|
|
c00048611e | ||
|
|
f5e6c0318d | ||
|
|
e1e730560d | ||
|
|
94b298962a | ||
|
|
f7fe63776a | ||
|
|
01bc4f9711 | ||
|
|
85491d0fea | ||
|
|
d37a67c29f | ||
|
|
a66272c6f4 | ||
|
|
f4af2fe508 | ||
|
|
8e07e3c958 | ||
|
|
9868f1035f | ||
|
|
f2c9b07cc1 | ||
|
|
2c7a2f849d | ||
|
|
33e71ca96c |
@@ -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.
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ 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
|
||||
@@ -99,6 +100,46 @@ 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.
|
||||
|
||||
@@ -17,24 +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
|
||||
|
||||
# The scheduled sweep is a different judgement from a commit's. A commit knows
|
||||
# it replaced a file; the timer only knows nobody points at one right now — and
|
||||
# "nobody points at it" is a normal, reversible state. Detaching a plan (switch
|
||||
# a space to "draw") leaves the image on disk on purpose, and re-attaching it
|
||||
# later is a thing people do. On 2026-07-28 the hourly rule applied to that case
|
||||
# and removed two plans the owner had detached weeks earlier; they were not
|
||||
# recoverable. So the timer waits a month, and never touches a plan or an
|
||||
# attachment that still belongs to something in the configuration.
|
||||
# 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.46.4"
|
||||
VERSION = "1.50.3"
|
||||
|
||||
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
|
||||
@@ -20,9 +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, reserve_filename
|
||||
from .plans import TMP_PREFIX, QuotaError, check_quota, reserve_filename
|
||||
from .validation import (
|
||||
FILE_EXTENSIONS,
|
||||
MAX_FILE_BYTES,
|
||||
@@ -204,6 +207,16 @@ class HouseplanUploadView(HomeAssistantView):
|
||||
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
|
||||
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.46.4"
|
||||
"version": "1.50.3"
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .const import PLAN_ORPHAN_TTL_S, SCHEDULED_GRACE_S
|
||||
from .const import MIN_FREE_BYTES, PLAN_ORPHAN_TTL_S
|
||||
from .validation import MAX_FILENAME, PLAN_EXTENSIONS, sanitize_filename
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -121,20 +121,25 @@ def collect_attachments(
|
||||
) -> int:
|
||||
"""The same commit-scoped rule as `collect_plans`, for marker attachments.
|
||||
|
||||
A file the old revision referenced and the new one does not was superseded
|
||||
by this commit and goes. Otherwise a staging folder (`up_*` — only ever a
|
||||
dialog that was never saved) is collected after PLAN_ORPHAN_TTL_S, and
|
||||
anything else waits out SCHEDULED_GRACE_S. Never raises: it runs behind a
|
||||
durable write.
|
||||
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
|
||||
cutoff = now_s - SCHEDULED_GRACE_S
|
||||
removed = 0
|
||||
try:
|
||||
folders = sorted(p for p in files_dir.iterdir() if p.is_dir()) if files_dir.is_dir() else []
|
||||
@@ -144,10 +149,8 @@ def collect_attachments(
|
||||
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. A marker's own folder is not:
|
||||
# removing an attachment is deliberate, but so is re-adding one, and the
|
||||
# file may have been detached rather than abandoned. Give it a month.
|
||||
limit = staging_cutoff if folder.name.startswith("up_") else cutoff
|
||||
# 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:
|
||||
@@ -156,9 +159,16 @@ def collect_attachments(
|
||||
rel = f"{folder.name}/{item.name}"
|
||||
if rel in new_refs:
|
||||
continue
|
||||
if rel not in old_refs: # not superseded: absence alone is weak evidence
|
||||
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 >= limit:
|
||||
if item.stat().st_mtime >= staging_cutoff:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
@@ -179,6 +189,56 @@ def collect_attachments(
|
||||
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)."""
|
||||
if not isinstance(url, str) or not url:
|
||||
@@ -196,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(".")
|
||||
@@ -222,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
|
||||
@@ -240,11 +308,19 @@ def collect_plans(
|
||||
# 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.
|
||||
detached = {
|
||||
str(sp.get("id")) for sp in (new_cfg or {}).get("spaces") or []
|
||||
if not sp.get("plan_url")
|
||||
# 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
|
||||
}
|
||||
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
|
||||
removed = 0
|
||||
try:
|
||||
items = sorted(plans_dir.iterdir()) if plans_dir.is_dir() else []
|
||||
@@ -257,15 +333,22 @@ 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
|
||||
if not superseded:
|
||||
if item.name.split(".")[0] in detached:
|
||||
continue # detached, not abandoned — the space is waiting for it
|
||||
try:
|
||||
if item.stat().st_mtime >= cutoff:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
if item.name not in replaced:
|
||||
# PRODUCT RULE (owner's decision, 2026-07-28): a plan file we were
|
||||
# not told to delete is kept, however long it sits there. Detaching
|
||||
# is one click to undo and the editor says the image stays; deleting
|
||||
# a space is deliberate but the image was imported and may be
|
||||
# nowhere else. The errors are not symmetrical — unnecessary
|
||||
# megabytes can be removed by hand, a deleted file cannot be
|
||||
# brought back.
|
||||
#
|
||||
# There is deliberately no age rule here. An earlier version aged
|
||||
# out "rejected uploads" — a file of a space that has a plan, which
|
||||
# was never the plan — and that raced a save: the sweep deleted the
|
||||
# upload from the failed attempt while a retry was committing a
|
||||
# reference to it. A rule that can delete a file somebody is about
|
||||
# to point at is not worth the disk it reclaims.
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
removed += 1
|
||||
|
||||
@@ -43,6 +43,12 @@ 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
|
||||
|
||||
@@ -95,13 +95,41 @@ _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))
|
||||
|
||||
# Positions are normalised to the canvas (0..1). Allow generous slack for an
|
||||
# icon dragged past an edge, but not arbitrary magnitudes: any finite float
|
||||
# used to pass, and a single stored 1e100 stretched every client's view of the
|
||||
# space until the plan was invisible (HP-1500-03).
|
||||
_COORD = vol.All(_finite, vol.Range(min=-4.0, max=4.0))
|
||||
|
||||
POS_SCHEMA = vol.Schema(
|
||||
{vol.Required("x"): _finite, vol.Required("y"): _finite},
|
||||
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
|
||||
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
|
||||
)
|
||||
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
|
||||
|
||||
POINT = vol.All([_finite], vol.Length(min=2, max=2))
|
||||
# Geometry is normalised to the canvas (0..1). ±4 is generous slack for a
|
||||
# vertex nudged past an edge, not an envelope for arbitrary magnitudes: any
|
||||
# finite float used to pass here, and a single 1e100 room vertex stretched the
|
||||
# frame until the whole space was unviewable for every client (HP-1501-01) —
|
||||
# the exact failure HP-1500-03 closed for layout positions, one schema over.
|
||||
_GEOM = vol.All(_finite, vol.Range(min=-4.0, max=4.0))
|
||||
|
||||
# A SIZE is not a coordinate (HP-1502-01): SVG requires positive width/height,
|
||||
# and the clients divide by these. `view_box: [0,0,0,0]` passed the shared
|
||||
# validator and serialised into viewBox="0 0 0 0" — a blank plan on every
|
||||
# client. The floor is one thousandth of the canvas (1 render unit): far below
|
||||
# any real room, but keeps the maths finite.
|
||||
_EXTENT = vol.All(_finite, vol.Range(min=0.001, max=4.0))
|
||||
|
||||
|
||||
def _view_box(value):
|
||||
"""[x, y, w, h]: the first two are coordinates, the last two are sizes."""
|
||||
if not isinstance(value, (list, tuple)) or len(value) != 4:
|
||||
raise vol.Invalid("view_box must be [x, y, w, h]")
|
||||
return [_GEOM(value[0]), _GEOM(value[1]), _EXTENT(value[2]), _EXTENT(value[3])]
|
||||
|
||||
|
||||
POINT = vol.All([_GEOM], vol.Length(min=2, max=2))
|
||||
|
||||
|
||||
def _require_geometry(room: dict) -> dict:
|
||||
@@ -130,10 +158,10 @@ ROOM_SCHEMA = vol.All(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
),
|
||||
vol.Optional("x"): _finite,
|
||||
vol.Optional("y"): _finite,
|
||||
vol.Optional("w"): _finite,
|
||||
vol.Optional("h"): _finite,
|
||||
vol.Optional("x"): _GEOM,
|
||||
vol.Optional("y"): _GEOM,
|
||||
vol.Optional("w"): _EXTENT,
|
||||
vol.Optional("h"): _EXTENT,
|
||||
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3, max=MAX_POLY_POINTS)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
@@ -188,8 +216,16 @@ 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)),
|
||||
vol.Required("view_box"): vol.All([_finite], vol.Length(min=4, max=4)),
|
||||
# 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"): _view_box,
|
||||
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
|
||||
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
|
||||
vol.Optional("openings"): vol.All([
|
||||
@@ -197,9 +233,9 @@ SPACE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("type"): vol.Any("door", "window"),
|
||||
vol.Required("x"): _finite,
|
||||
vol.Required("y"): _finite,
|
||||
vol.Required("angle"): _finite,
|
||||
vol.Required("x"): _GEOM,
|
||||
vol.Required("y"): _GEOM,
|
||||
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
|
||||
vol.Required("length"): vol.All(vol.Coerce(float), vol.Range(min=0.001, max=1)),
|
||||
vol.Optional("contact"): vol.Any(str, None),
|
||||
vol.Optional("lock"): vol.Any(str, None),
|
||||
|
||||
@@ -17,14 +17,19 @@ from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import (
|
||||
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
|
||||
CONTENT_URL, FILES_DIR, 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_attachments, collect_plans, reserve_filename
|
||||
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_CONFIG_BYTES, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, valid_space_id,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, sanitize_filename, valid_space_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -36,11 +41,14 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
"""Register the WS commands."""
|
||||
websocket_api.async_register_command(hass, ws_layout_get)
|
||||
websocket_api.async_register_command(hass, ws_layout_set)
|
||||
websocket_api.async_register_command(hass, ws_geometry_repair)
|
||||
websocket_api.async_register_command(hass, ws_layout_update)
|
||||
websocket_api.async_register_command(hass, ws_layout_delete)
|
||||
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)
|
||||
@@ -111,7 +119,8 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
)
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await rt.store.async_save({"layout": msg["layout"], "rev": new_rev})
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
|
||||
"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})
|
||||
|
||||
@@ -140,11 +149,100 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
# 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})
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
|
||||
"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(
|
||||
{
|
||||
vol.Required("type"): "houseplan/geometry/repair",
|
||||
vol.Required("space_id"): str,
|
||||
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
||||
vol.Optional("dry_run"): bool,
|
||||
vol.Optional("undo"): bool,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Re-apply the square-canvas transform to ONE space's layout, explicitly.
|
||||
|
||||
For installations that hit the v1.48/v1.49 crash window: the config write
|
||||
of the migration landed, the layout write did not, and the trigger fields
|
||||
were already gone — markers and labels of that space are stranded in the
|
||||
old coordinates with nothing able to tell (HP-1500-01). Nothing can be
|
||||
detected reliably after the fact, and re-running a transform on a layout
|
||||
that is already correct would corrupt it, so this NEVER runs by itself:
|
||||
an administrator names the space and its old aspect, may preview with
|
||||
`dry_run`, and gets a one-deep backup written in the same store write —
|
||||
`undo` restores it.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may repair the layout")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
from .geometry_migration import migrate_layout
|
||||
|
||||
space_id = msg["space_id"]
|
||||
if not valid_space_id(space_id):
|
||||
connection.send_error(msg["id"], "invalid_space_id", "space_id: only [a-z0-9_-], up to 64 characters")
|
||||
return
|
||||
async with rt.write_lock:
|
||||
data = await rt.store.async_load() or {}
|
||||
layout = data.get("layout") or {}
|
||||
current_rev = int(data.get("rev", 0))
|
||||
if msg.get("undo"):
|
||||
backup = data.get("repair_backup")
|
||||
if not isinstance(backup, dict) or backup.get("space") != space_id:
|
||||
connection.send_error(msg["id"], "no_backup", "No repair backup stored for this space")
|
||||
return
|
||||
restored = dict(layout)
|
||||
for key, pos in (backup.get("positions") or {}).items():
|
||||
restored[key] = pos
|
||||
new_rev = current_rev + 1
|
||||
await rt.store.async_save({"layout": restored, "rev": new_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev,
|
||||
"restored": len(backup.get("positions") or {})})
|
||||
return
|
||||
touched = {
|
||||
k: dict(v) for k, v in layout.items()
|
||||
if isinstance(v, dict) and str(v.get("s")) == space_id
|
||||
}
|
||||
if not touched:
|
||||
# A typo'd space id used to "succeed" with moved: 0 — and its
|
||||
# empty result REPLACED the one-deep backup, destroying the very
|
||||
# undo this endpoint promises (HP-1501-02). Nothing to move means
|
||||
# nothing to save: no write, no revision bump, the backup stays.
|
||||
connection.send_error(
|
||||
msg["id"], "nothing_to_repair",
|
||||
f"No stored positions belong to space '{space_id}'",
|
||||
)
|
||||
return
|
||||
preview = {k: dict(v) for k, v in touched.items()}
|
||||
migrate_layout(preview, {space_id: msg["aspect"]})
|
||||
if msg.get("dry_run"):
|
||||
connection.send_result(msg["id"], {
|
||||
"ok": True, "dry_run": True, "moved": len(preview),
|
||||
"before": touched, "after": preview,
|
||||
})
|
||||
return
|
||||
new_layout = {**layout, **preview}
|
||||
new_rev = current_rev + 1
|
||||
# the backup rides the same store write: either both are durable or
|
||||
# neither — the deletion-shy rules of this project apply to positions
|
||||
# too
|
||||
await rt.store.async_save({
|
||||
"layout": new_layout, "rev": new_rev,
|
||||
"repair_backup": {"space": space_id, "positions": touched},
|
||||
})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev, "moved": len(preview)})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/files/migrate",
|
||||
@@ -215,6 +313,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",
|
||||
@@ -263,34 +460,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(
|
||||
@@ -315,7 +546,8 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
if msg["device_id"] in layout:
|
||||
del layout[msg["device_id"]]
|
||||
new_rev = int(data.get("rev", 0)) + 1
|
||||
await rt.store.async_save({"layout": layout, "rev": new_rev})
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
|
||||
"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})
|
||||
@@ -337,6 +569,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",
|
||||
@@ -386,6 +658,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
|
||||
@@ -446,10 +735,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
|
||||
@@ -458,9 +748,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));
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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,81 @@
|
||||
// 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
|
||||
});
|
||||
|
||||
// -- a card BELOW other dashboard content still gets a stage (HP-1500-02) --
|
||||
out.stageSurvivesContentAbove = await page.evaluate(async () => {
|
||||
const spacer = document.createElement('div');
|
||||
spacer.style.height = '900px';
|
||||
document.body.insertBefore(spacer, document.body.firstChild);
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const h = sr.querySelector('.stage').getBoundingClientRect().height;
|
||||
spacer.remove();
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
// the old code billed the 900px spacer as "header" and left a 0px stage
|
||||
return h > 300;
|
||||
});
|
||||
|
||||
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
+119
-38
File diff suppressed because one or more lines are too long
+68
-15
@@ -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)
|
||||
@@ -178,8 +178,32 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
|
||||
| `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) |
|
||||
|
||||
**If the v1.48 migration crashed halfway** (HP-1500-01): the config write
|
||||
landed, the layout write did not, and both triggers are gone — markers of that
|
||||
space sit in the old coordinates and nothing in the data can prove it. The
|
||||
`geom_pending` intent (v1.50.0) prevents this for any future migration, but
|
||||
cannot help an install that was already stranded. There is no safe automatic
|
||||
answer — re-transforming a layout that is actually correct would corrupt it —
|
||||
so the fix is explicit: `houseplan/geometry/repair {space_id, aspect}`
|
||||
re-applies the transform to that one space's positions. `dry_run: true`
|
||||
previews, the previous positions ride the same store write as a one-deep
|
||||
backup, and `undo: true` restores them. Admin-gated like every other write.
|
||||
|
||||
**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
|
||||
@@ -208,16 +232,36 @@ cancelled would wait for a write that may never come. A new icon has no id yet,
|
||||
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 how weak depends on the
|
||||
case. A space with `plan_url = null` had its image **detached**, which is
|
||||
reversible and which the editor promises leaves the file alone: those are never
|
||||
collected. A space that does have a plan can only be holding its own rejected
|
||||
uploads, so `PLAN_ORPHAN_TTL_S` (1 hour) still applies to them. For attachments,
|
||||
a per-dialog staging folder holds nothing but an upload from a dialog that was
|
||||
never saved — one hour again — while a marker's own folder waits
|
||||
`SCHEDULED_GRACE_S` (30 days). This is not theoretical caution: applying the
|
||||
one-hour rule to every unreferenced file destroyed two detached plans on
|
||||
2026-07-28.
|
||||
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
|
||||
@@ -233,12 +277,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
|
||||
@@ -279,7 +332,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,230 @@
|
||||
# Changelog
|
||||
|
||||
## v1.50.3 — 2026-07-29
|
||||
|
||||
**From the v1.50.2 review**
|
||||
|
||||
- **A size is not a coordinate (HP-1502-01).** The ±4 bound from v1.50.2
|
||||
treated all four view_box elements and room w/h alike, so `[0, 0, 0, 0]`
|
||||
and negative sizes still passed — and a zero axis serialises into
|
||||
`viewBox="0 0 0 0"`, a blank plan on every client, with the static card
|
||||
computing `aspect-ratio: 0 / 0` on top. Sizes now get their own validator:
|
||||
strictly positive, floored at one thousandth of the canvas; coordinates may
|
||||
still be negative, because a crop origin legitimately sits past the edge.
|
||||
And since a store may already hold a broken viewport from before, both
|
||||
cards fall back to the whole canvas instead of a blank screen, and a legacy
|
||||
rectangle with a negative size is read as the same rectangle drawn from the
|
||||
other corner.
|
||||
|
||||
**Also in this release**
|
||||
|
||||
- The room settings button moved to the bottom of the room card, and the room
|
||||
name renders in exactly the same spot in view mode and in the plan editor —
|
||||
the button and the metrics no longer take part in the label's centring.
|
||||
|
||||
## v1.50.2 — 2026-07-29
|
||||
|
||||
**From the v1.50.1 review**
|
||||
|
||||
- **Geometry magnitudes are bounded on both layers (HP-1501-01).** v1.50.1
|
||||
bounded layout positions, but room rectangles, polygon vertices, view_box
|
||||
and opening coordinates still took any finite float — one schema-valid 1e100
|
||||
vertex framed the space so wide the plan was a dot, for every client, and
|
||||
the server stored it as a perfectly good configuration. The config schema
|
||||
now bounds geometry to ±4 (angles to ±360°), and the content frame applies
|
||||
the same canvas envelope to room vertices it already applied to device
|
||||
positions — so a store that already holds an absurd coordinate from before
|
||||
this door existed still renders: the point draws wherever it is, it just no
|
||||
longer commands the frame. A vertex a bit past the canvas edge keeps
|
||||
working.
|
||||
- **A no-op repair no longer eats the undo backup (HP-1501-02).** A typo'd
|
||||
space id "succeeded" with moved: 0 — and its empty result replaced the
|
||||
one-deep backup, destroying the only way back exactly when it was needed
|
||||
most: right after repairing the wrong space. Matching nothing is an error
|
||||
now (`nothing_to_repair`); nothing is written, the revision does not move,
|
||||
and the previous repair stays undoable.
|
||||
|
||||
## v1.50.1 — 2026-07-29
|
||||
|
||||
**From the v1.50.0 review**
|
||||
|
||||
- **A card below other dashboard content gets its stage back (HP-1500-02).**
|
||||
The v1.50.0 height measurement used the absolute document coordinate, so a
|
||||
tall card before this one was billed as "header" and the stage collapsed to
|
||||
zero. The card now measures only its own chrome plus a bounded allowance for
|
||||
what the dashboard keeps above it, and re-measures on window resize; the
|
||||
listener is removed on teardown.
|
||||
- **The content frame can no longer be degenerate or absurd (HP-1500-03).**
|
||||
A lone marker in an empty space produced a zero-area viewBox — a blank
|
||||
scene; a single stored coordinate like 1e100 (any finite float passed
|
||||
validation) stretched the frame until the plan was a dot, for every viewer
|
||||
of the space. A near-zero axis now opens up to a floor of canvas around the
|
||||
marker, points far outside the canvas envelope no longer command the frame
|
||||
(they still render where they are), and the server refuses layout
|
||||
coordinates outside ±4 — generous slack for an icon dragged past an edge,
|
||||
not an envelope for absurdity. A real thin room keeps its tight frame, and
|
||||
the gate sensor slightly past the edge still counts.
|
||||
- **A repair path for installs stranded by the v1.48 migration window
|
||||
(HP-1500-01).** If the old migration crashed between its two writes, the
|
||||
markers of a space are left in the old coordinates with nothing in the data
|
||||
able to prove it — and re-transforming a correct layout would corrupt it, so
|
||||
nothing automatic is safe. `houseplan/geometry/repair {space_id, aspect}` is
|
||||
the explicit answer: `dry_run` previews the exact moves, the previous
|
||||
positions ride the same store write as a one-deep backup, `undo` restores
|
||||
them, and routine drags no longer erase that backup. The v1.50.0
|
||||
`geom_pending` protocol already protects every future migration; this covers
|
||||
the installs it was too late for.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -6,6 +6,230 @@
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## v1.50.3 — 2026-07-29
|
||||
|
||||
**По ревью v1.50.2**
|
||||
|
||||
- **Размер — не координата (HP-1502-01).** Граница ±4 из v1.50.2 мерила все
|
||||
четыре элемента view_box и w/h комнат одинаково, поэтому `[0, 0, 0, 0]` и
|
||||
отрицательные размеры всё ещё проходили — а нулевая ось сериализуется в
|
||||
`viewBox="0 0 0 0"`: пустой план у всех клиентов, и `aspect-ratio: 0 / 0` у
|
||||
статичной карточки сверху. У размеров теперь свой validator: строго
|
||||
положительные, пол — одна тысячная холста; координаты по-прежнему могут
|
||||
быть отрицательными — начало кропа законно выходит за край. А поскольку в
|
||||
сторе битый viewport может уже лежать, обе карточки падают на полный холст
|
||||
вместо пустого экрана, и legacy-прямоугольник с отрицательным размером
|
||||
читается как тот же прямоугольник, нарисованный из другого угла.
|
||||
|
||||
**Также в этом релизе**
|
||||
|
||||
- Кнопка настроек комнаты переехала в самый низ карточки комнаты, а имя
|
||||
комнаты отображается ровно в одном и том же месте в просмотре и в
|
||||
редакторе плана — кнопка и метрики больше не участвуют в центрировании.
|
||||
|
||||
## v1.50.2 — 2026-07-29
|
||||
|
||||
**По ревью v1.50.1**
|
||||
|
||||
- **Величины геометрии ограничены на обоих слоях (HP-1501-01).** v1.50.1
|
||||
ограничила позиции устройств, но прямоугольники комнат, вершины полигонов,
|
||||
view_box и координаты проёмов всё ещё принимали любое конечное число — одна
|
||||
проходящая схему вершина 1e100 растягивала кадр так, что план становился
|
||||
точкой у всех клиентов, и сервер хранил это как вполне корректную
|
||||
конфигурацию. Теперь схема конфига ограничивает геометрию ±4 (углы ±360°),
|
||||
а рамка содержимого применяет к вершинам комнат тот же конверт холста, что
|
||||
уже применяла к позициям устройств — конфиг, где абсурдная координата уже
|
||||
лежит с прежних времён, всё равно рендерится: точка рисуется, где стоит,
|
||||
просто кадром больше не командует. Вершина чуть за краём холста работает
|
||||
как раньше.
|
||||
- **Пустой repair больше не съедает бэкап (HP-1501-02).** Опечатка в space_id
|
||||
«успешно» отвечала moved: 0 — и её пустой результат заменял бэкап глубиной
|
||||
один, уничтожая единственный путь назад ровно тогда, когда он нужнее всего:
|
||||
сразу после починки не того пространства. Теперь «нечего чинить» — ошибка
|
||||
(`nothing_to_repair`): ничего не пишется, ревизия не растёт, предыдущий
|
||||
repair по-прежнему отменяем.
|
||||
|
||||
## v1.50.1 — 2026-07-29
|
||||
|
||||
**По ревью v1.50.0**
|
||||
|
||||
- **Карточка ниже другого контента дашборда снова получает сцену
|
||||
(HP-1500-02).** Замер высоты в v1.50.0 брал абсолютную координату документа:
|
||||
высокая карточка перед этой записывалась в «шапку», и сцена схлопывалась в
|
||||
ноль. Теперь меряется только собственная обвязка плюс ограниченная поправка
|
||||
на то, что дашборд держит сверху; перемер — по resize окна, слушатель
|
||||
снимается при демонтаже.
|
||||
- **Рамка содержимого больше не бывает вырожденной или абсурдной
|
||||
(HP-1500-03).** Одинокий значок в пустом пространстве давал viewBox нулевой
|
||||
площади — пустую сцену; одна сохранённая координата вида 1e100 (любое
|
||||
конечное число проходило проверку) растягивала рамку так, что план
|
||||
становился точкой — у всех зрителей пространства. Почти нулевая ось теперь
|
||||
раскрывается до минимального кадра вокруг значка, точки далеко за холстом
|
||||
рамкой не командуют (рисуются, где стоят), а сервер отклоняет координаты вне
|
||||
±4 — щедрый запас для значка, утащенного за край, но не для абсурда. Узкая
|
||||
комната сохраняет тесный кадр, датчик калитки чуть за краём по-прежнему
|
||||
учитывается.
|
||||
- **Путь восстановления для установок, застрявших в окне миграции v1.48
|
||||
(HP-1500-01).** Если старая миграция упала между двумя записями, значки
|
||||
пространства остаются в старых координатах, и по данным это недоказуемо — а
|
||||
повторное преобразование правильных координат их испортит, поэтому ничего
|
||||
автоматического тут быть не может. Явный ответ —
|
||||
`houseplan/geometry/repair {space_id, aspect}`: `dry_run` показывает точные
|
||||
перемещения, прежние позиции уезжают той же записью хранилища как резервная
|
||||
копия глубиной один, `undo` их возвращает, и обычные перетаскивания эту
|
||||
копию больше не затирают. Протокол `geom_pending` из v1.50.0 уже защищает
|
||||
все будущие миграции; это — для тех, кому он опоздал.
|
||||
|
||||
## 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 (потеря данных: отцеплённые планы убирались как мусор)
|
||||
- **Отцеплённый план больше не удаляется через час.** Переключение пространства
|
||||
в режим «нарисовать» снимает ссылку и, как редактор всегда и говорил,
|
||||
|
||||
+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.
|
||||
|
||||
+2
-2
@@ -15,12 +15,12 @@
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.46.4** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Version | **v1.50.3** 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` 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.46.4** via direct copy (HACS custom repo also installed) |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.50.3** via direct copy (HACS custom repo also installed) |
|
||||
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
|
||||
| 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 |
|
||||
|
||||
+84
-6
@@ -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
|
||||
@@ -239,10 +239,88 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
on the plan after a reload. Same for each tap action and each fill mode
|
||||
[auto: backend test_every_display_mode_the_editor_offers_is_accepted and
|
||||
neighbours, test_a_marker_showing_its_value_can_be_saved]
|
||||
- [ ] Detaching a plan keeps the file (v1.46.4): switch a space to "draw",
|
||||
restart, wait a day — the image is still in `config/houseplan/plans/` and
|
||||
can be re-attached. Replacing a plan still removes the one it replaced,
|
||||
immediately [auto: unit: test_scheduled_collection_never_takes_a_detached_plan]
|
||||
- [ ] Sizes are positive (v1.50.3, HP-1502-01): view_box or room w/h of zero
|
||||
or below is refused; a store that already holds one opens on the full
|
||||
canvas, not a blank screen [auto: test_sizes_are_not_coordinates + unit
|
||||
safeViewBox fallback]
|
||||
- [ ] Room card layout (v1.50.3): the settings button is the bottom row of the
|
||||
card and the room name sits in the same spot in view and plan modes
|
||||
[manual; verified by vb-coordinate measurement]
|
||||
- [ ] Geometry bounds (v1.50.2, HP-1501-01): a config with a 1e100 room
|
||||
vertex is refused by the server; one already stored still renders with a
|
||||
sane frame [auto: test_geometry_magnitudes_are_bounded + unit
|
||||
contentBounds legacy case]
|
||||
- [ ] No-op repair (v1.50.2, HP-1501-02): geometry/repair with a typo'd space
|
||||
id errors, moves no revision and keeps the previous backup undoable
|
||||
[auto: test_a_noop_repair_does_not_eat_the_backup]
|
||||
- [ ] Card below other dashboard content (v1.50.1, HP-1500-02): place the card
|
||||
after a tall card in a normal dashboard — the plan still gets most of the
|
||||
viewport instead of a zero-height stage [auto: smoke_zoom_out]
|
||||
- [ ] Frame never degenerate (v1.50.1, HP-1500-03): a space with one lone
|
||||
marker opens with canvas around it, not an empty scene; an absurd stored
|
||||
coordinate neither hides the plan nor is accepted by the server
|
||||
[auto: unit contentBounds + backend test_layout_coordinates_are_bounded]
|
||||
- [ ] Stranded migration repair (v1.50.1, HP-1500-01): geometry/repair with
|
||||
dry_run previews, applies with a backup, undo restores; wrong space is
|
||||
recoverable [auto: test_geometry_repair_is_explicit_previewable_and_undoable]
|
||||
- [ ] 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
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.45.4",
|
||||
"version": "1.50.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.45.4",
|
||||
"version": "1.50.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.46.4",
|
||||
"version": "1.50.3",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+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); }
|
||||
|
||||
+299
-70
@@ -33,15 +33,16 @@ import type {
|
||||
import './editor';
|
||||
import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
import { fitInSquare, contentBounds } from './space-geometry';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.46.4';
|
||||
const CARD_VERSION = '1.50.3';
|
||||
const LS_KEY = 'houseplan_card_layout_v1';
|
||||
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||
const LS_NAV = 'houseplan_card_nav_v1'; // last space + editor mode (owner: restore where you were)
|
||||
const LS_KIOSK = 'houseplan_card_kiosk_v1'; // per-SCREEN size multipliers (each wall tablet differs)
|
||||
const NORM_W = 1000; // width of the render space for normalized configs
|
||||
const NORM_W = 1000; // side of the render space — the canvas is square (v1.48.0)
|
||||
|
||||
const GRID_N = 240; // grid points across the plan width (half the previous step; old nodes are a subset of the new ones, positions are preserved)
|
||||
type MarkupTool = 'draw' | 'merge' | 'split' | 'opening' | 'openwall' | 'delroom';
|
||||
@@ -163,13 +164,34 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
/** Kiosk auto-carousel: advance to the next space every `cycle` seconds. */
|
||||
/**
|
||||
* Which way the plan should fly out when the space changes. Empty means no
|
||||
* animation — a direct pick from the tabs should not slide anywhere.
|
||||
*/
|
||||
private _slide: '' | 'left' | 'right' = '';
|
||||
private _slideTimer?: number;
|
||||
|
||||
/** Change the space with the usual sideways transition. */
|
||||
private _slideTo(id: string, dir: 'left' | 'right'): void {
|
||||
if (id === this._space) return;
|
||||
const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
|
||||
this._space = id;
|
||||
this._selId = null;
|
||||
this._restoreZoom();
|
||||
if (reduce) return;
|
||||
this._slide = dir;
|
||||
clearTimeout(this._slideTimer);
|
||||
// long enough to be read as motion, short enough not to be in the way
|
||||
this._slideTimer = window.setTimeout(() => { this._slide = ''; this.requestUpdate(); }, 260);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _cycleTick(): void {
|
||||
if (!this._kiosk || !(Number(this._config?.cycle) > 0)) return;
|
||||
if (Date.now() >= this._cyclePausedUntil && this._model.length > 1 && this._zoom <= 1.001) {
|
||||
const ids = this._model.map((m) => m.id);
|
||||
const i = ids.indexOf(this._space);
|
||||
this._space = ids[(i + 1) % ids.length];
|
||||
this._restoreZoom();
|
||||
this._slideTo(ids[(i + 1) % ids.length], 'left');
|
||||
this._showKioskDots();
|
||||
}
|
||||
}
|
||||
@@ -223,6 +245,9 @@ class HouseplanCard extends LitElement {
|
||||
private _pinchStart: { dist: number; zoom: number } | null = null;
|
||||
private _suppressClick = false;
|
||||
private _roViewport?: ResizeObserver;
|
||||
private _roHdr?: ResizeObserver;
|
||||
private _onWinResize?: () => void;
|
||||
private _hdrH = 118; // measured px above the stage (see the observer in updated())
|
||||
private _onboardingShown = false; // the auto space dialog is shown once per session
|
||||
|
||||
private _rulesDialog: { rules: IconRule[]; test: string; busy: boolean } | null = null;
|
||||
@@ -277,8 +302,16 @@ class HouseplanCard extends LitElement {
|
||||
title: string;
|
||||
planUrl: string | null;
|
||||
planFile: { ext: string; b64: string; aspect: number; name: string } | null;
|
||||
/**
|
||||
* The "already uploaded" list, its contents, and the aspect of whatever was
|
||||
* picked from it. Plans are never deleted for being unreferenced, which is
|
||||
* only a sane policy if they can be found again (docs/SCOPE.md).
|
||||
*/
|
||||
pickSaved?: boolean;
|
||||
saved?: { name: string; url: string; size: number; modified: number; used_by: string[] }[] | null;
|
||||
savedBusy?: boolean;
|
||||
savedAspect?: number;
|
||||
source: 'file' | 'draw'; // draw = no background image, hand-drawn rooms
|
||||
orientation: 'landscape' | 'portrait' | 'square';
|
||||
showBorders: boolean;
|
||||
showNames: boolean;
|
||||
roomColor: string;
|
||||
@@ -329,6 +362,7 @@ class HouseplanCard extends LitElement {
|
||||
private _holdFired = false;
|
||||
|
||||
static properties = {
|
||||
_hdrH: { state: true },
|
||||
hass: { attribute: false },
|
||||
_config: { state: true },
|
||||
_space: { state: true },
|
||||
@@ -395,11 +429,18 @@ class HouseplanCard extends LitElement {
|
||||
clearTimeout(this._reloadRetry);
|
||||
this._signer.dispose();
|
||||
clearTimeout(this._toastTimer);
|
||||
clearTimeout(this._slideTimer);
|
||||
this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown
|
||||
window.removeEventListener('hashchange', this._onHashChange);
|
||||
clearTimeout(this._holdTimer);
|
||||
this._roViewport?.disconnect();
|
||||
this._roViewport = undefined;
|
||||
this._roHdr?.disconnect();
|
||||
this._roHdr = undefined;
|
||||
if (this._onWinResize) {
|
||||
window.removeEventListener('resize', this._onWinResize);
|
||||
this._onWinResize = undefined;
|
||||
}
|
||||
if (this._unsubCfg) {
|
||||
this._unsubCfg();
|
||||
this._unsubCfg = null;
|
||||
@@ -560,7 +601,7 @@ class HouseplanCard extends LitElement {
|
||||
return !!(this._serverCfg && this._serverCfg.spaces.length);
|
||||
}
|
||||
|
||||
/** Spaces in render units (NORM_W × NORM_W/aspect). */
|
||||
/** Spaces in render units (NORM_W × NORM_W — the canvas is square). */
|
||||
/** Bumped by every config mutation — the model/geometry cache key (audit L1). */
|
||||
private _cfgEpoch = 0;
|
||||
private _modelCache: { key: string; model: SpaceModel[] } | null = null;
|
||||
@@ -570,7 +611,7 @@ class HouseplanCard extends LitElement {
|
||||
const sp = this._serverCfg?.spaces || [];
|
||||
let s = sp.length + ':';
|
||||
for (const x of sp as any[]) {
|
||||
s += (x.id || '') + ',' + (x.aspect || '') + ',' + (x.plan_url || '').length + ','
|
||||
s += (x.id || '') + ',' + (x.plan_aspect || '') + ',' + (x.plan_url || '').length + ','
|
||||
+ (x.rooms?.length || 0) + ',' + (x.openings?.length || 0) + ',' + (x.decor?.length || 0) + ';';
|
||||
for (const r of x.rooms || []) {
|
||||
// O(1) geometry roll-up per room: the count alone said nothing about
|
||||
@@ -601,7 +642,7 @@ class HouseplanCard extends LitElement {
|
||||
private _buildModel(): SpaceModel[] {
|
||||
if (!this._serverCfg) return [];
|
||||
return this._serverCfg.spaces.map((s: any) => {
|
||||
const H = NORM_W / s.aspect;
|
||||
const H = NORM_W; // the canvas is always square (v1.48.0)
|
||||
const scale = (r: any) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
@@ -622,7 +663,7 @@ class HouseplanCard extends LitElement {
|
||||
// so a signed url baked in here would freeze BEFORE the signature
|
||||
// arrives and the plan would never load (bug found 2026-07-27).
|
||||
// _display() is called at render time instead.
|
||||
bg: s.plan_url ? { href: s.plan_url, x: 0, y: 0, w: NORM_W, h: H } : null,
|
||||
bg: s.plan_url ? { href: s.plan_url, ...fitInSquare(s.plan_aspect, NORM_W) } : null,
|
||||
rooms: s.rooms.map(scale),
|
||||
};
|
||||
});
|
||||
@@ -693,6 +734,34 @@ class HouseplanCard extends LitElement {
|
||||
this._roViewport = new ResizeObserver(() => this._refitView());
|
||||
this._roViewport.observe(stage);
|
||||
}
|
||||
// The stage fills the rest of the viewport. What sits above it inside the
|
||||
// CARD depends on the mode — the editor bars used to be billed against a
|
||||
// hard-coded 118px, so entering an editor pushed the plan down by the
|
||||
// difference. Measure our own chrome (stage top relative to the card) and
|
||||
// allow a BOUNDED amount for what the dashboard puts above us (HA's
|
||||
// toolbar). The first version used the absolute document coordinate here:
|
||||
// put anything tall before the card and the "header budget" swallowed the
|
||||
// whole viewport, leaving a 0px stage (HP-1500-02). Content above the card
|
||||
// is the dashboard's business — it scrolls; it is not header.
|
||||
const hdr = this.renderRoot.querySelector('.hdr') as HTMLElement | null;
|
||||
if (hdr && stage && !this._roHdr) {
|
||||
const measure = () => {
|
||||
const card = this.renderRoot.querySelector('ha-card');
|
||||
if (!card) return;
|
||||
const own = stage.getBoundingClientRect().top - card.getBoundingClientRect().top;
|
||||
const above = Math.min(Math.max(card.getBoundingClientRect().top, 0), 120);
|
||||
const t = Math.round(own + above);
|
||||
if (t >= 0 && Math.abs(t - this._hdrH) > 1) this._hdrH = t;
|
||||
};
|
||||
// a frame later: setting state straight from the observer callback makes
|
||||
// the browser report "ResizeObserver loop completed with undelivered
|
||||
// notifications" — the render it triggers resizes the stage again
|
||||
this._roHdr = new ResizeObserver(() => requestAnimationFrame(measure));
|
||||
this._roHdr.observe(hdr);
|
||||
this._onWinResize = () => requestAnimationFrame(measure);
|
||||
window.addEventListener('resize', this._onWinResize);
|
||||
measure();
|
||||
}
|
||||
if (stage && !this._view) this._refitView();
|
||||
// onboarding: on an empty server config, open the space dialog right away
|
||||
if (
|
||||
@@ -997,7 +1066,7 @@ class HouseplanCard extends LitElement {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
/** Curation + light groups + overrides + virtual devices. */
|
||||
/** Filtering + light groups + overrides + virtual devices. */
|
||||
private get _markers(): Marker[] {
|
||||
return this._serverCfg?.markers || [];
|
||||
}
|
||||
@@ -1062,8 +1131,7 @@ class HouseplanCard extends LitElement {
|
||||
if (saved) {
|
||||
if (this._norm) {
|
||||
if (saved.s === d.space) {
|
||||
const aspect = this._serverCfg!.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 };
|
||||
}
|
||||
} else if (saved.s === undefined) {
|
||||
return { x: saved.x, y: saved.y };
|
||||
@@ -1080,11 +1148,11 @@ class HouseplanCard extends LitElement {
|
||||
const g = this._gridPitch;
|
||||
const gx = Math.round(x / g) * g;
|
||||
const gy = Math.round(y / g) * g;
|
||||
const aspect = this._serverCfg!.spaces.find((s: any) => s.id === d.space)?.aspect || 1;
|
||||
|
||||
const prevK = (this._layout[d.id] as any)?.k;
|
||||
this._layout = {
|
||||
...this._layout,
|
||||
[d.id]: { s: d.space, x: gx / NORM_W, y: gy / (NORM_W / aspect), ...(prevK ? { k: prevK } : {}) },
|
||||
[d.id]: { s: d.space, x: gx / NORM_W, y: gy / NORM_W, ...(prevK ? { k: prevK } : {}) },
|
||||
};
|
||||
} else {
|
||||
this._layout = { ...this._layout, [d.id]: { x: Math.round(x), y: Math.round(y) } };
|
||||
@@ -1196,10 +1264,31 @@ class HouseplanCard extends LitElement {
|
||||
return this.renderRoot.querySelector('.stage') as HTMLElement | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rectangle "fit to screen" fits. With a background image that is the
|
||||
* whole canvas — the image IS the plan. Without one it is what has been
|
||||
* drawn, plus a small margin, so a single room on a big canvas fills the
|
||||
* screen instead of sitting in it as a speck.
|
||||
*/
|
||||
private _baseVb(): number[] {
|
||||
const m = this._spaceModel();
|
||||
if (m.bg) return m.vb;
|
||||
// The EDITORS get the whole square: the content frame also bounds pan,
|
||||
// zoom and pointer maths, and inside it there is nowhere to draw the next
|
||||
// room or drag a marker away from the first one (HP-1490-03).
|
||||
if (this._mode !== 'view') return m.vb;
|
||||
// devices are content too — they may stand outside every room
|
||||
const pts = this._devices
|
||||
.filter((d) => d.space === m.id)
|
||||
.map((d) => { const p = this._pos(d); return [p.x, p.y] as const; });
|
||||
const b = contentBounds(m, 0.05, pts);
|
||||
return b ? [b.x, b.y, b.w, b.h] : m.vb;
|
||||
}
|
||||
|
||||
/** Aspect ratio of the scene (width/height, px). */
|
||||
private _stageAspect(): number {
|
||||
const s = this._stageEl;
|
||||
const vb = this._spaceModel().vb;
|
||||
const vb = this._baseVb();
|
||||
return s && s.clientHeight ? s.clientWidth / s.clientHeight : vb[2] / vb[3];
|
||||
}
|
||||
|
||||
@@ -1211,12 +1300,19 @@ class HouseplanCard extends LitElement {
|
||||
/** Screen (sx,sy relative to the scene, px) → vb coordinates per the current view. */
|
||||
private _screenToVb(sx: number, sy: number): number[] {
|
||||
const s = this._stageEl;
|
||||
const v = this._viewOr(this._spaceModel().vb);
|
||||
const v = this._viewOr(this._baseVb());
|
||||
const w = s?.clientWidth || 1, h = s?.clientHeight || 1;
|
||||
return [v.x + (sx / w) * v.w, v.y + (sy / h) * v.h];
|
||||
}
|
||||
|
||||
/** Clamp the view to the fit bounds (the content always covers the scene). */
|
||||
/** Zoom limits: 8× in, 0.4× out (zoomed out, the plan floats centred). */
|
||||
private static readonly ZOOM_MAX = 8;
|
||||
private static readonly ZOOM_MIN = 0.4;
|
||||
|
||||
/** Clamp the view to the fit bounds (the content always covers the scene).
|
||||
* Zoomed OUT the view is larger than the content on an axis — then there is
|
||||
* nothing to clamp against and the content is centred on that axis instead
|
||||
* of being pinned to a corner. */
|
||||
private _clampView(
|
||||
v: { x: number; y: number; w: number; h: number },
|
||||
fit: { x: number; y: number; w: number; h: number },
|
||||
@@ -1224,16 +1320,20 @@ class HouseplanCard extends LitElement {
|
||||
return {
|
||||
w: v.w,
|
||||
h: v.h,
|
||||
x: Math.max(fit.x, Math.min(fit.x + fit.w - v.w, v.x)),
|
||||
y: Math.max(fit.y, Math.min(fit.y + fit.h - v.h, v.y)),
|
||||
x: v.w >= fit.w
|
||||
? fit.x + (fit.w - v.w) / 2
|
||||
: Math.max(fit.x, Math.min(fit.x + fit.w - v.w, v.x)),
|
||||
y: v.h >= fit.h
|
||||
? fit.y + (fit.h - v.h) / 2
|
||||
: Math.max(fit.y, Math.min(fit.y + fit.h - v.h, v.y)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Set the zoom (centered on vb point cx,cy, or on the center of the current view). */
|
||||
private _applyView(zoom: number, cx?: number, cy?: number): void {
|
||||
const vb = this._spaceModel().vb;
|
||||
const vb = this._baseVb();
|
||||
const fit = fitView(vb, this._stageAspect());
|
||||
const z = Math.min(8, Math.max(1, zoom));
|
||||
const z = Math.min(HouseplanCard.ZOOM_MAX, Math.max(HouseplanCard.ZOOM_MIN, zoom));
|
||||
const w = fit.w / z, h = fit.h / z;
|
||||
const cur = this._viewOr(vb);
|
||||
const ccx = cx ?? cur.x + cur.w / 2;
|
||||
@@ -1254,9 +1354,9 @@ class HouseplanCard extends LitElement {
|
||||
private _zoomAt(sx: number, sy: number, newZoom: number): void {
|
||||
const stage = this._stageEl;
|
||||
if (!stage) return;
|
||||
const vb = this._spaceModel().vb;
|
||||
const vb = this._baseVb();
|
||||
const fit = fitView(vb, this._stageAspect());
|
||||
const z = Math.min(8, Math.max(1, newZoom));
|
||||
const z = Math.min(HouseplanCard.ZOOM_MAX, Math.max(HouseplanCard.ZOOM_MIN, newZoom));
|
||||
const w = stage.clientWidth, h = stage.clientHeight;
|
||||
const pt = this._screenToVb(sx, sy);
|
||||
const nw = fit.w / z, nh = fit.h / z;
|
||||
@@ -1282,7 +1382,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _resetZoom(): void {
|
||||
const vb = this._spaceModel().vb;
|
||||
const vb = this._baseVb();
|
||||
this._zoom = 1;
|
||||
this._view = fitView(vb, this._stageAspect());
|
||||
this._saveZoom();
|
||||
@@ -1305,7 +1405,7 @@ class HouseplanCard extends LitElement {
|
||||
this._view = null;
|
||||
requestAnimationFrame(() => {
|
||||
if (!this._stageEl) return;
|
||||
const vb = this._spaceModel().vb;
|
||||
const vb = this._baseVb();
|
||||
this._applyView(z, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
|
||||
this.requestUpdate();
|
||||
});
|
||||
@@ -1334,7 +1434,7 @@ class HouseplanCard extends LitElement {
|
||||
if (this._mode === 'devices' && (ev.target as HTMLElement).closest('.dev')) return;
|
||||
if (this._mode === 'decor' && this._decorPointerDown(ev)) return;
|
||||
this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
|
||||
const v = this._viewOr(this._spaceModel().vb);
|
||||
const v = this._viewOr(this._baseVb());
|
||||
if (this._pointers.size === 1) {
|
||||
this._panStart = { sx: ev.clientX, sy: ev.clientY, vx: v.x, vy: v.y };
|
||||
this._suppressClick = false;
|
||||
@@ -1380,7 +1480,7 @@ class HouseplanCard extends LitElement {
|
||||
if (this._zoom > 1 && this._view) {
|
||||
const stage = this._stageEl!;
|
||||
const v = this._view;
|
||||
const fit = fitView(this._spaceModel().vb, this._stageAspect());
|
||||
const fit = fitView(this._baseVb(), this._stageAspect());
|
||||
this._view = this._clampView(
|
||||
{
|
||||
x: this._panStart.vx - (ddx / stage.clientWidth) * v.w,
|
||||
@@ -1410,9 +1510,9 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
const target = swipeTarget(dx, dy, this._zoom, this._model.map((m) => m.id), this._space);
|
||||
if (target) {
|
||||
this._space = target;
|
||||
this._selId = null;
|
||||
this._restoreZoom();
|
||||
// the plan follows the finger: swiping left brings the next one in
|
||||
// from the right, so the current one leaves to the left
|
||||
this._slideTo(target, dx < 0 ? 'left' : 'right');
|
||||
this._saveNav();
|
||||
this._suppressClick = true;
|
||||
setTimeout(() => (this._suppressClick = false), 0);
|
||||
@@ -1467,7 +1567,7 @@ class HouseplanCard extends LitElement {
|
||||
if (!this._drag || this._drag.id !== d.id) return;
|
||||
const stage = this.renderRoot.querySelector('.stage') as HTMLElement;
|
||||
if (!stage) return;
|
||||
const vb = this._spaceModel().vb;
|
||||
const vb = this._baseVb();
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const v = this._viewOr(vb);
|
||||
const dx = ((ev.clientX - this._drag.sx) / rect.width) * v.w;
|
||||
@@ -1558,7 +1658,7 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
private get _spaceH(): number {
|
||||
const sp = this._curSpaceCfg;
|
||||
return sp ? NORM_W / sp.aspect : NORM_W;
|
||||
return NORM_W; // square canvas
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1595,7 +1695,15 @@ class HouseplanCard extends LitElement {
|
||||
this._showToast(this._t('toast.markup_needs_server'));
|
||||
return;
|
||||
}
|
||||
const baseChanges = !this._spaceModel().bg && (mode === 'view') !== (this._mode === 'view');
|
||||
this._mode = mode;
|
||||
if (baseChanges) {
|
||||
// refit against the new base: the editors measure from the full square,
|
||||
// the view from the content frame — a view clamped to one is nonsense
|
||||
// against the other (HP-1490-03)
|
||||
this._zoom = 1;
|
||||
this._view = null; // updated() refits on the next frame
|
||||
}
|
||||
this._path = [];
|
||||
this._cursorPt = null;
|
||||
this._tool = 'draw';
|
||||
@@ -1818,7 +1926,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private get _decorH(): number {
|
||||
return NORM_W / (this._curSpaceCfg?.aspect || 1);
|
||||
return NORM_W;
|
||||
}
|
||||
|
||||
/** Begin a decor gesture. Returns true when the event is consumed (no pan). */
|
||||
@@ -2102,7 +2210,7 @@ class HouseplanCard extends LitElement {
|
||||
//
|
||||
// The key is the SPACE MODEL OBJECT ITSELF (HP-1454-04). It used to be a
|
||||
// string of room ids and open_to links, which said nothing about geometry:
|
||||
// change the space's aspect, or drag a vertex, and the shared segments were
|
||||
// change the plan, or drag a vertex, and the shared segments were
|
||||
// recomputed for the outlines but the open boundaries — and the glow cuts
|
||||
// that follow them — kept their old coordinates until a full reload.
|
||||
// `_model` is already rebuilt whenever the epoch or the config fingerprint
|
||||
@@ -2531,8 +2639,7 @@ class HouseplanCard extends LitElement {
|
||||
// so that icons do not get reshuffled when the order in the HA registry changes.
|
||||
let added = 0;
|
||||
if (boundArea) {
|
||||
const aspect = this._serverCfg?.spaces.find((x: any) => x.id === this._space)?.aspect || 1;
|
||||
const H2 = NORM_W / aspect;
|
||||
const H2 = NORM_W;
|
||||
const next = { ...this._layout };
|
||||
for (const d of this._devices) {
|
||||
if (d.area !== boundArea || d.space !== this._space) continue;
|
||||
@@ -2987,8 +3094,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _normPos(space: string, x: number, y: number): { s: string; x: number; y: number } {
|
||||
const aspect = this._serverCfg!.spaces.find((s: any) => s.id === space)?.aspect || 1;
|
||||
return { s: space, x: x / NORM_W, y: y / (NORM_W / aspect) };
|
||||
return { s: space, x: x / NORM_W, y: y / NORM_W };
|
||||
}
|
||||
|
||||
// ================= SPACE MANAGEMENT =================
|
||||
@@ -3004,7 +3110,7 @@ class HouseplanCard extends LitElement {
|
||||
const disp = spaceDisplayOf(sp);
|
||||
this._spaceDialog = {
|
||||
mode, spaceId, title: sp.title, planUrl: sp.plan_url || null, planFile: null,
|
||||
source: sp.plan_url ? 'file' : 'draw', orientation: 'landscape',
|
||||
source: sp.plan_url ? 'file' : 'draw',
|
||||
showBorders: disp.showBorders, showNames: disp.showNames,
|
||||
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill,
|
||||
tempMin: disp.tempMin, tempMax: disp.tempMax,
|
||||
@@ -3018,7 +3124,7 @@ class HouseplanCard extends LitElement {
|
||||
} else {
|
||||
this._spaceDialog = {
|
||||
mode, title: '', planUrl: null, planFile: null,
|
||||
source: 'file', orientation: 'landscape',
|
||||
source: 'file',
|
||||
showBorders: false, showNames: false,
|
||||
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
|
||||
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
|
||||
@@ -3060,6 +3166,117 @@ class HouseplanCard extends LitElement {
|
||||
this._spaceDialog = { ...this._spaceDialog, planFile: { ext, b64, aspect, name: file.name } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Plans that are on the server but not attached anywhere are not garbage —
|
||||
* the component never deletes them (docs/SCOPE.md), which only makes sense if
|
||||
* they can be found again. This is that: detach a plan, come back later, pick
|
||||
* it out of the list. It is also the only way one is ever deleted.
|
||||
*/
|
||||
private _toggleServerPlans = async (): Promise<void> => {
|
||||
const d = this._spaceDialog;
|
||||
if (!d) return;
|
||||
if (d.pickSaved) {
|
||||
this._spaceDialog = { ...d, pickSaved: false };
|
||||
return;
|
||||
}
|
||||
this._spaceDialog = { ...d, pickSaved: true, savedBusy: true };
|
||||
try {
|
||||
const r: any = await this.hass.callWS({ type: 'houseplan/plans/list' });
|
||||
const cur = this._spaceDialog;
|
||||
if (cur) this._spaceDialog = { ...cur, saved: r?.plans || [], savedBusy: false };
|
||||
} catch (e: any) {
|
||||
const cur = this._spaceDialog;
|
||||
if (cur) this._spaceDialog = { ...cur, saved: [], savedBusy: false };
|
||||
this._showToast(this._t('toast.plans_list_failed', { err: this._errText(e) }));
|
||||
}
|
||||
};
|
||||
|
||||
/** The in-flight proportions read for the last picked saved plan. Save
|
||||
* awaits it rather than shipping whatever was there before (HP-1490-04). */
|
||||
private _aspectJob: Promise<number> | null = null;
|
||||
|
||||
private _useServerPlan(url: string): void {
|
||||
const d = this._spaceDialog;
|
||||
if (!d) return;
|
||||
// Attach immediately — the click should not wait for anything. The OLD
|
||||
// file's proportions go right away: they describe the previous image, and
|
||||
// a Save racing the read must get "unknown", never "the wrong shape".
|
||||
this._spaceDialog = { ...d, planUrl: url, planFile: null, pickSaved: false, savedAspect: undefined };
|
||||
this._aspectJob = this._readPlanAspect(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a stored plan's proportions from the image itself.
|
||||
*
|
||||
* The content endpoint needs a signature, and `_display()` deliberately
|
||||
* returns nothing until one arrives. Loading too early therefore failed and
|
||||
* an earlier version treated that as "unknown ratio" and saved a fallback of
|
||||
* 1.414 — a square plan came out stretched (HP-1470-03). So wait for the
|
||||
* signature, and bind the result to THIS dialog and THIS url, or a late
|
||||
* answer would reshape whatever the user opened next.
|
||||
*/
|
||||
private async _readPlanAspect(url: string): Promise<number> {
|
||||
for (let i = 0; i < 40; i++) { // ~6 s, then give up quietly
|
||||
const src = this._display(url);
|
||||
if (src) {
|
||||
const ratio = await new Promise<number>((res) => {
|
||||
const img = new Image();
|
||||
img.onload = () => res(img.naturalWidth && img.naturalHeight
|
||||
? img.naturalWidth / img.naturalHeight : 0);
|
||||
img.onerror = () => res(0);
|
||||
img.src = src;
|
||||
});
|
||||
const cur = this._spaceDialog;
|
||||
if (cur && cur.planUrl === url && Number.isFinite(ratio) && ratio > 0) {
|
||||
this._spaceDialog = { ...cur, savedAspect: ratio };
|
||||
return ratio;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
if (this._spaceDialog?.planUrl !== url) return 0; // the user moved on
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private async _deleteServerPlan(name: string): Promise<void> {
|
||||
if (!confirm(this._t('confirm.delete_plan', { name }))) return;
|
||||
try {
|
||||
await this.hass.callWS({ type: 'houseplan/plans/delete', name });
|
||||
const d = this._spaceDialog;
|
||||
if (d?.saved) this._spaceDialog = { ...d, saved: d.saved.filter((p) => p.name !== name) };
|
||||
} catch (e: any) {
|
||||
this._showToast(this._t('toast.plan_delete_failed', { err: this._errText(e) }));
|
||||
}
|
||||
}
|
||||
|
||||
private _renderServerPlans(d: NonNullable<typeof this._spaceDialog>): TemplateResult {
|
||||
if (d.savedBusy) return html`<div class="savedplans muted">${this._t('space.loading')}</div>`;
|
||||
const list = d.saved || [];
|
||||
if (!list.length) return html`<div class="savedplans muted">${this._t('space.no_saved')}</div>`;
|
||||
const kb = (n: number) => (n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : Math.round(n / 1024) + ' KB');
|
||||
return html`<div class="savedplans">
|
||||
${list.map((p) => html`
|
||||
<div class="savedplan ${p.url === d.planUrl ? 'cur' : ''}">
|
||||
<img src=${this._display(p.url)} alt="" loading="lazy" decoding="async" />
|
||||
<div class="savedmeta">
|
||||
<b>${p.name}</b>
|
||||
<span class="muted">${kb(p.size)}${p.used_by.length
|
||||
? ' · ' + this._t('space.used_by', { list: p.used_by.join(', ') })
|
||||
: ''}</span>
|
||||
</div>
|
||||
<button class="btn ghost" @click=${() => this._useServerPlan(p.url)}
|
||||
?disabled=${p.url === d.planUrl}>${this._t('btn.use')}</button>
|
||||
<button class="btn ghost danger"
|
||||
title=${p.used_by.length || p.url === d.planUrl ? this._t('space.in_use') : this._t('btn.delete')}
|
||||
?disabled=${p.used_by.length > 0 || p.url === d.planUrl}
|
||||
@click=${() => this._deleteServerPlan(p.name)}>
|
||||
<ha-icon icon="mdi:trash-can-outline"></ha-icon>
|
||||
</button>
|
||||
</div>`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private async _saveSpaceDialog(): Promise<void> {
|
||||
const d = this._spaceDialog;
|
||||
if (!d || d.busy || !d.title.trim()) return;
|
||||
@@ -3070,7 +3287,6 @@ class HouseplanCard extends LitElement {
|
||||
const wasFirst = d.mode === 'create' && (this._serverCfg?.spaces.length || 0) === 0;
|
||||
this._spaceDialog = { ...d, busy: true };
|
||||
try {
|
||||
const drawAspect = d.orientation === 'portrait' ? 0.707 : d.orientation === 'square' ? 1 : 1.414;
|
||||
const spaceId = d.mode === 'create' ? 's' + Date.now().toString(36) : d.spaceId!;
|
||||
|
||||
/* Upload BEFORE touching the config, and never hold a reference to a
|
||||
@@ -3088,6 +3304,17 @@ class HouseplanCard extends LitElement {
|
||||
uploaded = { url: resp.url, aspect: d.planFile.aspect };
|
||||
}
|
||||
|
||||
// A plan picked from the server list reads its proportions from the
|
||||
// image, and Save used to outrun that read: the snapshot still carried
|
||||
// the PREVIOUS file's ratio and a wide plan came out at the old shape
|
||||
// for good (HP-1490-04). Wait for the read — it is bounded (~6 s) and
|
||||
// the dialog is already busy. Unknown stays unknown, never the old
|
||||
// value: a square fallback is honest, an inherited ratio is not.
|
||||
let pickedAspect: number | null = d.savedAspect || null;
|
||||
if (!uploaded && d.source === 'file' && d.planUrl && !pickedAspect && this._aspectJob) {
|
||||
pickedAspect = (await this._aspectJob) || null;
|
||||
}
|
||||
|
||||
// from here on: no awaits until the save, so `sp` cannot be orphaned
|
||||
const cfg = this._serverCfg!;
|
||||
let sp: any;
|
||||
@@ -3096,7 +3323,7 @@ class HouseplanCard extends LitElement {
|
||||
id: spaceId,
|
||||
title: d.title.trim(),
|
||||
plan_url: null,
|
||||
aspect: d.source === 'draw' ? drawAspect : 1.414,
|
||||
|
||||
view_box: [0, 0, 1, 1],
|
||||
rooms: [],
|
||||
};
|
||||
@@ -3108,11 +3335,17 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
if (uploaded) {
|
||||
sp.plan_url = uploaded.url;
|
||||
sp.aspect = uploaded.aspect;
|
||||
// the image's own proportions, so it can be centred before it loads
|
||||
sp.plan_aspect = uploaded.aspect;
|
||||
} else if (d.source === 'file' && d.planUrl && d.planUrl !== sp.plan_url) {
|
||||
// picked from the server list: no upload, just a reference — and the
|
||||
// previous image's proportions never survive the switch
|
||||
sp.plan_url = d.planUrl;
|
||||
sp.plan_aspect = pickedAspect;
|
||||
}
|
||||
// switching an existing space to "draw" detaches its background image
|
||||
// (the uploaded file stays on disk; only the reference is cleared)
|
||||
if (d.source === 'draw') sp.plan_url = null;
|
||||
if (d.source === 'draw') { sp.plan_url = null; sp.plan_aspect = null; }
|
||||
// per-space display settings; hand-drawn spaces get borders+names on by default
|
||||
const draw = d.source === 'draw';
|
||||
sp.settings = {
|
||||
@@ -3224,7 +3457,7 @@ class HouseplanCard extends LitElement {
|
||||
if (title === undefined) return;
|
||||
this._spaceDialog = {
|
||||
mode: 'create', title, planUrl: null, planFile: null,
|
||||
source: 'file', orientation: 'landscape',
|
||||
source: 'file',
|
||||
showBorders: false, showNames: false,
|
||||
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
|
||||
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
|
||||
@@ -3730,14 +3963,14 @@ class HouseplanCard extends LitElement {
|
||||
</div>
|
||||
|
||||
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + (this._tool === 'openwall' && this._openWallHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}"
|
||||
style="height:${this._kiosk ? '100dvh' : 'calc(100dvh - 118px)'}"
|
||||
style="height:${this._kiosk ? '100dvh' : `calc(100dvh - ${this._hdrH}px)`}"
|
||||
@click=${(e: MouseEvent) => this._markupClick(e)}
|
||||
@wheel=${(e: WheelEvent) => this._onWheel(e)}
|
||||
@pointerdown=${(e: PointerEvent) => { this._notePointer(e); this._stagePointerDown(e); }}
|
||||
@pointermove=${(e: PointerEvent) => this._stagePointerMove(e)}
|
||||
@pointerup=${(e: PointerEvent) => this._stagePointerUp(e)}
|
||||
@pointercancel=${(e: PointerEvent) => this._stagePointerUp(e)}>
|
||||
<div class="zoomwrap">
|
||||
<div class="zoomwrap ${this._slide ? 'slide-' + this._slide : ''}">
|
||||
<svg viewBox="${view.x} ${view.y} ${view.w} ${view.h}" preserveAspectRatio="xMidYMid meet">
|
||||
${this._editing ? this._renderMarkupDefs(vb) : nothing}
|
||||
${this._editing && !this._markup
|
||||
@@ -4099,8 +4332,7 @@ class HouseplanCard extends LitElement {
|
||||
private _labelPos(r: RoomCfg, spaceId: string): { x: number; y: number } {
|
||||
const saved = this._layout['rl_' + (r.id || '')];
|
||||
if (saved && saved.s === spaceId) {
|
||||
const aspect = this._serverCfg!.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 = this._roomCenter(r);
|
||||
return { x: c[0], y: c[1] };
|
||||
@@ -4176,10 +4408,10 @@ class HouseplanCard extends LitElement {
|
||||
const room = sp.rooms.find((x) => x.id === roomId);
|
||||
if (!room) return;
|
||||
const p = this._labelPos(room, rs.space);
|
||||
const aspect = this._serverCfg!.spaces.find((x: any) => x.id === rs.space)?.aspect || 1;
|
||||
|
||||
this._layout = {
|
||||
...this._layout,
|
||||
[rs.id]: { s: rs.space, x: p.x / NORM_W, y: p.y / (NORM_W / aspect), k },
|
||||
[rs.id]: { s: rs.space, x: p.x / NORM_W, y: p.y / NORM_W, k },
|
||||
};
|
||||
} else {
|
||||
this._layout = { ...this._layout, [rs.id]: { ...rec, k } };
|
||||
@@ -4237,20 +4469,21 @@ class HouseplanCard extends LitElement {
|
||||
@pointermove=${(e: PointerEvent) => this._labelMove(e, r, space.id)}
|
||||
@pointerup=${() => this._labelUp(r)}
|
||||
@pointercancel=${() => this._labelUp(r)}
|
||||
>${this._markup && r.id
|
||||
? html`<button class="rlgearbtn" title=${this._t('room.settings_title')}
|
||||
@pointerdown=${(e: Event) => e.stopPropagation()}
|
||||
@click=${(e: Event) => { e.stopPropagation(); this._openRoomEdit(r); }}>
|
||||
<ha-icon icon="mdi:cog-outline"></ha-icon>
|
||||
<span class="rlgeartext">${this._t('room.settings_short')}</span>
|
||||
</button>`
|
||||
: nothing}<span class="rlname">${r.name || (this._markup ? this._t('room.unnamed') : '')}${!this._markup && r.area
|
||||
><span class="rlname">${r.name || (this._markup ? this._t('room.unnamed') : '')}${!this._markup && r.area
|
||||
? html`<ha-icon class="rlgo" icon="mdi:open-in-new"
|
||||
title=${this._t('room.open_area')}
|
||||
@click=${(e: Event) => { e.stopPropagation(); this._clickRoom(r); }}
|
||||
@pointerdown=${(e: Event) => e.stopPropagation()}></ha-icon>`
|
||||
: nothing}</span>
|
||||
${rows.length ? html`<span class="rlmetrics">${rows}</span>` : nothing}
|
||||
${this._markup && r.id
|
||||
? html`<button class="rlgearbtn" title=${this._t('room.settings_title')}
|
||||
@pointerdown=${(e: Event) => e.stopPropagation()}
|
||||
@click=${(e: Event) => { e.stopPropagation(); this._openRoomEdit(r); }}>
|
||||
<ha-icon icon="mdi:cog-outline"></ha-icon>
|
||||
<span class="rlgeartext">${this._t('room.settings_short')}</span>
|
||||
</button>`
|
||||
: nothing}
|
||||
${this._mode === 'plan'
|
||||
? ['tl', 'tr', 'bl', 'br'].map(
|
||||
(c) => html`<span class="rlhandle ${c}"
|
||||
@@ -5128,29 +5361,25 @@ class HouseplanCard extends LitElement {
|
||||
${d.planFile
|
||||
? html`<span class="planname">${d.planFile.name}</span>`
|
||||
: d.planUrl
|
||||
? html`<img class="planprev" src=${d.planUrl} alt=${this._t('space.plan_alt')} />`
|
||||
? html`<img class="planprev" src=${this._display(d.planUrl)} alt=${this._t('space.plan_alt')} />`
|
||||
: html`<span class="planname muted">${this._t('space.no_plan')}</span>`}
|
||||
<label class="btn filebtn">
|
||||
<ha-icon icon="mdi:upload"></ha-icon>${d.planUrl || d.planFile ? this._t('btn.replace') : this._t('btn.upload')}
|
||||
<input type="file" hidden accept=".svg,.png,.jpg,.jpeg,.webp,image/svg+xml,image/png,image/jpeg,image/webp"
|
||||
@change=${(e: Event) => this._pickPlanFile(e)} />
|
||||
</label>
|
||||
</div>`
|
||||
<button class="btn ghost" @click=${this._toggleServerPlans}
|
||||
title=${this._t('space.pick_saved_hint')}>
|
||||
<ha-icon icon="mdi:folder-image"></ha-icon>${this._t('space.pick_saved')}
|
||||
</button>
|
||||
</div>
|
||||
${d.pickSaved ? this._renderServerPlans(d) : nothing}`
|
||||
: nothing}
|
||||
<label class="srcrow">
|
||||
<input type="radio" name="plansrc" .checked=${d.source === 'draw'}
|
||||
@change=${() => (this._spaceDialog = { ...d, source: 'draw' })} />
|
||||
<span>${this._t('space.source_draw')}</span>
|
||||
</label>
|
||||
${d.source === 'draw' && d.mode === 'create'
|
||||
? html`<label>${this._t('space.orientation')}</label>
|
||||
<select class="areasel"
|
||||
@change=${(e: Event) => (this._spaceDialog = { ...d, orientation: (e.target as HTMLSelectElement).value as any })}>
|
||||
${[['landscape', 'orient.landscape'], ['portrait', 'orient.portrait'], ['square', 'orient.square']].map(
|
||||
([v, k]) => html`<option value=${v} ?selected=${d.orientation === v}>${this._t(k as any)}</option>`,
|
||||
)}
|
||||
</select>`
|
||||
: nothing}
|
||||
|
||||
<label>${this._t('space.scale_label')}</label>
|
||||
<div class="colorrow">
|
||||
|
||||
+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}"
|
||||
}
|
||||
|
||||
+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',
|
||||
|
||||
+109
-11
@@ -6,17 +6,59 @@
|
||||
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. */
|
||||
/** A stored view_box the render can trust: 4 finite numbers, positive sizes.
|
||||
* The server refuses anything else NOW (HP-1502-01), but a store may already
|
||||
* hold [0,0,0,0] or negative sizes from before — and a zero axis serialises
|
||||
* into viewBox="0 0 0 0", which draws nothing on every client. Bad input
|
||||
* falls back to the whole canvas rather than to a blank screen. */
|
||||
function safeViewBox(vb: any): [number, number, number, number] {
|
||||
if (
|
||||
Array.isArray(vb) && vb.length === 4 && vb.every((n: any) => Number.isFinite(n))
|
||||
&& vb[2] > 1e-6 && vb[3] > 1e-6
|
||||
) return vb as [number, number, number, number];
|
||||
return [0, 0, 1, 1];
|
||||
}
|
||||
|
||||
/** Legacy rectangle rooms, normalised: a negative size is the same rectangle
|
||||
* drawn from the other corner; the maths downstream assumes w/h >= 0. */
|
||||
function normRect(r: any): { x?: number; y?: number; w?: number; h?: number } {
|
||||
if (r.x == null || r.y == null) return { x: r.x, y: r.y, w: r.w, h: r.h };
|
||||
const w = Number(r.w) || 0, h = Number(r.h) || 0;
|
||||
return {
|
||||
x: w < 0 ? r.x + w : r.x,
|
||||
y: h < 0 ? r.y + h : r.y,
|
||||
w: Math.abs(w),
|
||||
h: Math.abs(h),
|
||||
};
|
||||
}
|
||||
|
||||
export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
|
||||
if (!cfg || !Array.isArray(cfg.spaces)) return [];
|
||||
return cfg.spaces.map((s: any) => {
|
||||
const H = NORM_W / s.aspect;
|
||||
const scale = (r: any): RoomCfg => ({
|
||||
const H = NORM_W; // square canvas
|
||||
const scale = (raw: any): RoomCfg => { const r = { ...raw, ...normRect(raw) }; return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
area: r.area ?? null,
|
||||
@@ -30,17 +72,75 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
|
||||
w: r.w != null ? r.w * NORM_W : undefined,
|
||||
h: r.h != null ? r.h * H : undefined,
|
||||
poly: r.poly ? r.poly.map((p: number[]) => [p[0] * NORM_W, p[1] * H]) : undefined,
|
||||
});
|
||||
}; };
|
||||
const vb = safeViewBox(s.view_box);
|
||||
return {
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
|
||||
bg: s.plan_url ? { href: contentUrl(s.plan_url), x: 0, y: 0, w: NORM_W, h: H } : null,
|
||||
vb: [vb[0] * NORM_W, vb[1] * H, vb[2] * NORM_W, vb[3] * H],
|
||||
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;
|
||||
// Only points within a bounded envelope around the canvas command the
|
||||
// opening view. The server bounds what it ACCEPTS now (±4 normalised,
|
||||
// HP-1501-01), but a store may already hold an absurd coordinate from
|
||||
// before that door existed — and one 1e100 vertex framed the space so wide
|
||||
// the plan was a dot for every client. An out-of-envelope point is still
|
||||
// rendered wherever it is; it just does not decide the frame. This applies
|
||||
// to ROOM GEOMETRY and device positions alike (HP-1500-03, HP-1501-01).
|
||||
const lo = -NORM_W * 0.25, hi = NORM_W * 1.25;
|
||||
const add = (x: number, y: number) => {
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||||
if (x < lo || x > hi || y < lo || y > hi) 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;
|
||||
// A single marker (or a collinear row of them) has no area, and an SVG
|
||||
// viewBox with a zero axis draws nothing at all (HP-1500-03). An axis with
|
||||
// essentially no span — nothing there but icons — opens up to a floor:
|
||||
// enough canvas around a lone marker to see where it stands. A REAL thin
|
||||
// shape (a 100-unit corridor) keeps its tight frame; only the degenerate
|
||||
// case is padded, so the threshold sits at about an icon's size.
|
||||
const FLOOR = NORM_W * 0.2;
|
||||
const DEGENERATE = NORM_W * 0.03;
|
||||
if (maxX - minX < DEGENERATE) { const c = (minX + maxX) / 2; minX = c - FLOOR / 2; maxX = c + FLOOR / 2; }
|
||||
if (maxY - minY < DEGENERATE) { const c = (minY + maxY) / 2; minY = c - FLOOR / 2; maxY = c + FLOOR / 2; }
|
||||
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) {
|
||||
@@ -91,8 +191,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;
|
||||
@@ -103,8 +202,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] };
|
||||
|
||||
+65
-2
@@ -383,11 +383,34 @@ 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; }
|
||||
}
|
||||
/* The name is the anchor: the label box is centred on the room point, so
|
||||
anything that takes part in its layout SHIFTS THE NAME. The gear button
|
||||
and the metrics hang below as absolutes — the name renders in exactly
|
||||
the same place in view mode and in the plan editor (owner's request),
|
||||
and the button sits at the very bottom of the card. */
|
||||
.rlgearbtn {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.35em);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 3px;
|
||||
padding: 3px 8px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
@@ -402,7 +425,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;
|
||||
@@ -424,6 +448,11 @@ export const cardStyles = css`
|
||||
}
|
||||
.stage.mode-view .rlgo:hover { opacity: 1; }
|
||||
.roomlabel .rlmetrics {
|
||||
position: absolute; /* below the name, outside the centring math */
|
||||
top: calc(100% + 0.15em);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55em;
|
||||
@@ -1062,6 +1091,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;
|
||||
|
||||
+125
-12
@@ -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,103 @@ 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));
|
||||
});
|
||||
|
||||
test('contentBounds: never degenerate, never unbounded (HP-1500-03)', () => {
|
||||
const empty = spaceModels({ spaces: [{ id: 's', view_box: [0, 0, 1, 1], rooms: [] }], markers: [] })[0];
|
||||
// a single marker has no area — the frame gets a floor instead of a 0x0 viewBox
|
||||
const one = contentBounds(empty, 0.05, [[500, 500]]);
|
||||
assert.ok(one.w >= 200 && one.h >= 200, 'a lone marker still frames some canvas');
|
||||
assert.ok(Math.abs(one.x + one.w / 2 - 500) < 1, 'centred on the marker');
|
||||
// a collinear row: the flat axis gets the floor, the long one keeps its span
|
||||
const row = contentBounds(empty, 0.05, [[100, 500], [900, 500]]);
|
||||
assert.ok(row.h >= 200, 'the flat axis is opened up');
|
||||
assert.ok(row.w > 800, 'the long axis is untouched');
|
||||
// an absurd stored coordinate does not command the frame...
|
||||
const room = 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];
|
||||
assert.deepEqual(contentBounds(room, 0.05, [[1e100, 500]]), contentBounds(room));
|
||||
assert.deepEqual(contentBounds(room, 0.05, [[-1e100, -1e100]]), contentBounds(room));
|
||||
// ...but a device a bit past the canvas edge still counts (the gate sensor)
|
||||
const near = contentBounds(room, 0.05, [[1100, 500]]);
|
||||
assert.ok(near.x + near.w > 1050, 'slightly outside the canvas still stretches the frame');
|
||||
|
||||
// HP-1501-01: the same envelope guards ROOM GEOMETRY — the server refuses
|
||||
// absurd vertices now, but a store may hold one from before that door
|
||||
// existed, and a legacy 1e100 vertex must not frame the plan into a dot
|
||||
const legacy = spaceModels({ spaces: [{
|
||||
id: 's', view_box: [0, 0, 1, 1],
|
||||
rooms: [
|
||||
{ id: 'ok', poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] },
|
||||
{ id: 'bad', poly: [[0, 0], [1e100, 0], [1, 1]] },
|
||||
],
|
||||
}], markers: [] })[0];
|
||||
const lb = contentBounds(legacy);
|
||||
assert.ok(lb.w < 1500 && lb.h < 1500, 'the absurd vertex does not command the frame');
|
||||
assert.ok(lb.x <= 400 && lb.x + lb.w >= 600, 'the sane room is still inside it');
|
||||
// a space where EVERY point is absurd falls back to the whole canvas
|
||||
const allBad = spaceModels({ spaces: [{
|
||||
id: 's', view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'b', poly: [[1e100, 1e100], [2e100, 1e100], [2e100, 2e100]] }],
|
||||
}], markers: [] })[0];
|
||||
assert.equal(contentBounds(allBad), null, 'the caller keeps the full canvas');
|
||||
});
|
||||
|
||||
test('spaceModels: a stored broken view_box falls back to the canvas (HP-1502-01)', () => {
|
||||
// the server refuses these now, but a store may already hold one
|
||||
for (const vb of [[0, 0, 0, 0], [0, 0, -1, -2], [0, 0, 1], null, [0, 0, NaN, 1]]) {
|
||||
const m = spaceModels({ spaces: [{ id: 's', view_box: vb, rooms: [] }], markers: [] })[0];
|
||||
assert.deepEqual(m.vb, [0, 0, 1000, 1000], JSON.stringify(vb) + ' falls back');
|
||||
}
|
||||
// a legitimate crop viewport is preserved
|
||||
const crop = spaceModels({ spaces: [{ id: 's', view_box: [0.1, 0.2, 0.5, 0.4], rooms: [] }], markers: [] })[0];
|
||||
assert.deepEqual(crop.vb, [100, 200, 500, 400]);
|
||||
// a legacy rect with a negative size is the same rectangle from the other corner
|
||||
const m = spaceModels({ spaces: [{
|
||||
id: 's', view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'r', x: 0.6, y: 0.7, w: -0.2, h: -0.3 }],
|
||||
}], markers: [] })[0];
|
||||
const r = m.rooms[0];
|
||||
assert.deepEqual([r.x, r.y, r.w, r.h].map(Math.round), [400, 400, 200, 300]);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,35 +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))
|
||||
# r3 still HAS a plan, so this really is a rejected upload — collectable
|
||||
_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(
|
||||
@@ -776,8 +784,22 @@ async def test_repair_issue_goes_when_its_space_does(
|
||||
client = await hass_ws_client(hass)
|
||||
registry = ir.async_get(hass)
|
||||
|
||||
gone = "/api/houseplan/content/plans/_/nosuchfile.png"
|
||||
rev = (await _save(client, await _cfg([{"id": "r7", "plan_url": gone}]), 0))["result"]["rev"]
|
||||
# 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
|
||||
|
||||
@@ -887,7 +909,9 @@ def _paths(hass):
|
||||
"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"),
|
||||
"orphan_plan": os.path.join(plans, "deleted_space.orphan.png"),
|
||||
# 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"),
|
||||
}
|
||||
|
||||
|
||||
@@ -905,8 +929,12 @@ async def _assert_swept(hass, p) -> None:
|
||||
|
||||
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"])
|
||||
assert not await hass.async_add_executor_job(os.path.isfile, p["orphan_plan"])
|
||||
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(
|
||||
@@ -926,6 +954,9 @@ async def test_startup_sweep_collects_what_no_commit_will(
|
||||
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()))
|
||||
@@ -953,6 +984,9 @@ async def test_periodic_sweep_collects_too(
|
||||
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()))
|
||||
@@ -980,6 +1014,9 @@ async def test_sweep_and_a_config_write_do_not_race(
|
||||
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
|
||||
@@ -989,16 +1026,394 @@ async def test_sweep_and_a_config_write_do_not_race(
|
||||
cfg2 = await _referenced_config()
|
||||
cfg2["spaces"][0]["plan_url"] = "/api/houseplan/content/plans/_/s5.newcomer.png"
|
||||
|
||||
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
await asyncio.gather(
|
||||
hass.config_entries.async_reload(entry.entry_id), # runs the sweep
|
||||
_save(client, cfg2, rev),
|
||||
)
|
||||
# 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"]
|
||||
referenced = stored["spaces"][0]["plan_url"].rsplit("/", 1)[-1]
|
||||
assert stored["spaces"][0]["plan_url"].endswith("s5.newcomer.png")
|
||||
assert await hass.async_add_executor_job(
|
||||
os.path.isfile, os.path.join(plans, referenced)
|
||||
), f"the accepted config points at {referenced}, which must exist"
|
||||
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"
|
||||
|
||||
|
||||
async def test_layout_coordinates_are_bounded(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""HP-1500-03: any finite float used to pass, and one stored 1e100
|
||||
stretched every client's frame until the plan was invisible. Positions are
|
||||
normalised to the canvas; +-4 is generous slack for an icon dragged past an
|
||||
edge, not an envelope for absurdity."""
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/layout/update", "device_id": "d1",
|
||||
"pos": {"s": "f1", "x": 1e100, "y": 0.5},
|
||||
})
|
||||
resp = await client.receive_json()
|
||||
assert not resp["success"], "an absurd coordinate is refused at the door"
|
||||
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/layout/update", "device_id": "d1",
|
||||
"pos": {"s": "f1", "x": -1.2, "y": 0.5},
|
||||
})
|
||||
resp = await client.receive_json()
|
||||
assert resp["success"], "a bit past the edge is a dragged icon, not an attack"
|
||||
|
||||
|
||||
async def test_geometry_repair_is_explicit_previewable_and_undoable(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""HP-1500-01: an install that crashed in the v1.48 migration window has a
|
||||
square config and an old-coordinates layout, and NOTHING left to tell —
|
||||
both triggers went with the write that succeeded. No automation can fix
|
||||
that safely (a correct layout looks the same), so the repair is a person
|
||||
saying "this space, this old aspect": preview first, one-deep backup with
|
||||
the write, undo restores it."""
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
# the stranded state: nothing in the layout says it is old
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/layout/set",
|
||||
"layout": {"lamp": {"s": "wide", "x": 0.2, "y": 0.1},
|
||||
"other": {"s": "elsewhere", "x": 0.9, "y": 0.9}},
|
||||
})
|
||||
assert (await client.receive_json())["success"]
|
||||
|
||||
# preview does not touch the store
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/geometry/repair", "space_id": "wide", "aspect": 2.0, "dry_run": True,
|
||||
})
|
||||
dry = (await client.receive_json())["result"]
|
||||
assert dry["moved"] == 1 and dry["after"]["lamp"]["y"] == 0.3
|
||||
await client.send_json_auto_id({"type": "houseplan/layout/get"})
|
||||
assert (await client.receive_json())["result"]["layout"]["lamp"]["y"] == 0.1
|
||||
|
||||
# the repair itself
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/geometry/repair", "space_id": "wide", "aspect": 2.0,
|
||||
})
|
||||
res = (await client.receive_json())["result"]
|
||||
assert res["moved"] == 1
|
||||
await client.send_json_auto_id({"type": "houseplan/layout/get"})
|
||||
lay = (await client.receive_json())["result"]["layout"]
|
||||
assert lay["lamp"] == {"s": "wide", "x": 0.2, "y": 0.3}
|
||||
assert lay["other"] == {"s": "elsewhere", "x": 0.9, "y": 0.9}, "another space untouched"
|
||||
|
||||
# a routine drag must not eat the backup...
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/layout/update", "device_id": "other",
|
||||
"pos": {"s": "elsewhere", "x": 0.5, "y": 0.5},
|
||||
})
|
||||
assert (await client.receive_json())["success"]
|
||||
|
||||
# ...because undo is the safety net for repairing the WRONG space
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/geometry/repair", "space_id": "wide", "aspect": 2.0, "undo": True,
|
||||
})
|
||||
res = (await client.receive_json())["result"]
|
||||
assert res["restored"] == 1
|
||||
await client.send_json_auto_id({"type": "houseplan/layout/get"})
|
||||
lay = (await client.receive_json())["result"]["layout"]
|
||||
assert lay["lamp"] == {"s": "wide", "x": 0.2, "y": 0.1}, "back to before the repair"
|
||||
assert lay["other"]["x"] == 0.5, "the drag after the repair survives the undo"
|
||||
|
||||
# undo without a backup for that space is a clean error
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/geometry/repair", "space_id": "nosuch", "aspect": 2.0, "undo": True,
|
||||
})
|
||||
bad = await client.receive_json()
|
||||
assert not bad["success"] and bad["error"]["code"] == "no_backup"
|
||||
|
||||
|
||||
async def test_a_noop_repair_does_not_eat_the_backup(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""HP-1501-02: a repair that matches nothing used to answer moved: 0 and
|
||||
replace the one-deep backup with an empty one — a typo after a wrong
|
||||
repair destroyed the only way back. Now it is an error, the revision does
|
||||
not move, and the previous repair is still undoable."""
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/layout/set",
|
||||
"layout": {"lamp": {"s": "wide", "x": 0.2, "y": 0.1}},
|
||||
})
|
||||
assert (await client.receive_json())["success"]
|
||||
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/geometry/repair", "space_id": "wide", "aspect": 2.0,
|
||||
})
|
||||
rev = (await client.receive_json())["result"]["rev"]
|
||||
|
||||
# the typo: syntactically valid, matches nothing
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/geometry/repair", "space_id": "wid", "aspect": 2.0,
|
||||
})
|
||||
bad = await client.receive_json()
|
||||
assert not bad["success"] and bad["error"]["code"] == "nothing_to_repair"
|
||||
|
||||
await client.send_json_auto_id({"type": "houseplan/layout/get"})
|
||||
got = (await client.receive_json())["result"]
|
||||
assert got["rev"] == rev, "a refused repair moves nothing, including the revision"
|
||||
|
||||
# the wrong-space repair from before the typo is STILL undoable
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/geometry/repair", "space_id": "wide", "aspect": 2.0, "undo": True,
|
||||
})
|
||||
res = (await client.receive_json())["result"]
|
||||
assert res["restored"] == 1
|
||||
await client.send_json_auto_id({"type": "houseplan/layout/get"})
|
||||
lay = (await client.receive_json())["result"]["layout"]
|
||||
assert lay["lamp"] == {"s": "wide", "x": 0.2, "y": 0.1}
|
||||
|
||||
@@ -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]})
|
||||
|
||||
@@ -182,7 +190,7 @@ def test_collection_caps():
|
||||
|
||||
def test_finite_on_every_coordinate():
|
||||
"""audit follow-up B5: NaN/Infinity must be refused everywhere, not only in layout."""
|
||||
base = {"id": "s1", "title": "S", "aspect": 1.0, "view_box": [0, 0, 100, 100], "rooms": []}
|
||||
base = {"id": "s1", "title": "S", "aspect": 1.0, "view_box": [0, 0, 1, 1], "rooms": []}
|
||||
# view_box
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [{**base, "view_box": [0, 0, "NaN", 100]}]})
|
||||
@@ -203,13 +211,59 @@ def test_finite_on_every_coordinate():
|
||||
{"id": "r", "name": "R", "poly": [[0, 0], [1, 0], [1, 1]]}]}]})
|
||||
|
||||
|
||||
def test_geometry_magnitudes_are_bounded():
|
||||
"""HP-1501-01: any finite float used to pass, and one schema-valid 1e100
|
||||
room vertex made the space unviewable for every client — the exact failure
|
||||
HP-1500-03 closed for layout positions, one schema over. ±4 is slack for a
|
||||
vertex nudged past an edge, not an envelope for absurdity."""
|
||||
base = {"id": "s1", "title": "S", "view_box": [0, 0, 1, 1], "rooms": []}
|
||||
huge = 1e100
|
||||
for cfg in (
|
||||
{**base, "rooms": [{"id": "r", "name": "R", "poly": [[0, 0], [huge, 0], [1, 1]]}]},
|
||||
{**base, "rooms": [{"id": "r", "name": "R", "poly": [[0, 0], [-huge, 0], [1, 1]]}]},
|
||||
{**base, "rooms": [{"id": "r", "name": "R", "x": huge, "y": 0, "w": 1, "h": 1}]},
|
||||
{**base, "rooms": [{"id": "r", "name": "R", "x": 0, "y": 0, "w": huge, "h": 1}]},
|
||||
{**base, "view_box": [0, 0, huge, 1]},
|
||||
{**base, "openings": [{"id": "o", "type": "door", "x": huge, "y": 0.5,
|
||||
"angle": 0, "length": 0.1}]},
|
||||
{**base, "openings": [{"id": "o", "type": "door", "x": 0.5, "y": 0.5,
|
||||
"angle": 1e6, "length": 0.1}]},
|
||||
):
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [cfg]})
|
||||
# a vertex a bit past the canvas edge is a drawing, not an attack
|
||||
assert v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
|
||||
{"id": "r", "name": "R", "poly": [[-0.2, 0], [1.3, 0], [1, 1]]}]}]})
|
||||
|
||||
|
||||
def test_sizes_are_not_coordinates():
|
||||
"""HP-1502-01: a size must be strictly positive — SVG refuses zero and
|
||||
negative width/height, and the clients divide by these. view_box [0,0,0,0]
|
||||
used to pass the shared validator and blank the plan on every client.
|
||||
Coordinates may still be negative: a crop origin can sit past the edge."""
|
||||
base = {"id": "s1", "title": "S", "view_box": [0, 0, 1, 1], "rooms": []}
|
||||
for cfg in (
|
||||
{**base, "view_box": [0, 0, 0, 0]},
|
||||
{**base, "view_box": [0, 0, -1, -2]},
|
||||
{**base, "view_box": [0, 0, 1, 0.0001]}, # below the 0.001 floor
|
||||
{**base, "rooms": [{"id": "r", "name": "R", "x": 0.1, "y": 0.1, "w": 0, "h": 0.5}]},
|
||||
{**base, "rooms": [{"id": "r", "name": "R", "x": 0.1, "y": 0.1, "w": 0.5, "h": -1}]},
|
||||
):
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [cfg]})
|
||||
# negative COORDINATES stay legal, and a normal crop viewport passes
|
||||
assert v.CONFIG_SCHEMA({"spaces": [{**base, "view_box": [-0.2, -0.1, 1.4, 1.2]}]})
|
||||
assert v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
|
||||
{"id": "r", "name": "R", "x": -0.1, "y": -0.1, "w": 0.4, "h": 0.3}]}]})
|
||||
|
||||
|
||||
def test_openings_cap_enforced():
|
||||
"""audit follow-up B5: MAX_OPENINGS was defined but never wired in."""
|
||||
many = [{"id": f"o{i}", "type": "door", "x": 0.1, "y": 0.1, "angle": 0, "length": 0.1}
|
||||
for i in range(v.MAX_OPENINGS + 1)]
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [{"id": "s1", "title": "S", "aspect": 1.0,
|
||||
"view_box": [0, 0, 100, 100], "rooms": [],
|
||||
"view_box": [0, 0, 1, 1], "rooms": [],
|
||||
"openings": many}]})
|
||||
|
||||
|
||||
@@ -265,17 +319,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):
|
||||
collect_plans = plans.collect_plans
|
||||
|
||||
d = _plans(tmp_path, ["f1.keep.png"])
|
||||
# past the long grace, and belonging to no space in the config
|
||||
_plans(tmp_path, ["f1.abandoned.png"], age=const.SCHEDULED_GRACE_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
|
||||
@@ -477,31 +520,6 @@ def test_attachment_refs_reads_marker_urls():
|
||||
assert plans.attachment_refs(cfg) == set()
|
||||
|
||||
|
||||
def test_collect_attachments_supersedes_and_ages(tmp_path):
|
||||
import os
|
||||
import time
|
||||
|
||||
collect_attachments = plans.collect_attachments
|
||||
files = tmp_path / "files"
|
||||
(files / "m1").mkdir(parents=True)
|
||||
for n in ("old.pdf", "new.pdf", "cancelled.pdf"):
|
||||
(files / "m1" / n).write_bytes(b"x")
|
||||
|
||||
# the commit swapped old.pdf for new.pdf; cancelled.pdf is a fresh upload
|
||||
# nobody saved — it may belong to a dialog that is still open
|
||||
removed = collect_attachments(files, _acfg("m1/old.pdf"), _acfg("m1/new.pdf"))
|
||||
assert removed == 1
|
||||
assert not (files / "m1" / "old.pdf").exists()
|
||||
assert (files / "m1" / "new.pdf").is_file()
|
||||
assert (files / "m1" / "cancelled.pdf").is_file()
|
||||
|
||||
old = time.time() - const.SCHEDULED_GRACE_S - 60
|
||||
os.utime(files / "m1" / "cancelled.pdf", (old, old))
|
||||
assert collect_attachments(files, _acfg("m1/new.pdf"), _acfg("m1/new.pdf")) == 1
|
||||
assert not (files / "m1" / "cancelled.pdf").exists()
|
||||
assert (files / "m1" / "new.pdf").is_file()
|
||||
|
||||
|
||||
def test_collect_attachments_removes_the_empty_folder_and_never_raises(tmp_path):
|
||||
import os
|
||||
import time
|
||||
@@ -554,69 +572,344 @@ def test_legacy_segments_are_dropped_by_the_server():
|
||||
assert "segments" not in out
|
||||
|
||||
|
||||
def test_scheduled_collection_never_takes_a_detached_plan(tmp_path):
|
||||
"""2026-07-28, on the author's own instance: two plans were deleted.
|
||||
|
||||
Detaching a plan (switching a space to "draw") is reversible and the editor
|
||||
says the file stays on disk. The timer only knows "nothing points at it",
|
||||
applied the one-hour orphan rule, and removed images that had been detached
|
||||
weeks earlier. A commit may still collect what it superseded — it knows it
|
||||
replaced something. The timer may not.
|
||||
"""
|
||||
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()
|
||||
for n in ("f1.svg", "f2.tok.png", "gone.old.png"):
|
||||
(d / n).write_bytes(b"x")
|
||||
t = time.time() - const.SCHEDULED_GRACE_S - 60
|
||||
os.utime(d / n, (t, t))
|
||||
|
||||
cfg = {"spaces": [{"id": "f1", "plan_url": None}, # detached, space alive
|
||||
{"id": "f2", "plan_url": None}]} # same
|
||||
assert plans.collect_plans(d, cfg, cfg) == 1
|
||||
assert (d / "f1.svg").is_file(), "a detached plan of a live space is kept"
|
||||
assert (d / "f2.tok.png").is_file()
|
||||
assert not (d / "gone.old.png").exists(), "a plan of a space that no longer exists ages out"
|
||||
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
|
||||
|
||||
# a commit still removes what it SUPERSEDED — that it knows for certain
|
||||
old = {"spaces": [{"id": "f1", "plan_url": "/p/f1.svg"}, {"id": "f2", "plan_url": None}]}
|
||||
new = {"spaces": [{"id": "f1", "plan_url": "/p/f1.new.png"}, {"id": "f2", "plan_url": None}]}
|
||||
(d / "f1.new.png").write_bytes(b"x")
|
||||
assert plans.collect_plans(d, old, new) == 1
|
||||
assert not (d / "f1.svg").exists()
|
||||
assert (d / "f2.tok.png").is_file(), "and still touches nothing else of a live space"
|
||||
# 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_grace_is_a_month_outside_a_staging_folder(tmp_path):
|
||||
"""A staging folder is unambiguous; a marker folder is not.
|
||||
def test_attachment_collection_matrix(tmp_path):
|
||||
"""Removing an attachment is a trash button; deleting the device is not."""
|
||||
collect = plans.collect_attachments
|
||||
|
||||
`up_*` only ever holds an upload from a dialog that was never saved, so an
|
||||
hour is right there. A marker's own folder may hold a file that is merely
|
||||
unreferenced at the moment, and one hour of that turned out to be a way to
|
||||
lose data (2026-07-28).
|
||||
"""
|
||||
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
|
||||
month_ago = time.time() - const.SCHEDULED_GRACE_S - 60
|
||||
|
||||
for path, when in (
|
||||
((files / "m1" / "recent.pdf"), hour_ago),
|
||||
((files / "m1" / "ancient.pdf"), month_ago),
|
||||
((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": []}]}
|
||||
removed = plans.collect_attachments(files, cfg, cfg)
|
||||
assert (files / "m1" / "recent.pdf").is_file(), "an hour is not enough for a marker file"
|
||||
assert not (files / "m1" / "ancient.pdf").exists(), "a month is"
|
||||
assert not (files / "up_abandoned").exists(), "a cancelled dialog still goes after an hour"
|
||||
assert removed == 2
|
||||
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