Compare commits

...
24 Commits
Author SHA1 Message Date
Matysh 9b180c5917 changelog: describe the plan check as it ended up (new references only) 2026-07-28 22:54:00 +03:00
Matysh 3084472c75 v1.49.0 2026-07-28 22:53:35 +03:00
Matysh c00048611e HP-1470-02: only refuse a plan reference that is NEW and already broken
CI caught what the local pure suite cannot run. Four HA-harness tests store a
plan url whose file is not there — and so, sooner or later, will a user: files
disappear from outside Home Assistant, and one of them is what the 'broken plan'
repair exists to report. Refusing every write that names a missing file would
have locked the owner out of every edit, including detaching it.

So the check compares against the stored configuration and only refuses names it
has not seen before, which is exactly the pick-then-delete window it was written
for. The repairs test now attaches a real plan and removes the file behind it;
the quota test budgets from what the shared test config directory already holds
instead of assuming an empty folder.
2026-07-28 22:51:07 +03:00
Matysh f5e6c0318d v1.49.0: content-fit zoom, swipe animation, wording, and the v1.47.0 review
Owner's batch:
- zoom now opens on what is DRAWN (rooms + 5% margin) for spaces with no
  background image; with one the image is the plan and still fits whole. A small
  plan on the square canvas no longer opens as a speck.
- swiping between spaces, and the kiosk carousel, slide sideways; honours
  prefers-reduced-motion.
- the room settings button reads 'Room settings' and lightens on hover.
- 'curation' is filtering everywhere: UI strings, docs, code.

Checked the yard while I was there: its drawing sits off-centre because it was
drawn that way — before the migration x spanned 0.12..0.54 with 0.12 and 0.46 of
margin. The migration added 0.1465 on each side, symmetrically. Content-fit zoom
makes it moot anyway.

From the v1.47.0 review:
- HP-1470-02: the picker let you delete the plan you had just selected — it is
  not in the stored config yet, so the server rightly called it free, and the
  save then stored a url with no file. The button is disabled, and since two
  clients can do this in either order, config/set now verifies every internal
  plan url against the disk under the write lock and answers .
  External and legacy urls are not ours to police.
- HP-1470-01: growth is bounded at the door rather than by deleting old files —
  that mistake cost real plans twice. check_quota refuses an upload that would
  push the store past 256 MB / 200 plans (1 GB / 1000 attachments) or leave less
  than 512 MB free. The plan list is capped at 60 newest with a total, and
  thumbnails load lazily.
- HP-1470-03: picking a saved plan waited for nothing and stored a fallback
  ratio when the signature had not arrived — a square plan came out stretched.
  It waits for the signature, binds the result to the dialog that asked, and the
  dialog preview is signed too.
- report §5: the last lifecycle comments still described age-based collection.

Not released yet — the owner asked for a release once the batch is done.
2026-07-28 22:44:09 +03:00
Matysh e1e730560d fix: the migrated viewport must be the whole square, not the old rectangle
Seen on the live instance: in the editors the dot grid covered only part of the
canvas. The grid is drawn over the space's view_box, and I transformed that box
along with everything else — so it still described the old plan area, and the
margins the square canvas had just added were outside it. Nothing to draw on,
which is precisely the room the change exists to give.

The viewport is now reset to the full square. It is also what 'fit to screen'
fits, so the whole canvas is reachable.
2026-07-28 22:28:40 +03:00
Matysh 94b298962a v1.48.0: the canvas is always square, the plan is centred inside it
A space carried an aspect ratio, and coordinates were normalised against it: x
by the width, y by the height. Every geometric question therefore depended on a
per-space number, and picking a canvas orientation was a decision the user had
no reason to make. The render space is now NORM_W x NORM_W and a plan image is
fitted into it by its OWN ratio, centred — wide plans get margins above and
below, tall ones at the sides.

Migration (geometry_migration.py, pure and unit-tested) runs once at setup under
the write lock. Nothing about a drawing changes: the old box is padded out to a
square and every coordinate re-expressed against it — rooms as rects and
polygons, openings and their lengths, decor, view_box, and the marker positions
in the separate layout store. In render units it is a uniform scale plus an
offset, so angles and proportions are exact. cell_cm is scaled for tall plans,
because the grid pitch is a fraction of the width: without it a wall would
measure less than it does.

 is now dropped by the schema rather than accepted — a stale tab sending
it would be sending coordinates from the old normalisation too, and honouring
the field would not make them right.

The demo fixture was migrated with the same transform, so the smokes exercise
the new geometry rather than a square-native fake; six of them needed their
render-space helpers updated and one its click coordinates.

Not released — dev only, per the owner's instruction.
2026-07-28 22:20:59 +03:00
Matysh f7fe63776a Release v1.47.0
Pick a plan you already uploaded: the space dialog lists the plans stored on the
server, attaches one on click, and is the only place a plan file is deleted.
2026-07-28 21:55:24 +03:00
Matysh 01bc4f9711 test: the plans folder is shared across the module
Assert on our own two files rather than the whole listing.
2026-07-28 21:52:12 +03:00
Matysh 85491d0fea v1.47.0: pick a plan you already uploaded
Closes both findings from the v1.46.6 review with one feature, because they are
the same gap seen from two sides. HP-1466-02: a detached plan stayed on disk and
could not be re-attached from the card — the old url is nowhere in the config,
and the backend test 'proved' reattach by remembering it in a Python variable.
HP-1466-01: files kept forever with no way to see or remove them is not a
policy, it is accumulation.

New: houseplan/plans/list (name, url, size, modified, and which spaces use it)
and houseplan/plans/delete, which refuses while a space still references the
file — the stored configuration answers that, not the client. In the space
dialog, 'Already uploaded' shows the list with thumbnails; one click attaches,
reading the aspect from the image as an upload does; the trash button is the
only way a plan file is ever deleted.

That also bounds the disk without any timer, which is the part every automatic
attempt got wrong: v1.46.4 deleted detached plans, v1.46.5 raced the retry that
was about to reference an upload. The user decides, and can now see what they
are deciding about.

Docs: comments in plans.py and websocket_api.py still described the age-based
collection v1.46.6 removed (report §6); ARCHITECTURE gained the two new routes
and an explanation of why the listing is what makes 'never delete' livable.
2026-07-28 21:49:37 +03:00
Matysh d37a67c29f Release v1.46.6
Detaching a plan finally keeps the file where it matters — at the save, not just
on the scheduled pass. Transitions are classified by the space that owned the
file, and nothing is deleted for being old except a staging folder.
2026-07-28 21:25:50 +03:00
Matysh a66272c6f4 test: two HA-harness tests still asserted the old age rule
One demanded an aged upload be collected; the shared sweep fixture expected an
aged plan file to disappear. Both now assert the opposite, which is the rule.
2026-07-28 21:22:33 +03:00
Matysh f4af2fe508 fix: stop ageing files out entirely, except staging folders
The strengthened race test earned its keep on the first run: the sweep deleted
an aged 'rejected upload' while a save was committing a reference to it, and the
accepted config came out pointing at nothing. The write lock serializes the two
but cannot help when the sweep goes first.

So the age rule is gone for plans and for marker folders. What remains is one
sentence: a file goes when an action says so — a plan replaced, an attachment
dropped from a device that still exists — plus a per-dialog staging folder after
an hour, which by construction can only hold an upload nobody saved.

Cost: an upload whose save failed sits there until someone removes it by hand.
That is the side of the trade the owner picked, and it is the side that cannot
lose data.
2026-07-28 21:18:53 +03:00
Matysh 8e07e3c958 test: race the sweep against a save, not a reload against a save
A reload has an unload window where any WS call answers not_ready, so the save
failed at random — and the vaguer assertion this test used to carry was exactly
what hid that. Driving data.sweep() directly is the concurrency the write lock
actually guards.
2026-07-28 21:13:45 +03:00
Matysh 9868f1035f v1.46.6: the detach promise, actually kept this time
v1.46.4 and v1.46.5 documented that detaching a plan leaves the image on disk,
added guards for it, and shipped tests. The guards were never reached: they sit
behind 'not superseded', and a file that left the configuration was called
superseded. From old_refs - new_refs alone, replacing a plan, detaching one and
deleting its space are indistinguishable — so all three deleted the file, at the
moment of the save, before any scheduled pass ever ran.

Every test I wrote for this called collect_plans(d, cfg, cfg): old config equal
to new, i.e. only the scheduled pass. The transition that mattered was never
exercised. Codex reproduced it in four lines.

Classification is by owner now:
  space in both, plan A -> plan B  : the user picked another image -> removed
  space in both, plan -> none      : detached -> kept
  space gone                       : kept (the image was imported; a thirty-day
                                     grace measured from file age is meaningless
                                     anyway, it was uploaded months ago)
  space has a plan, other file     : rejected upload -> 1 h
Attachments follow the same shape: dropped from a device that still exists ->
removed (a trash button promises nothing); device gone -> kept; staging folder
-> 1 h.

Tests: a matrix per rule in the pure module, and — the part that was missing —
test_detaching_a_plan_keeps_the_file, which goes through real config/set calls:
attach, detach, assert the file is there, restart, assert again, re-attach,
replace, assert the replaced one is gone, delete the space, assert the plan
survives. Also strengthened the sweep/save race test to assert the save actually
succeeded and the config points at the specific expected file, per the report.
2026-07-28 21:11:11 +03:00
Matysh f2c9b07cc1 Release v1.46.5
Audit of every automatic deletion: a detached plan is never removed (standing
rule now in SCOPE.md), files/cleanup verifies against the stored config instead
of trusting the client, and a deleted space's plan waits thirty days.
2026-07-28 20:41:06 +03:00
Matysh 2c7a2f849d test: files/cleanup reports counts now, not a boolean
It answers {removed, kept} since v1.46.5 — the 'kept' side is the point: files
the stored configuration still references survive a cleanup of their folder.
2026-07-28 20:38:49 +03:00
Matysh 33e71ca96c v1.46.5: audit of every automatic deletion
Owner's decision after the incident: a detached plan is never deleted, at any
age. v1.46.4 gave it a month; this makes it permanent and, more importantly,
writes the reasoning where the next change will trip over it — docs/SCOPE.md now
carries the standing rule. The component may delete a file only when a user
action says so. 'Nothing points at this any more' is not such an action, because
the two errors are not symmetrical: wasted disk is visible, cheap and
reversible; a deleted file is none of those.

Went through every other automatic deletion with the same question. One more
was wrong: houseplan/files/cleanup rmtree'd whatever folder the card named. A
partial migration leaves urls pointing into it — files/migrate deliberately does
not rewrite the ones it could not confirm — so those were live links to files
being deleted; and a wrong or stale id from any client destroyed a live device's
manuals. The server now reads the stored config under its lock and removes only
what nothing references, keeping the rest and saying so.

Also: a plan of a DELETED space now waits thirty days rather than an hour.
Deleting a space is deliberate; an hour is a short window to notice a misclick.

The rest came out clean: layout/delete and marker/room/space removal are all
confirm-guarded user actions, upload temporaries are never user-visible, and
dropping legacy 'segments' is a documented migration.
2026-07-28 20:36:17 +03:00
Matysh 7128ab504d Release v1.46.4
Data loss fix: collection treated a detached plan as abandoned and removed it
after an hour. Supersession stays immediate; absence is now judged per case.
2026-07-28 20:04:07 +03:00
Matysh f953a3c286 fix: the guard has to be per-case, not blanket
Protecting every file of a live space also protected the ones a commit had just
superseded, and gave rejected uploads immortality. The distinction that matters
is narrower: a space with NO plan_url has had its image detached and may want it
back; a space that has one can only be holding its own rejects. Attachments:
staging folders keep the hour, marker folders get the month.

Also: the layout event test asserted the order of separately fired bus events,
which nothing promises — it came back [2,1,3] in CI.
2026-07-28 19:59:32 +03:00
Matysh ef270d11b7 v1.46.4: detached plans were being collected as garbage — data loss
Deployed v1.46.3 to my own instance, restarted, and the startup sweep deleted
both floor plans: config/houseplan/plans/ went from f1.svg + f2.png to empty.
The backup is a SecureTar, so they are gone.

The rule was wrong, not the code. v1.46.0 introduced collection that treats
'nothing references this right now' as abandoned and gives it an hour. But
detaching a plan — switching a space to 'draw' — is a normal, reversible action,
and the editor's own comment says the file stays on disk. Those two plans had
been detached for weeks; every pass since v1.46.0 was entitled to remove them,
and the one that finally ran did.

New rule, one for every path:
  * superseded by a commit (was in the old revision, is not in the new) — goes
    immediately; that is the one thing a commit knows for certain;
  * belongs to a space or marker that still exists — never collected, at any
    age, because unreferenced is not abandoned;
  * a per-dialog staging folder (up_*) — one hour, unchanged: by construction it
    only ever holds an upload from a dialog that was never saved;
  * anything else — thirty days.

The  flag I added an hour ago is gone with it: two rules for the same
question is how this happened. Tests updated to the new grace, plus two that pin
the distinction directly.

I am sorry about the files.
2026-07-28 19:55:38 +03:00
Matysh dc24390222 Release v1.46.3
Re-check of v1.46.2: the startup sweep uses the runtime data it already has
instead of a lookup that cannot succeed during setup, and the test that was
supposed to prove it no longer passes for the wrong reason.
2026-07-28 19:45:39 +03:00
Matysh 254354bf56 test: invoke the scheduled sweep instead of faking a 24 h jump
The time-changed variant failed in CI: the timer fired but the assertion still
saw the orphan, and 'the timer fires' and 'the work happens' are different
claims anyway. HouseplanData now publishes the sweep, so the test awaits it
directly and asserts the outcome.
2026-07-28 19:35:09 +03:00
Matysh c9a60a110d chore: untrack __pycache__
.gitignore has covered it for a long time, but four .pyc files were committed
before the rule existed and kept turning up in every diff.
2026-07-28 19:31:53 +03:00
Matysh 75279308c1 v1.46.3: re-check of v1.46.2 — HP-1462-01
The startup sweep resolved its runtime data with get_data(hass), which lists
only LOADED entries — during async_setup_entry the entry is still
SETUP_IN_PROGRESS, so it always got None and degraded to removing streaming
temporaries. The real collection was then 24 hours away, and an instance that
restarts more often than that never ran it at all. It closes over the
object created a few lines above instead; the callback is unregistered with the
entry, so that matches the lifecycle.

The test that was meant to prove the previous fix passed for the wrong reason:
it seeded the strays BEFORE config/set, which collects too, so nothing was left
for the restart to find. Now seeded after the save, plus two more — one firing
the interval callback on its own, and one running a reload and a save
concurrently to assert the accepted config never references a file the sweep
removed (they share the write lock; this pins that they must).
Docs: CHANGELOG.md + CHANGELOG.ru.md + TESTING.md + STATUS.md.
2026-07-28 19:31:29 +03:00
46 changed files with 2469 additions and 364 deletions
+1 -1
View File
@@ -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.
+33 -5
View File
@@ -20,9 +20,10 @@ from .const import (
PLANS_URL,
VERSION,
)
from .geometry_migration import migrate_config
from .plans import collect_attachments, collect_plans, sweep_upload_temps
from .repairs import async_check_plan_files
from .store import HouseplanConfigEntry, create_data, get_data
from .store import HouseplanConfigEntry, create_data
_LOGGER = logging.getLogger(__name__)
@@ -99,6 +100,26 @@ 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.
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 {}
if cfg and migrate_config(cfg, layout):
rev = int(stored.get("rev", 0)) + 1
await data.config_store.async_save({"config": cfg, "rev": rev})
await data.store.async_save(
{"layout": layout, "rev": int(lay_stored.get("rev", 0)) + 1}
)
_LOGGER.info(
"House Plan: migrated %s space(s) to the square canvas", len(cfg.get("spaces") or [])
)
hass.bus.async_fire("houseplan_config_updated", {"rev": rev})
await async_check_plan_files(hass, entry)
# Scheduled collection of everything nobody ended up referencing.
@@ -118,16 +139,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
files_dir = Path(hass.config.path(FILES_DIR))
plans_dir = Path(hass.config.path(PLANS_DIR))
try:
data = get_data(hass)
if data is None: # entry unloaded — nothing authoritative to compare against
await hass.async_add_executor_job(sweep_upload_temps, files_dir)
return
# `data` from the closure, NOT get_data(hass): during
# async_setup_entry the entry is still SETUP_IN_PROGRESS, so
# async_loaded_entries() does not list it and the lookup returned
# None. The startup pass then silently degraded to removing
# streaming temporaries only, and the real collection waited a full
# day — restarting more often than that meant it never ran at all
# (HP-1462-01). The callback is unregistered with the entry, so
# closing over its runtime data matches the lifecycle exactly.
async with data.write_lock:
stored = await data.config_store.async_load() or {}
cfg = stored.get("config") or {}
def _collect() -> int:
n = sweep_upload_temps(files_dir)
# same config on both sides: nothing is superseded, so this
# only ever collects what the shared rules call abandoned
n += collect_attachments(files_dir, cfg, cfg)
n += collect_plans(plans_dir, cfg, cfg)
return n
@@ -138,6 +165,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
except Exception: # noqa: BLE001 — housekeeping must never fail a setup
_LOGGER.exception("House Plan: sweeping unreferenced files failed")
data.sweep = _sweep
await _sweep()
entry.async_on_unload(
async_track_time_interval(hass, _sweep, timedelta(hours=24))
+22 -1
View File
@@ -17,14 +17,35 @@ CONTENT_URL = "/api/houseplan/content"
# way to tell which paths were dropped (review R2-2).
MAX_SIGN_PATHS = 200
# Nothing is ever deleted for being old (docs/SCOPE.md), so growth has to be
# stopped at the door instead. These bound the whole store, not one request: by
# default any authenticated user may upload, and a per-request cap of 8/50 MB
# says nothing about how many requests there are (HP-1470-01).
MAX_PLANS_BYTES = 256 * 1024 * 1024
MAX_PLANS_FILES = 200
# How many the picker asks for at once — newest first.
MAX_PLANS_LISTED = 60
MAX_FILES_BYTES = 1024 * 1024 * 1024
MAX_FILES_COUNT = 1000
# Refuse to write when the disk is nearly full: filling the config partition
# breaks .storage, the recorder and backups, not just this card.
MIN_FREE_BYTES = 512 * 1024 * 1024
# An uploaded plan that no accepted configuration references is collected only
# once it is this old. Age is a race guard, not a policy: a plan uploaded
# seconds ago may belong to another client's transaction that has not written
# its configuration yet (review R3-1).
PLAN_ORPHAN_TTL_S = 3600
# Kept for compatibility with anything reading it; the collectors no longer use
# a long grace at all. Every attempt to age files out ended badly — first by
# deleting detached plans, then by racing the save that was about to reference a
# retried upload. What is left is deliberately simple: files go when the user's
# action says so, plus staging folders after PLAN_ORPHAN_TTL_S.
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.46.2"
VERSION = "1.49.0"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
@@ -0,0 +1,133 @@
"""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 migrate_config(config: dict[str, Any], layout: dict[str, Any] | None = None) -> bool:
"""Migrate every space, and the marker positions that belong to them."""
spaces = config.get("spaces") or []
factors = {}
for space in spaces:
if "aspect" in space:
factors[str(space.get("id"))] = transform_for(space.get("aspect") or 1)
if not factors:
return False
for space in spaces:
migrate_space(space)
for pos in (layout or {}).values():
if not isinstance(pos, dict):
continue
f = factors.get(str(pos.get("s")))
if not f:
continue
dx, dy, kx, ky = f
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
return True
+15 -2
View File
@@ -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
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.46.2"
"version": "1.49.0"
}
+130 -21
View File
@@ -14,7 +14,7 @@ import time
from pathlib import Path
from typing import Any
from .const import PLAN_ORPHAN_TTL_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,15 +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. Anything else unreferenced is an upload that was
never saved — a cancelled dialog, a rejected write — and waits out
PLAN_ORPHAN_TTL_S first, because a fresh one may belong to a dialog the user
still has open. Never raises: it runs behind a durable write.
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)
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
# Removing an attachment from a device that still exists is the user saying
# "drop this one" — a trash button, no promise that anything is kept. A
# device that is GONE is a different transition, and its files follow the
# same rule as a deleted space's plan: kept.
live_markers = {str(m.get("id")) for m in (new_cfg or {}).get("markers") or []}
# Same distinction as for plans. A staging folder (`up_*`) is different: it
# only ever holds an upload from a dialog that was never saved, so the short
# rule is exactly right there even on the timer.
now_s = time.time() if now is None else now
staging_cutoff = now_s - PLAN_ORPHAN_TTL_S
removed = 0
try:
folders = sorted(p for p in files_dir.iterdir() if p.is_dir()) if files_dir.is_dir() else []
@@ -138,6 +148,9 @@ def collect_attachments(
return 0
removed += sweep_upload_temps(files_dir, now)
for folder in folders:
# A staging folder only ever holds an upload from a dialog that was never
# saved — unambiguous, so an hour is right, and no device owns it.
staging = folder.name.startswith("up_")
try:
items = sorted(p for p in folder.iterdir() if p.is_file())
except OSError:
@@ -146,12 +159,19 @@ def collect_attachments(
rel = f"{folder.name}/{item.name}"
if rel in new_refs:
continue
try:
stale = item.stat().st_mtime < cutoff
except OSError:
stale = False
if rel not in old_refs and not stale:
continue
dropped = rel in old_refs and folder.name in live_markers
if not dropped:
if not staging:
# Same rule as for plans: not asked for, so kept. A file in
# a device's folder that the device does not list is an
# upload whose save was rejected — and ageing those out
# raced the retry that was about to reference them.
continue
try:
if item.stat().st_mtime >= staging_cutoff:
continue
except OSError:
continue
try:
item.unlink()
removed += 1
@@ -169,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:
@@ -186,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(".")
@@ -212,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
@@ -220,7 +298,29 @@ def collect_plans(
"""
new_refs = plan_refs(new_cfg)
old_refs = plan_refs(old_cfg)
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
# A commit knows what it superseded. The timer only knows what nothing
# points at *right now*, and for a plan that is a reversible state: the
# editor detaches the image when a space switches to "draw" and says the
# file stays on disk. So the scheduled pass keeps anything belonging to a
# space that still exists, and waits a month for the rest.
# A space with NO plan_url has had its image detached — reversible, and the
# editor promises the file stays. A space that HAS one is different: any
# other file of its own is a superseded or rejected upload, so the short
# rule is right for those. Getting this distinction wrong (protecting
# nothing) destroyed two detached plans on 2026-07-28.
# The short rule fits exactly one case: a space that HAS a plan, where any
# other file of its own can only be a superseded or rejected upload.
old_by_space = plan_by_space(old_cfg)
new_by_space = plan_by_space(new_cfg)
# A file that left the configuration tells us nothing on its own: replacing a
# plan, detaching one and deleting a space all look identical from
# `old_refs - new_refs`. Only the first is a deletion the user asked for
# (HP-1465-01 — the guards below were written and then never reached,
# because the code decided "superseded" before asking why).
replaced = {
name for space, name in old_by_space.items()
if new_by_space.get(space) and new_by_space[space] != name
}
removed = 0
try:
items = sorted(plans_dir.iterdir()) if plans_dir.is_dir() else []
@@ -233,12 +333,21 @@ def collect_plans(
for item in items:
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
continue
superseded = item.name in old_refs
try:
stale = item.stat().st_mtime < cutoff
except OSError:
stale = False
if not superseded and not stale:
if item.name not in replaced:
# PRODUCT RULE (owner's decision, 2026-07-28): a plan file we were
# not told to delete is kept, however long it sits there. Detaching
# is one click to undo and the editor says the image stays; deleting
# a space is deliberate but the image was imported and may be
# nowhere else. The errors are not symmetrical — unnecessary
# megabytes can be removed by hand, a deleted file cannot be
# brought back.
#
# There is deliberately no age rule here. An earlier version aged
# out "rejected uploads" — a file of a space that has a plan, which
# was never the plan — and that raced a save: the sweep deleted the
# upload from the failed attempt while a retry was committing a
# reference to it. A rule that can delete a file somebody is about
# to point at is not worth the disk it reclaims.
continue
try:
item.unlink()
+6
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
@@ -42,6 +43,11 @@ class HouseplanData:
# One lock for every load→modify→save cycle of both stores: prevents
# lost updates from concurrent WS calls and makes the rev check atomic.
write_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
# Collect files nothing references any more. Set during setup, which also
# runs it once and schedules it daily. Exposed so it can be invoked
# directly — a test that fakes a 24 h jump proves the timer fires, not that
# the work happens, and those are different claims.
sweep: Callable[[], Awaitable[None]] | None = None
HouseplanConfigEntry = ConfigEntry[HouseplanData]
+9 -1
View File
@@ -188,7 +188,15 @@ SPACE_SCHEMA = vol.Schema(
vol.Required("title"): str,
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
vol.Optional("plan_url"): vol.Any(str, None),
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
# The canvas is square since v1.48.0. What used to be the space's own
# `aspect` is gone; the background image keeps its own proportions and
# is centred, so only the IMAGE's ratio is stored. A stale tab may still
# send the old field — it is dropped rather than trusted, because the
# coordinates it comes with were normalised against a different box.
vol.Remove("aspect"): object,
vol.Optional("plan_aspect"): vol.Any(
None, vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20))
),
vol.Required("view_box"): vol.All([_finite], vol.Length(min=4, max=4)),
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
+229 -24
View File
@@ -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,
)
@@ -41,6 +46,8 @@ def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_config_get)
websocket_api.async_register_command(hass, ws_config_set)
websocket_api.async_register_command(hass, ws_plan_set)
websocket_api.async_register_command(hass, ws_plans_list)
websocket_api.async_register_command(hass, ws_plans_delete)
websocket_api.async_register_command(hass, ws_files_migrate)
websocket_api.async_register_command(hass, ws_files_cleanup)
websocket_api.async_register_command(hass, ws_content_sign)
@@ -215,6 +222,105 @@ async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any])
connection.send_result(msg["id"], {"ok": True, "mapping": mapping, "copied": len(mapping)})
@websocket_api.websocket_command({vol.Required("type"): "houseplan/plans/list"})
@websocket_api.async_response
async def ws_plans_list(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Plan images on the server, with what still uses them.
Files are never removed for being unreferenced (docs/SCOPE.md), which only
works as a policy if the user can see them: detaching a plan keeps the
image, and this is how it gets picked up again — or deleted on purpose.
"""
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
stored = await rt.config_store.async_load() or {}
cfg = stored.get("config") or {}
used: dict[str, list[str]] = {}
for space in cfg.get("spaces") or []:
name = plan_basename(space.get("plan_url"))
if name:
used.setdefault(name, []).append(space.get("title") or space.get("id") or "?")
plans_dir = Path(hass.config.path(PLANS_DIR))
def _scan() -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
if not plans_dir.is_dir():
return out
for item in sorted(plans_dir.iterdir()):
if not item.is_file() or not is_plan_file(item.name):
continue
try:
st = item.stat()
except OSError:
continue
out.append({
"name": item.name,
"url": f"{CONTENT_URL}/plans/_/{item.name}",
"size": st.st_size,
"modified": int(st.st_mtime),
"used_by": used.get(item.name, []),
})
out.sort(key=lambda x: -x["modified"])
return out
plans = await hass.async_add_executor_job(_scan)
# newest first and capped: a folder with thousands of files would otherwise
# become one huge message, one huge list and a signing request per row
connection.send_result(
msg["id"], {"plans": plans[:MAX_PLANS_LISTED], "total": len(plans)}
)
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/plans/delete",
vol.Required("name"): str,
}
)
@websocket_api.async_response
async def ws_plans_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Delete a plan image because the user asked — the only way one goes.
Refuses while a space still references it: the answer to "can I delete this"
is the stored configuration's, not the client's.
"""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may delete plans")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
name = sanitize_filename(msg["name"])
if not is_plan_file(name):
connection.send_error(msg["id"], "invalid_name", "Not a plan file")
return
async with rt.write_lock:
stored = await rt.config_store.async_load() or {}
cfg = stored.get("config") or {}
if name in plan_refs(cfg):
connection.send_error(
msg["id"], "in_use", "A space still uses this plan — detach it first"
)
return
path = Path(hass.config.path(PLANS_DIR)) / name
def _rm() -> bool:
try:
path.unlink()
return True
except FileNotFoundError:
return False
except OSError as err:
_LOGGER.warning("House Plan: could not delete %s: %s", path, err)
return False
removed = await hass.async_add_executor_job(_rm)
connection.send_result(msg["id"], {"ok": True, "removed": removed})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/content/sign",
@@ -263,34 +369,68 @@ async def ws_content_sign(hass: HomeAssistant, connection, msg: dict[str, Any])
)
@websocket_api.async_response
async def ws_files_cleanup(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Delete a marker's file folder — called only AFTER the config is committed."""
"""Drop a marker folder's leftovers after its files moved elsewhere.
Called after a rebind: the files were copied to the new marker id and the
config that references them is committed, so the source folder is spent.
It used to `rmtree` the folder on the client's word alone. Two ways that
ends badly: a partial copy leaves some urls still pointing INTO this folder
(the migration deliberately does not rewrite those), and a wrong or stale
id from any client deletes a live marker's attachments outright. So the
server checks for itself — under the config lock — and removes only files
the stored configuration does not reference. Same principle as the
collector: a client may say what it no longer needs, never what may go.
"""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit files")
return
import shutil
from pathlib import Path
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
from .const import FILES_DIR
from .plans import attachment_refs
from .validation import sanitize_marker_id
mid = sanitize_marker_id(msg["marker_id"])
if not mid:
connection.send_result(msg["id"], {"ok": True, "removed": False})
return
base = Path(hass.config.path(FILES_DIR)).resolve()
target = (base / mid).resolve()
if not str(target).startswith(str(base)) or target == base:
connection.send_result(msg["id"], {"ok": True, "removed": False})
target = (base / mid).resolve() if mid else base
if not mid or not str(target).startswith(str(base)) or target == base:
connection.send_result(msg["id"], {"ok": True, "removed": 0, "kept": 0})
return
def _rm() -> bool:
if not target.is_dir():
return False
shutil.rmtree(target, ignore_errors=True)
return True
async with rt.write_lock:
stored = await rt.config_store.async_load() or {}
refs = attachment_refs(stored.get("config") or {})
removed = await hass.async_add_executor_job(_rm)
connection.send_result(msg["id"], {"ok": True, "removed": removed})
def _rm() -> tuple[int, int]:
if not target.is_dir():
return 0, 0
removed = kept = 0
for item in sorted(target.iterdir()):
if not item.is_file():
continue
if f"{mid}/{item.name}" in refs:
kept += 1
continue
try:
item.unlink()
removed += 1
except OSError as err:
_LOGGER.warning("House Plan: could not remove %s: %s", item, err)
if not kept:
try:
target.rmdir()
except OSError:
pass
return removed, kept
removed, kept = await hass.async_add_executor_job(_rm)
if kept:
_LOGGER.info(
"House Plan: kept %s file(s) in %s — the configuration still references them", kept, mid
)
connection.send_result(msg["id"], {"ok": True, "removed": removed, "kept": kept})
@websocket_api.websocket_command(
@@ -337,6 +477,46 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
def _internal_plan_names(config: dict[str, Any]) -> set[str]:
"""Plan file names a configuration names through OUR urls.
Only `/api/houseplan/content/plans/_/<name>` and the legacy static path
count. Anything else belongs to the user and may point wherever they like.
"""
out: set[str] = set()
for space in (config or {}).get("spaces") or []:
url = space.get("plan_url")
if not isinstance(url, str) or not url:
continue
if not (url.startswith(CONTENT_URL + "/plans/") or url.startswith(PLANS_URL + "/")):
continue
name = plan_basename(url)
if name:
out.add(name)
return out
def _missing_internal_plans(
plans_dir: Path, config: dict[str, Any], previous: dict[str, Any] | None = None
) -> set[str]:
"""Newly named plan files that are not on disk.
Guards the pick-then-save window: another client may delete a plan between
the moment this one chose it and the moment it saves, which would otherwise
store a url with nothing behind it (HP-1470-02).
A name the stored configuration already carries is deliberately let through.
It is already broken — repairs says so — and refusing the write would lock
the owner out of every other edit, including the one that detaches it.
"""
known = _internal_plan_names(previous or {})
return {
name
for name in _internal_plan_names(config)
if name not in known and not (plans_dir / name).is_file()
}
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/config/set",
@@ -386,6 +566,23 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
f"Configuration was changed in another window (rev {current_rev} != {msg['expected_rev']})",
)
return
# An internal plan url must name a file that exists. The card can pick a
# plan and then delete it from the same dialog, and two clients can do
# the same thing in either order — the lock serialises them but says
# nothing about whether the file survived (HP-1470-02). External and
# legacy urls are not ours to check and are left alone.
missing = await hass.async_add_executor_job(
_missing_internal_plans,
Path(hass.config.path(PLANS_DIR)),
msg["config"],
data.get("config"),
)
if missing:
connection.send_error(
msg["id"], "missing_plan",
"Plan file no longer exists: " + ", ".join(sorted(missing)),
)
return
new_rev = current_rev + 1
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
# Still holding the lock: the file system is not part of the store's
@@ -446,15 +643,23 @@ 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
# files of a differently named space.
plans_dir = Path(hass.config.path(PLANS_DIR))
try:
await hass.async_add_executor_job(
check_quota, plans_dir, len(raw), MAX_PLANS_BYTES, MAX_PLANS_FILES
)
except QuotaError as err:
connection.send_error(msg["id"], err.reason, err.detail)
return
name = f"{space_id}.{secrets.token_hex(4)}.{msg['ext']}"
path = plans_dir / name
+2 -2
View File
@@ -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;
+2 -2
View File
@@ -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
View File
@@ -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);
+1 -2
View File
@@ -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);
+1 -1
View File
@@ -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;
+2 -3
View File
@@ -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;
+1 -1
View File
@@ -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';
+4 -4
View File
@@ -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);
+93
View File
@@ -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);
+2 -2
View File
@@ -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);
+3 -3
View File
@@ -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);
File diff suppressed because one or more lines are too long
+7 -7
View File
@@ -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: {}
+90 -23
View File
File diff suppressed because one or more lines are too long
+58 -7
View File
@@ -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,21 @@ 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) |
**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
@@ -207,8 +220,37 @@ happen when somebody saves, and a file uploaded into a dialog that was then
cancelled would wait for a write that may never come. A new icon has no id yet, so its
files go to a per-dialog staging folder and move to the real id once the config
write is accepted — the same copy → save → cleanup order as a rebind.
`config/set` collects what its commit superseded, and anything unreferenced and
older than `PLAN_ORPHAN_TTL_S`.
`config/set` collects what its commit superseded — that much a commit knows for
certain. *Unreferenced* is a far weaker signal, and the policy follows from one
asymmetry: **a few unnecessary megabytes can always be removed by hand; a file
we should not have removed cannot be brought back.** When the evidence is weak,
keep the file. Owner's decision, 2026-07-28, after the one-hour rule applied to
every unreferenced file destroyed two detached plans.
The classification is by **owner**, not by "is it referenced". A file leaving
the configuration looks identical whether the plan was replaced, detached, or
its space deleted — and only the first is a deletion the user asked for. Reading
`old_refs - new_refs` and calling it "superseded" deleted a plan the moment it
was detached, under documentation promising the opposite (HP-1465-01).
| Case | What it means | Rule |
|---|---|---|
| Space in both, plan A → plan B | the user picked another image | removed immediately |
| Space in both, plan → none | detached; one click undoes it | **kept** |
| Space gone | deliberate, but the image was imported and may be nowhere else | **kept** |
| Space has a plan, plus another file of its own | an upload whose save was rejected | **kept** — ageing these out raced the retry that referenced them |
| Marker in both, attachment dropped from its list | a trash button, promising nothing | removed immediately |
| Marker gone | same call as a deleted space's plan | **kept** |
| Attachment in `up_*` | a dialog that was never saved; no device owns it | `PLAN_ORPHAN_TTL_S` (1 h) |
| Marker there, file it never listed | a rejected upload | **kept**, same reason |
Nothing is deleted for being old, with one exception: a per-dialog staging
folder (`up_*`), which by construction can only hold an upload from a dialog
that was never saved. The disk therefore stays bounded by the user, not by a
timer — `houseplan/plans/list` shows every stored plan with its size and which
space uses it, and `houseplan/plans/delete` removes one on request, refusing
while a space still references it. That listing is what makes "we never delete"
livable: a detached plan is not lost, it is one click away in the space dialog.
**Config writes are serialized** (HP-1454-03). `_writeConfig()` chains onto a
single promise: one `config/set` in flight, each carrying the revision the
@@ -224,12 +266,21 @@ removes nothing. Deciding what may then go is *not* a client's call — a cleanu
request cannot be ordered against another client's commit, and a delayed one
deletes a plan that was just saved. So `config/set` collects itself, inside its
write lock, from the pair of configurations that bracket the commit
(`plans.collect_plans`): superseded files go immediately, other unreferenced
uploads only once `PLAN_ORPHAN_TTL_S` has passed, since a fresh one may belong
to a transaction still in flight. The `.` between id and token is load-bearing —
(`plans.collect_plans`): a file the commit REPLACED goes immediately, and
nothing else goes at all — see the table above; only a per-dialog staging folder
ages out. Growth is bounded at the door instead, by `plans.check_quota` on every
upload (store size, file count, free disk), because a limit that deletes is how
plans were lost twice. The `.` between id and token is load-bearing —
a space id cannot contain one, so `<space>.<token>.<ext>` can never be confused
with the files of a space whose name merely starts the same way.
**An internal plan url must exist when it is stored** (HP-1470-02). The picker
can attach a plan and then delete it, and two clients can do the same in either
order — the write lock orders the requests but says nothing about whether the
file survived. `config/set` therefore checks every `/api/houseplan/content/plans/`
url against the disk before saving, and refuses with `missing_plan`. External and
legacy urls are the user's own and are never second-guessed.
**Signed content urls are batched, aged and deduplicated** (reviews R2-2, R3-2, R4-2). `ContentSigner`
in `src/signing.ts` is the single implementation, used by both cards; the
duplicate inside houseplan-space-card signed correctly and never handed the
@@ -270,7 +321,7 @@ Shared, framework-light modules keep the two views from diverging:
`roomCenter`, `defaultPositions`, `markerPos`, `labelPos`; no Lit import) — unit-tested,
mirrors the full card's private geometry.
- `src/space-render.ts` — `renderSpaceStatic()` draws the plan + configured room
borders/names + device markers (via `buildDevices`, same curation) with NO handlers,
borders/names + device markers (via `buildDevices`, same filtering) with NO handlers,
NO live states, NO status/temperature fills. Uses the same CSS classes as the full card
(the space-card imports `cardStyles`) for visual parity.
- `src/config-store.ts` — module-level `{config, rev, layout}` cache shared by all embedded
+144
View File
@@ -1,5 +1,149 @@
# Changelog
## v1.49.0 — 2026-07-28
**The canvas is square** (see v1.48.0, released together with this one).
- **Zoom opens on what is drawn, not on the whole canvas.** A space without a
background image now fits its rooms with a 5% margin, so a small plan on a big
canvas fills the screen instead of sitting in the middle of it as a speck.
With a background image nothing changes: the image is the plan, and cropping
to the rooms would hide the parts nobody has outlined yet.
- **Switching spaces by swipe, or on the kiosk carousel, slides.** The plan
leaves the way the finger went and the next one arrives from the other side.
Respects "reduce motion".
- The room settings button says "Room settings" rather than just "Room", and
lightens slightly under the cursor.
- "Curation" is called filtering everywhere — the interface, the documentation
and the code.
**From the v1.47.0 review**
- **A plan you have just picked can no longer be deleted from the same dialog
(HP-1470-02).** It was not saved yet, so the server correctly considered it
free — and the save then stored a url with no file behind it. The button is
disabled now, and, because two clients can do the same in either order, the
server checks every internal plan url a configuration adds against the disk
and refuses a new reference that is already broken. A url the stored
configuration already carries is let through — a file can vanish from outside
Home Assistant, and refusing then would block the very edit that detaches it.
Urls that are not ours are left alone.
- **Uploads are bounded (HP-1470-01).** Nothing is deleted for being old — that
cost real plans twice — so the limit sits where a decision is being made
anyway: an upload is refused if the store would pass 256 MB or 200 plans
(1 GB / 1000 for attachments), or if the disk would drop below 512 MB free.
The plan list is capped at the 60 newest and its thumbnails load lazily.
- **Picking a saved plan reads its real proportions (HP-1470-03).** The card
waited for nothing and, when the signature for the protected url had not
arrived yet, saved a fallback ratio — a square plan came out stretched. It now
waits for the signature, ties the result to the dialog that asked, and the
preview in the dialog is signed like everything else.
## v1.48.0 — 2026-07-28 (the canvas is always square)
- **A space no longer has proportions of its own.** The drawing area is a square;
a plan image keeps its own shape and is centred inside it, so a wide plan gets
margins above and below and a tall one gets them at the sides. There is
nothing left to choose — the canvas orientation setting for hand-drawn spaces
is gone with it.
- **Existing plans are migrated once, on upgrade.** Nothing about a drawing
changes: the box is padded out to a square and every coordinate is
re-expressed against it — rooms, doors and windows, decor, marker positions
and the saved viewport. Angles, room proportions and relative positions are
preserved exactly. For a tall plan the scale in centimetres per grid cell is
adjusted along with it, because the grid is tied to the width; without that a
wall would silently measure less than it does.
## v1.47.0 — 2026-07-28 (pick a plan you already uploaded)
- **The space dialog can now show the plans stored on the server.** Detaching a
plan keeps the image on disk — that has been the rule since v1.46.4, but until
now the only way back was to find the original file on your computer and
upload it again. "Already uploaded" lists what is there, with a thumbnail, the
file size and which space uses it. One click attaches it; the aspect ratio is
read from the image, exactly as on upload.
- **And it is where you delete one.** A plan file is never removed automatically
— not for being detached, not for being old — which is only a sensible policy
if you can see what is being kept and get rid of it deliberately. The trash
button does that, and refuses while a space still uses the plan: the answer to
"may this go" comes from the stored configuration, not from the browser.
- Documentation caught up with the code: several comments still described the
age-based collection that v1.46.6 removed.
## v1.46.6 — 2026-07-28 (the detach promise, actually kept this time)
- **Switching a space to "draw" no longer deletes its image.** v1.46.4 and
v1.46.5 said it did not, and the scheduled cleanup indeed left detached plans
alone — but the save itself deleted the file the moment the reference was
cleared, before any of those guards were reached. The cause: a file that left
the configuration was called "superseded", and from that difference alone
replacing a plan, detaching one and deleting its space are indistinguishable.
Only the first is a deletion anybody asked for. The transition is now
classified by the space that owned the file, and the same distinction applies
to attachments: dropping one from a device that still exists removes it,
deleting the device keeps its manuals.
- **A plan whose space was deleted is kept**, rather than the thirty days
v1.46.5 promised — thirty days measured from the file's age is meaningless
anyway, since it was usually uploaded months earlier.
- **Nothing is deleted for being old any more**, except a per-dialog staging
folder. The rule that aged out "rejected uploads" turned out to race a retry:
the cleanup removed the file from a failed save while the next attempt was
committing a reference to it. A rule that can delete a file somebody is about
to point at is not worth the disk it reclaims. Files therefore go when an
action says so, and otherwise stay.
## v1.46.5 — 2026-07-28 (audit of every automatic deletion)
- **A detached plan is never deleted, at any age.** v1.46.4 gave it a month;
this makes it permanent and writes the reason down where the next change will
see it. The rule, now in docs/SCOPE.md: the component may delete a file only
when a user action says so — replacing a plan, removing an attachment,
deleting a device. "Nothing points at this any more" is not such an action.
The errors are not symmetrical: wasted disk is visible, cheap and reversible;
a deleted file is none of those.
- **`houseplan/files/cleanup` no longer takes a folder on the client's word.**
After a device is rebound its files are copied to the new id and the old
folder is dropped — with `rmtree`, on whatever id the card sent. Two ways that
ends badly: a partial copy leaves some urls still pointing into that folder
(the migration deliberately does not rewrite those, so they were live links to
files being deleted), and a wrong or stale id from any client would destroy a
live device's manuals. The server now checks the stored configuration itself,
under the config lock, and removes only files nothing references.
- **A plan of a space that was deleted waits thirty days instead of an hour.**
Deleting a space is deliberate, but an hour is a short window in which to
notice it was a misclick.
## v1.46.4 — 2026-07-28 (data loss: detached plans were collected as garbage)
- **A plan you detach is no longer deleted an hour later.** Switching a space to
"draw" clears the reference and, as the editor has always said, leaves the
image on disk so you can put it back. The collection added in v1.46.0 did not
make that distinction: it treated "nothing points at this right now" as
abandoned and applied a one-hour rule. On the author's own instance the
scheduled pass then removed two floor plans that had been detached weeks
earlier, with no way to get them back. If you have detached a plan since
v1.46.0 and your instance restarted or ran for a day, check
`config/houseplan/plans/` before updating anything else — and please report it
in the Telegram chat if a file is missing.
The rule now: **a commit still removes exactly what it replaced**, because
that it knows for certain. Beyond that the question is whether "unreferenced"
means "abandoned", and the answer depends on the case. A space with no plan at
all has had one detached and may want it back — its files are never collected.
A space that does have a plan can only be holding rejected uploads of its own,
so those still go after an hour. Attachments outside a per-dialog staging
folder wait a month; a staging folder, which by construction only ever holds
an upload from a dialog that was never saved, keeps the one-hour rule.
## v1.46.3 — 2026-07-28 (re-check of v1.46.2: HP-1462-01)
- **The cleanup at startup now actually cleans up.** It looked its own runtime
data up by domain, and during startup Home Assistant does not yet consider
the integration loaded — so the lookup came back empty and the pass quietly
degraded to removing half-finished transfers, leaving the real work to a timer
24 hours away. Restart more often than that and it never ran at all. It uses
the object it was given at startup now.
- **The test that was supposed to prove this was passing for the wrong
reason.** It created the stray files *before* saving the configuration — and
saving collects too, so everything was already gone by the time the restart
happened. Rewritten to seed after the save, plus a second test that fires the
scheduled timer on its own, and a third that runs a restart and a save at the
same time and asserts the accepted configuration never points at a file the
cleanup removed.
## v1.46.2 — 2026-07-28 (re-check of v1.46.1: HP-1461-01, -02)
- **A file nobody ended up using is now cleaned up even if nothing is ever
saved again (HP-1461-01).** Collection is tied to a configuration write,
+144
View File
@@ -6,6 +6,150 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
## v1.49.0 — 2026-07-28
**Холст стал квадратным** (см. v1.48.0, выпущена вместе с этой).
- **Масштаб открывается по нарисованному, а не по всему холсту.** Пространство
без подложки теперь вписывается по границам своих комнат с полями 5%: маленький
план на большом холсте заполняет экран, а не сидит посередине точкой. С
подложкой ничего не меняется — картинка и есть план, и обрезать её по комнатам
значило бы спрятать то, что ещё не обведено.
- **Переключение пространств свайпом и в киоске стало с анимацией.** План
уезжает туда, куда пошёл палец, следующий приходит с другой стороны.
Уважает системную настройку «уменьшить движение».
- Кнопка настроек комнаты подписана «Настройки комнаты», а не просто «Комната»,
и слегка светлеет под курсором.
- Слово «курирование» заменено на «фильтрацию» — в интерфейсе, документации и
коде.
**По ревью v1.47.0**
- **Только что выбранный план больше нельзя удалить из того же диалога
(HP-1470-02).** Он ещё не сохранён, поэтому сервер справедливо считал его
свободным — а сохранение потом записывало ссылку, за которой нет файла.
Кнопка заблокирована, и, поскольку два клиента могут сделать это в любом
порядке, сервер сверяет с диском каждую внутреннюю ссылку, которую
конфигурация добавляет, и отказывает, если файла нет. Ссылку, уже записанную
в конфигурации, он пропускает: файл может исчезнуть и мимо Home Assistant, а
отказ заблокировал бы ровно ту правку, которая его отцепляет. Чужие ссылки не
трогаются.
- **Загрузки ограничены (HP-1470-01).** По возрасту не удаляется ничего — это
дважды стоило настоящих планов, — поэтому предел стоит там, где решение и так
принимается: загрузка отклоняется, если хранилище перевалит за 256 МБ или 200
планов (1 ГБ и 1000 для вложений) либо если на диске останется меньше 512 МБ.
Список планов отдаётся по 60 самых свежих, миниатюры грузятся лениво.
- **Выбор сохранённого плана читает его настоящие пропорции (HP-1470-03).**
Карточка ничего не ждала и, если подпись для защищённой ссылки ещё не пришла,
записывала пропорции «по умолчанию» — квадратный план получался растянутым.
Теперь она дожидается подписи, привязывает результат к тому диалогу, который
спрашивал, а превью в диалоге подписывается, как и всё остальное.
## v1.48.0 — 2026-07-28 (холст всегда квадратный)
- **У пространства больше нет собственных пропорций.** Область рисования —
квадрат, а картинка плана сохраняет свою форму и вписывается в него по
центру: у широкого плана появляются поля сверху и снизу, у вытянутого — по
бокам. Выбирать нечего, поэтому настройка ориентации холста для пространств
без картинки убрана.
- **Существующие планы переносятся один раз, при обновлении.** В самом рисунке
ничего не меняется: коробка дополняется до квадрата, и все координаты
пересчитываются относительно неё — комнаты, двери и окна, декор, позиции
значков и сохранённая область просмотра. Углы, пропорции комнат и взаимное
расположение сохраняются точно. Для вытянутых планов заодно пересчитывается
масштаб в сантиметрах на клетку: сетка привязана к ширине, и без этого стены
молча стали бы короче.
## v1.47.0 — 2026-07-28 (выбор из уже загруженных планов)
- **Диалог пространства показывает планы, сохранённые на сервере.** Отцепление
плана оставляет картинку на диске — так с v1.46.4, но вернуть её можно было
только найдя исходный файл у себя и загрузив заново. «Уже загруженные»
показывают, что есть: миниатюра, размер файла и то, какое пространство его
использует. Клик прикрепляет, пропорции читаются из самой картинки — так же,
как при загрузке.
- **Там же план и удаляется.** Файл плана никогда не удаляется автоматически —
ни за отцепление, ни за возраст, — и это разумная политика ровно до тех пор,
пока видно, что именно хранится, и есть способ убрать это осознанно. Кнопка
корзины делает это и отказывает, пока план используется пространством: ответ
на вопрос «можно ли удалить» даёт сохранённая конфигурация, а не браузер.
- Документация догнала код: несколько комментариев всё ещё описывали удаление по
возрасту, убранное в v1.46.6.
## v1.46.6 — 2026-07-28 (обещание про отцепление, теперь выполненное)
- **Переключение пространства в «нарисовать» больше не удаляет его картинку.**
v1.46.4 и v1.46.5 утверждали, что не удаляет, и плановая уборка действительно
отцеплённые планы не трогала — но само сохранение удаляло файл в тот момент,
когда снималась ссылка, ещё до всех этих проверок. Причина: файл, покинувший
конфигурацию, считался «заменённым», а по одной этой разнице замену плана,
отцепление и удаление пространства различить невозможно. Удалением, о котором
просили, является только первое. Теперь переход классифицируется по
пространству-владельцу, и та же разница применяется к вложениям: убрали файл у
существующего устройства — он удаляется, удалили устройство — его инструкции
остаются.
- **План удалённого пространства сохраняется**, а не тридцать дней, как обещала
v1.46.5: тридцать дней по возрасту файла всё равно бессмысленны — обычно он
загружен месяцы назад.
- **По возрасту больше не удаляется ничего**, кроме промежуточной папки диалога.
Правило, которое вычищало «отвергнутые загрузки», оказалось в гонке с
повторной попыткой: уборка удаляла файл неудавшегося сохранения ровно тогда,
когда следующая попытка коммитила ссылку на него. Правило, способное удалить
файл, на который кто-то вот-вот сошлётся, не стоит освобождаемого места.
Файлы уходят по действию, в остальных случаях остаются.
## v1.46.5 — 2026-07-28 (ревизия всех автоматических удалений)
- **Отцеплённый план не удаляется никогда, ни в каком возрасте.** В v1.46.4 ему
давался месяц; теперь это навсегда, и причина записана там, где её увидит
следующая правка. Правило, оно же в docs/SCOPE.md: компонент вправе удалить
файл только тогда, когда об этом говорит действие пользователя — замена
плана, удаление вложения, удаление устройства. «На это больше никто не
ссылается» таким действием не является. Ошибки несимметричны: занятое зря
место видно, стоит копейки и обратимо; удалённый файл — ничего из этого.
- **`houseplan/files/cleanup` больше не сносит папку по слову клиента.** После
перепривязки устройства файлы копируются под новый id, а старая папка
удалялась — через `rmtree`, по тому id, который прислала карточка. Два плохих
исхода: при частичном копировании часть ссылок продолжает указывать внутрь
этой папки (миграция намеренно их не переписывает — то есть это были живые
ссылки на удаляемые файлы), а неверный или устаревший id от любого клиента
уничтожил бы инструкции существующего устройства. Теперь сервер сам сверяется
с сохранённой конфигурацией, под её блокировкой, и удаляет только то, на что
никто не ссылается.
- **План удалённого пространства ждёт тридцать дней вместо часа.** Удаление
пространства осознанно, но час — короткое окно, чтобы заметить промах.
## v1.46.4 — 2026-07-28 (потеря данных: отцеплённые планы убирались как мусор)
- **Отцеплённый план больше не удаляется через час.** Переключение пространства
в режим «нарисовать» снимает ссылку и, как редактор всегда и говорил,
оставляет картинку на диске, чтобы её можно было вернуть. Уборка, добавленная
в v1.46.0, этой разницы не делала: считала «на файл сейчас никто не
ссылается» синонимом «файл брошен» и применяла часовое правило. На установке
автора плановый проход в итоге удалил два плана этажей, отцеплённых
несколькими неделями раньше, — восстановить их было нечем. Если вы отцепляли
план после v1.46.0 и инстанс перезапускался или проработал сутки — загляните в
`config/houseplan/plans/` и напишите в Telegram-чат, если файла нет.
Правило теперь такое: **коммит по-прежнему удаляет ровно то, что заменил**, —
это он знает наверняка. Дальше вопрос в том, означает ли «непривязан»
«брошен», и ответ зависит от случая. У пространства, у которого плана нет
вовсе, его отцепили — и, возможно, вернут: его файлы не удаляются никогда. У
пространства, у которого план есть, лишние файлы могут быть только его же
отвергнутыми загрузками — они по-прежнему уходят через час. Вложения вне
промежуточной папки диалога ждут месяц; сама промежуточная папка, где по
построению лежит только загрузка из несохранённого диалога, сохраняет часовое
правило.
## v1.46.3 — 2026-07-28 (перепроверка v1.46.2: HP-1462-01)
- **Уборка при старте действительно убирает.** Она искала свои же runtime-данные
по домену, а во время запуска Home Assistant ещё не считает интеграцию
загруженной — поэтому поиск возвращал пустоту, и проход тихо вырождался в
удаление незавершённых передач, оставляя настоящую работу таймеру через
24 часа. При перезапусках чаще, чем раз в сутки, она не выполнялась вообще.
Теперь используется объект, который у неё и так был на руках.
- **Тест, который должен был это доказать, проходил по неверной причине.** Он
создавал лишние файлы *до* сохранения конфигурации, а сохранение тоже
собирает мусор — так что к моменту перезапуска убирать было уже нечего.
Переписан: файлы создаются после сохранения; добавлен второй тест, который
дёргает плановый таймер отдельно, и третий, который запускает перезапуск и
сохранение одновременно и проверяет, что принятая конфигурация никогда не
ссылается на удалённый уборкой файл.
## v1.46.2 — 2026-07-28 (перепроверка v1.46.1: HP-1461-01, -02)
- **Файл, который в итоге никому не понадобился, убирается, даже если больше
ничего не сохраняют (HP-1461-01).** Сборка привязана к записи конфигурации —
+13 -1
View File
@@ -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
View File
@@ -15,12 +15,12 @@
| Item | State |
|---|---|
| Version | **v1.46.2** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Version | **v1.49.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
| GitHub | https://github.com/Matysh/houseplan-card — **`main` 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.2** via direct copy (HACS custom repo also installed) |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.49.0** 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 |
+51 -7
View File
@@ -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,11 +239,55 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
on the plan after a reload. Same for each tap action and each fill mode
[auto: backend test_every_display_mode_the_editor_offers_is_accepted and
neighbours, test_a_marker_showing_its_value_can_be_saved]
- [ ] Nothing accumulates on an idle instance (v1.46.2, HP-1461-01): attach a
file, cancel the dialog, and do not save anything else — an hour later (or
after a restart) the file is gone, while every file the configuration
still references is untouched
[auto: backend test_scheduled_sweep_collects_what_no_commit_will]
- [ ] Zoom opens on the content (v1.49.0): a space with no background and one
small room opens with that room filling the screen, with a small margin.
With a background it still fits the whole image
[auto: unit: contentBounds]
- [ ] Deleting a picked plan is refused (v1.49.0, HP-1470-02): pick a saved
plan, reopen the list — its delete button is disabled. Ask the server to
store a plan url whose file is gone: `missing_plan`, and the revision does
not move [auto: smoke_saved_plans + backend
test_config_set_refuses_a_plan_that_no_longer_exists]
- [ ] Uploads are bounded (v1.49.0, HP-1470-01): past the store quota an upload
is refused with a clear error and the disk does not grow; the plan list
returns the newest 60 with a total
[auto: unit: test_check_quota_counts_the_whole_store_not_one_request,
backend test_uploads_are_bounded_by_a_store_quota]
- [ ] Square canvas migration (v1.48.0): after the upgrade every existing plan
looks exactly as before, just with margins where the canvas was extended.
Measure a wall in the plan editor — the length in cm is unchanged. Marker
positions, doors, decor and the saved zoom are all where they were
[auto: unit: test_a_wide_plan_gains_margins_above_and_below and neighbours,
test_migration_preserves_real_lengths_and_shapes]
- [ ] A plan image is centred (v1.48.0): a wide image sits in the middle with
empty bands above and below, a tall one with bands at the sides, and it is
never stretched [auto: unit: fitInSquare + smoke_space_settings]
- [ ] Re-attaching a detached plan (v1.47.0): detach a plan, save, RELOAD THE
PAGE, open space settings → "Already uploaded" → the image is listed with
its size and no "in use" note → attach it → it renders. The one a space
uses shows that space and cannot be deleted; a free one can, with a
confirm, and disappears from the list
[auto: smoke_saved_plans + backend test_stored_plans_can_be_listed_and_deleted_on_request]
- [ ] Detaching a plan keeps the file (v1.46.6): switch a space to "draw" and
SAVE — the image is still in `config/houseplan/plans/` right afterwards,
and after a restart, and can be re-attached. Deleting the space keeps it
too. Replacing a plan still removes the one it replaced, immediately.
Check straight after the save: the earlier bug deleted the file at that
moment, while every scheduled-pass test passed
[auto: unit: test_plan_collection_matrix, test_attachment_collection_matrix,
backend test_detaching_a_plan_keeps_the_file]
- [ ] Rebinding a device does not eat its manuals (v1.46.5): attach two files to
a device, rebind it to another HA device — both are readable afterwards.
If a copy failed, the file it failed on is still there rather than deleted
with the folder [auto: backend test_files_cleanup_keeps_referenced_files]
- [ ] Nothing accumulates on an idle instance (v1.46.2/v1.46.3, HP-1461-01,
HP-1462-01): attach a file, cancel the dialog, and do not save anything
else — the file is gone after a restart AND after the daily pass, while
every file the configuration still references is untouched. Seed the
strays AFTER the last save, or `config/set` collects them and the check
proves nothing
[auto: backend test_startup_sweep_collects_what_no_commit_will,
test_daily_sweep_callback_collects_too, test_sweep_and_a_config_write_do_not_race]
- [ ] A drag wins over a concurrent remote move (v1.46.2, HP-1461-02): drag an
icon and, while the save is still in flight, have another window move a
different icon — your icon stays where you put it and the other one
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.46.2",
"version": "1.49.0",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+7 -7
View File
@@ -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); }
+199 -56
View File
@@ -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.2';
const CARD_VERSION = '1.49.0';
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();
}
}
@@ -277,8 +299,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;
@@ -395,6 +425,7 @@ 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);
@@ -560,7 +591,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 +601,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 +632,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 +653,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),
};
});
@@ -997,7 +1028,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 +1093,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 +1110,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 +1226,23 @@ 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;
const b = contentBounds(m);
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,7 +1254,7 @@ 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];
}
@@ -1231,7 +1274,7 @@ class HouseplanCard extends LitElement {
/** 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 w = fit.w / z, h = fit.h / z;
@@ -1254,7 +1297,7 @@ 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 w = stage.clientWidth, h = stage.clientHeight;
@@ -1282,7 +1325,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 +1348,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 +1377,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 +1423,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 +1453,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 +1510,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 +1601,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
}
/**
@@ -1818,7 +1861,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 +2145,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 +2574,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 +3029,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 +3045,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 +3059,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 +3101,109 @@ 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) }));
}
};
private _useServerPlan(url: string): void {
const d = this._spaceDialog;
if (!d) return;
// Attach immediately — the click should not wait for anything.
this._spaceDialog = { ...d, planUrl: url, planFile: null, pickSaved: false };
void 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<void> {
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;
}
await new Promise((r) => setTimeout(r, 150));
if (this._spaceDialog?.planUrl !== url) return; // the user moved on
}
}
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 +3214,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
@@ -3096,7 +3239,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 +3251,16 @@ 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
sp.plan_url = d.planUrl;
if (d.savedAspect) sp.plan_aspect = d.savedAspect;
}
// 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 +3372,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,
@@ -3737,7 +3885,7 @@ class HouseplanCard extends LitElement {
@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 +4247,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 +4323,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 } };
@@ -5128,29 +5275,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
View File
@@ -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
View File
@@ -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
View File
@@ -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',
+58 -9
View File
@@ -6,16 +6,32 @@
import { declump, contentUrl } from './logic';
import type { ServerConfig, SpaceModel, RoomCfg, DevItem } from './types';
export const NORM_W = 1000; // width of the render space for normalized configs
export const NORM_W = 1000; // side of the render space — the canvas is square
/**
* Where a plan image sits inside the square canvas (v1.48.0).
*
* The canvas has no proportions of its own any more; the image keeps its own
* and is centred, so a wide plan gets margins above and below and a tall one
* gets them at the sides. `ratio` is the image's width/height; without it we
* assume square, which is only ever a brief guess before the file loads.
*/
export function fitInSquare(ratio: number | null | undefined, side: number) {
const r = Number(ratio);
const a = Number.isFinite(r) && r > 0 ? r : 1;
const w = a >= 1 ? side : side * a;
const h = a >= 1 ? side / a : side;
return { x: (side - w) / 2, y: (side - h) / 2, w, h };
}
export type Pt = { x: number; y: number };
export type Layout = Record<string, { s?: string; x: number; y: number } | undefined>;
/** Build render-space models (NORM_W × NORM_W/aspect) from a server config. */
/** Build render-space models (NORM_W × NORM_W) from a server config. */
export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
if (!cfg || !Array.isArray(cfg.spaces)) return [];
return cfg.spaces.map((s: any) => {
const H = NORM_W / s.aspect;
const H = NORM_W; // square canvas
const scale = (r: any): RoomCfg => ({
id: r.id,
name: r.name,
@@ -35,13 +51,48 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
id: s.id,
title: s.title,
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
bg: s.plan_url ? { href: contentUrl(s.plan_url), x: 0, y: 0, w: NORM_W, h: H } : null,
bg: s.plan_url ? { href: contentUrl(s.plan_url), ...fitInSquare(s.plan_aspect, NORM_W) } : null,
rooms: (s.rooms || []).map(scale),
} as SpaceModel;
});
}
/** Bounding rectangle of a room (rect or polygon) in render units. */
/**
* 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,
): { x: number; y: number; w: number; h: number } | null {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const add = (x: number, y: number) => {
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
};
for (const r of space.rooms || []) {
if (r.poly) for (const p of r.poly) add(p[0], p[1]);
else if (r.x != null && r.y != null) {
add(r.x, r.y);
add(r.x + (r.w || 0), r.y + (r.h || 0));
}
}
if (minX > maxX || minY > maxY) return null;
const m = Math.max(maxX - minX, maxY - minY) * pad;
const x = minX - m, y = minY - m;
return { x, y, w: (maxX - minX) + m * 2, h: (maxY - minY) + m * 2 };
}
/** Bounding rectangle of a room (rect or polygon) in render units. *//** 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) {
const xs = r.poly.map((p) => p[0]);
@@ -91,8 +142,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 +153,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] };
+51 -1
View File
@@ -383,6 +383,21 @@ export const cardStyles = css`
gap: 0.25em;
font-size: calc(1em * var(--rl-name, 1));
}
/* Switching spaces by swipe or on the kiosk carousel: the plan flies out
the way the finger went and the next one arrives from the other side. */
@keyframes hp-slide-left {
0% { transform: translateX(22%); opacity: 0; }
100% { transform: translateX(0); opacity: 1; }
}
@keyframes hp-slide-right {
0% { transform: translateX(-22%); opacity: 0; }
100% { transform: translateX(0); opacity: 1; }
}
.zoomwrap.slide-left { animation: hp-slide-left 0.26s cubic-bezier(0.22, 0.61, 0.36, 1); }
.zoomwrap.slide-right { animation: hp-slide-right 0.26s cubic-bezier(0.22, 0.61, 0.36, 1); }
@media (prefers-reduced-motion: reduce) {
.zoomwrap.slide-left, .zoomwrap.slide-right { animation: none; }
}
.rlgearbtn {
display: inline-flex;
align-items: center;
@@ -402,7 +417,8 @@ export const cardStyles = css`
opacity: 0.92;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
}
.rlgearbtn:hover { opacity: 1; }
.rlgearbtn { transition: opacity 0.15s, filter 0.15s; }
.rlgearbtn:hover { opacity: 1; filter: brightness(1.18); }
.rlgearbtn ha-icon { --mdc-icon-size: 14px; display: inline-flex; }
.rlgear {
--mdc-icon-size: 0.9em;
@@ -1062,6 +1078,40 @@ export const cardStyles = css`
align-items: center;
gap: 10px;
}
/* the "already uploaded" picker: a plan is never deleted for being
unreferenced, so it has to be findable again */
.savedplans {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 240px;
overflow: auto;
margin: 6px 0 2px;
padding: 6px;
border: 1px solid var(--hp-line);
border-radius: 8px;
background: var(--hp-bg2, rgba(255, 255, 255, 0.03));
}
.savedplan {
display: flex;
align-items: center;
gap: 10px;
}
.savedplan.cur { outline: 1px solid var(--hp-accent); border-radius: 6px; }
.savedplan img {
width: 56px;
height: 40px;
object-fit: contain;
border: 1px solid var(--hp-line);
border-radius: 4px;
background: #fff;
flex: none;
}
.savedmeta { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.savedmeta b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.savedmeta .muted { font-size: 11px; }
.savedplan .btn.danger ha-icon { color: #f25a4a; }
.savedplan .btn[disabled] { opacity: 0.4; pointer-events: none; }
.planprev {
max-width: 120px;
max-height: 70px;
+48 -12
View File
@@ -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,26 @@ 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);
});
+384 -57
View File
@@ -176,10 +176,12 @@ async def test_files_migrate_copies_and_reports_mapping(
assert src_kept, "migrate must COPY, not move (review CR-2)"
assert other == b"OTHER" and copied == b"SOURCE"
# cleanup runs only after the config is safely committed
# cleanup runs only after the config is safely committed, and since v1.46.5
# reports how many files it removed rather than a bare boolean — it now also
# keeps anything the stored configuration still references
await client.send_json_auto_id({"type": "houseplan/files/cleanup", "marker_id": "old1"})
resp2 = await client.receive_json()
assert resp2["success"] and resp2["result"]["removed"] is True
assert resp2["success"] and resp2["result"]["removed"] >= 1 and resp2["result"]["kept"] == 0
assert not await hass.async_add_executor_job(lambda: os.path.isdir(src))
@@ -340,34 +342,41 @@ async def test_commit_does_not_collect_another_client_s_uncommitted_upload(
assert not (plans / pa).exists()
async def test_abandoned_uploads_are_collected_once_old(
async def test_a_rejected_upload_is_kept_not_aged_out(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""A rejected upload must not accumulate forever — but only age may free it."""
"""v1.46.6: age is never a reason to delete a plan file.
It used to be, for "a file of a space that has a plan and never was one" —
an upload whose save had failed. That raced the retry: the sweep removed the
file while the next save was committing a reference to it. Keeping it costs
a few megabytes nobody can lose.
"""
import os
import time
from pathlib import Path
from custom_components.houseplan.const import PLANS_DIR, PLAN_ORPHAN_TTL_S
from custom_components.houseplan.const import PLANS_DIR, SCHEDULED_GRACE_S
from custom_components.houseplan.store import get_data
await _setup(hass)
client = await hass_ws_client(hass)
plans = Path(hass.config.path(PLANS_DIR))
plans.mkdir(parents=True, exist_ok=True)
for stale in plans.glob("r3.*"):
stale.unlink()
plans = hass.config.path(PLANS_DIR)
url0, p0 = await _upload(client, "r3", b"zero")
rev = (await _save(client, await _cfg([{"id": "r3", "plan_url": url0}]), 0))["result"]["rev"]
_url, orphan = await _upload(client, "r3", b"abandoned")
old = time.time() - PLAN_ORPHAN_TTL_S - 60
os.utime(plans / orphan, (old, old))
_url, orphan = await _upload(client, "r3", b"never saved")
old = time.time() - SCHEDULED_GRACE_S * 12
os.utime(os.path.join(plans, orphan), (old, old))
ok = await _save(client, await _cfg([{"id": "r3", "plan_url": url0}]), rev)
assert ok["success"]
assert not (plans / orphan).exists(), "an aged, unreferenced upload is collected"
assert (plans / p0).is_file(), "the referenced plan is never touched"
# a commit, and the scheduled pass, and any amount of age: it stays
assert (await _save(client, await _cfg([{"id": "r3", "plan_url": url0}]), rev))["success"]
data = get_data(hass)
await data.sweep()
await hass.async_block_till_done()
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, orphan))
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, p0))
async def test_collection_ignores_files_that_are_not_plans(
@@ -609,7 +618,8 @@ async def test_layout_keeps_its_revision_and_announces_changes(
assert not bad["success"] and bad["error"]["code"] == "conflict"
await hass.async_block_till_done()
assert [e["rev"] for e in events] == [1, 2, 3]
# the bus does not promise ordering between separately fired events
assert sorted(e["rev"] for e in events) == [1, 2, 3]
async def test_uploaded_svg_is_sandboxed_and_a_pdf_is_not(
@@ -774,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
@@ -855,61 +879,364 @@ async def test_cancelling_an_upload_takes_its_temporary_with_it(
assert entry
async def test_scheduled_sweep_collects_what_no_commit_will(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1461-01: a commit collects what it superseded — but only when it runs.
Cancel a dialog after the file uploaded, or lose the connection right after,
and nothing references the file and no future write notices it. The daily
sweep used to remove only streaming temporaries, so the promise that a
cancelled attachment goes after an hour did not hold on an instance nobody
edits.
"""
async def _seed_aged(hass, names) -> None:
"""Create the given files and backdate them past the orphan TTL."""
import os
import time
from custom_components.houseplan.const import FILES_DIR, PLANS_DIR, PLAN_ORPHAN_TTL_S
from custom_components.houseplan.const import SCHEDULED_GRACE_S
await _setup(hass)
client = await hass_ws_client(hass)
files = hass.config.path(FILES_DIR)
plans = hass.config.path(PLANS_DIR)
old = time.time() - PLAN_ORPHAN_TTL_S - 60
old = time.time() - SCHEDULED_GRACE_S - 60 # past every grace
def _seed() -> None:
os.makedirs(os.path.join(files, "m5"), exist_ok=True)
os.makedirs(os.path.join(files, "up_cancelled"), exist_ok=True)
os.makedirs(plans, exist_ok=True)
for path in (
os.path.join(files, "m5", "kept.pdf"),
os.path.join(files, "up_cancelled", "manual.pdf"),
os.path.join(plans, "s5.tok.png"),
os.path.join(plans, "s5.orphan.png"),
):
def _do() -> None:
for path in names:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as fh:
fh.write(b"x")
os.utime(path, (old, old))
await hass.async_add_executor_job(_seed)
await hass.async_add_executor_job(_do)
def _paths(hass):
import os
from custom_components.houseplan.const import FILES_DIR, PLANS_DIR
files = hass.config.path(FILES_DIR)
plans = hass.config.path(PLANS_DIR)
return files, plans, {
"kept_file": os.path.join(files, "m5", "kept.pdf"),
"kept_plan": os.path.join(plans, "s5.tok.png"),
"orphan_file": os.path.join(files, "up_cancelled", "manual.pdf"),
# a plan file is never collected by age any more; keep one around and
# assert exactly that
"kept_reject": os.path.join(plans, "s5.reject.png"),
}
async def _referenced_config() -> dict:
cfg = await _cfg([{"id": "s5", "plan_url": "/api/houseplan/content/plans/_/s5.tok.png"}])
cfg["markers"] = [
{"id": "m5", "binding": "virtual",
"pdfs": [{"name": "k", "url": "/api/houseplan/content/files/m5/kept.pdf"}]}
]
assert (await _save(client, cfg, 0))["success"]
return cfg
async def _assert_swept(hass, p) -> None:
import os
assert await hass.async_add_executor_job(os.path.isfile, p["kept_file"]), "referenced file kept"
assert await hass.async_add_executor_job(os.path.isfile, p["kept_plan"]), "referenced plan kept"
assert not await hass.async_add_executor_job(os.path.isfile, p["orphan_file"]), (
"a staging folder from a dialog nobody saved is the one thing age collects"
)
assert await hass.async_add_executor_job(os.path.isfile, p["kept_reject"]), (
"a plan file is never removed for being old"
)
async def test_startup_sweep_collects_what_no_commit_will(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1461-01 / HP-1462-01: collection must not depend on a future save.
The files are seeded AFTER the configuration is stored. The previous version
of this test seeded them before, and `config/set` collects too — so it
passed without the startup pass doing anything, hiding HP-1462-01: during
setup the entry is not "loaded" yet, so looking its runtime data up by
domain returned None and the pass degraded to removing streaming
temporaries only.
"""
import os
await _setup(hass)
client = await hass_ws_client(hass)
files, _plans, p = _paths(hass)
# the plan it names has to exist: config/set refuses a NEW broken
# reference (HP-1470-02). The orphans still arrive after the save.
await _seed_aged(hass, [p["kept_plan"]])
assert (await _save(client, await _referenced_config(), 0))["success"]
await _seed_aged(hass, list(p.values()))
# reload the entry: that is what runs the sweep at startup
entry = hass.config_entries.async_entries(DOMAIN)[0]
assert await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
exists = lambda p: os.path.isfile(p) # noqa: E731
assert await hass.async_add_executor_job(exists, os.path.join(files, "m5", "kept.pdf"))
assert await hass.async_add_executor_job(exists, os.path.join(plans, "s5.tok.png"))
await _assert_swept(hass, p)
assert not await hass.async_add_executor_job(
exists, os.path.join(files, "up_cancelled", "manual.pdf")
), "an aged cancelled attachment is collected without any further config write"
assert not await hass.async_add_executor_job(exists, os.path.join(plans, "s5.orphan.png"))
assert not await hass.async_add_executor_job(os.path.isdir, os.path.join(files, "up_cancelled"))
os.path.isdir, os.path.join(files, "up_cancelled")
), "the emptied staging folder goes with its last file"
async def test_periodic_sweep_collects_too(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""The scheduled pass, invoked directly rather than by faking a 24 h jump.
Firing a time change proves the timer fires; awaiting the callback proves it
does the work. This asserts the second, which is the part that regressed.
"""
from custom_components.houseplan.store import get_data
await _setup(hass)
client = await hass_ws_client(hass)
_files, _plans, p = _paths(hass)
# the plan it names has to exist: config/set refuses a NEW broken
# reference (HP-1470-02). The orphans still arrive after the save.
await _seed_aged(hass, [p["kept_plan"]])
assert (await _save(client, await _referenced_config(), 0))["success"]
await _seed_aged(hass, list(p.values()))
data = get_data(hass)
assert data is not None and data.sweep is not None, "setup must publish the sweep"
await data.sweep()
await hass.async_block_till_done()
await _assert_swept(hass, p)
async def test_sweep_and_a_config_write_do_not_race(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Both take the same write lock, so an accepted config cannot lose a file.
Without it the sweep could decide a file is unreferenced, a commit could
start referencing it, and the file would go — leaving the accepted revision
pointing at nothing.
"""
import asyncio
import os
await _setup(hass)
client = await hass_ws_client(hass)
_files, plans, p = _paths(hass)
# the plan it names has to exist: config/set refuses a NEW broken
# reference (HP-1470-02). The orphans still arrive after the save.
await _seed_aged(hass, [p["kept_plan"]])
rev = (await _save(client, await _referenced_config(), 0))["result"]["rev"]
# an aged, currently unreferenced plan that the commit below adopts
newcomer = os.path.join(plans, "s5.newcomer.png")
await _seed_aged(hass, [p["kept_file"], p["kept_plan"], newcomer])
cfg2 = await _referenced_config()
cfg2["spaces"][0]["plan_url"] = "/api/houseplan/content/plans/_/s5.newcomer.png"
# Drive the sweep directly rather than through a reload: an entry reload has
# an unload window in which any WS call legitimately answers `not_ready`, so
# a save racing THAT proves nothing about the lock and fails at random.
from custom_components.houseplan.store import get_data
data = get_data(hass)
assert data is not None and data.sweep is not None
_swept, saved = await asyncio.gather(data.sweep(), _save(client, cfg2, rev))
await hass.async_block_till_done()
# assert the CONCRETE outcome: a save that came back `not_ready` would leave
# the old config pointing at the old file and satisfy a vaguer check
assert saved["success"], saved.get("error")
await client.send_json_auto_id({"type": "houseplan/config/get"})
stored = (await client.receive_json())["result"]["config"]
assert stored["spaces"][0]["plan_url"].endswith("s5.newcomer.png")
assert await hass.async_add_executor_job(
os.path.isfile, os.path.join(plans, "s5.newcomer.png")
), "the file the accepted config points at must exist"
async def test_files_cleanup_keeps_referenced_files(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""v1.46.5: the client may say what it no longer needs, never what may go.
`files/cleanup` used to rmtree the folder it was handed. A partial migration
leaves some urls pointing into that folder — the copy deliberately does not
rewrite the ones it could not confirm — so those were live links to files
being deleted. A wrong id from any client had the same effect on a device's
manuals.
"""
import os
from custom_components.houseplan.const import FILES_DIR
await _setup(hass)
client = await hass_ws_client(hass)
folder = os.path.join(hass.config.path(FILES_DIR), "m7")
def _seed() -> None:
os.makedirs(folder, exist_ok=True)
for n in ("kept.pdf", "orphan.pdf"):
with open(os.path.join(folder, n), "wb") as fh:
fh.write(b"x")
await hass.async_add_executor_job(_seed)
cfg = await _cfg([{"id": "s7", "plan_url": None}])
cfg["markers"] = [
{"id": "other", "binding": "virtual",
"pdfs": [{"name": "k", "url": "/api/houseplan/content/files/m7/kept.pdf"}]}
]
assert (await _save(client, cfg, 0))["success"]
await client.send_json_auto_id({"type": "houseplan/files/cleanup", "marker_id": "m7"})
resp = await client.receive_json()
assert resp["success"] and resp["result"] == {"ok": True, "removed": 1, "kept": 1}
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(folder, "kept.pdf")), (
"a file the configuration still references survives a cleanup of its folder"
)
assert not await hass.async_add_executor_job(os.path.isfile, os.path.join(folder, "orphan.pdf"))
async def test_detaching_a_plan_keeps_the_file(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1465-01, through the real save — where the earlier tests never looked.
Every check for this lived in the pure collector with old and new config
equal, i.e. the scheduled pass. The transition that matters is a save, and
there the file was deleted the moment the reference was cleared.
"""
import os
from custom_components.houseplan.const import PLANS_DIR
await _setup(hass)
client = await hass_ws_client(hass)
plans = hass.config.path(PLANS_DIR)
url, name = await _upload(client, "d1", b"PLAN", ext="png")
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": url}]), 0))["result"]["rev"]
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name))
# detach: the space stays, its plan does not
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": None}]), rev))["result"]["rev"]
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name)), (
"the editor says the image stays on disk — it has to actually stay"
)
# a restart does not change its mind either
entry = hass.config_entries.async_entries(DOMAIN)[0]
assert await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name))
# re-attach, then replace: THAT removes the one it replaced
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": url}]), rev))["result"]["rev"]
url2, name2 = await _upload(client, "d1", b"NEWPLAN", ext="png")
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": url2}]), rev))["result"]["rev"]
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name2))
assert not await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name))
# and deleting the space keeps its plan
await _save(client, await _cfg([]), rev)
await hass.async_block_till_done()
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name2))
async def test_stored_plans_can_be_listed_and_deleted_on_request(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1466-01/-02: "we never delete" needs the files to be findable.
A detached plan stays on disk. Without a way to see it, that is neither a
recovery path nor a disk policy — it is just accumulation. Listing gives
both: the user attaches it again, or deletes it on purpose, which is the
only way a plan file is ever removed.
"""
await _setup(hass)
client = await hass_ws_client(hass)
used_url, used = await _upload(client, "p1", b"USED", ext="png")
_free_url, free = await _upload(client, "p2", b"FREE", ext="png")
rev = (await _save(client, await _cfg([{"id": "p1", "plan_url": used_url}]), 0))["result"]["rev"]
await client.send_json_auto_id({"type": "houseplan/plans/list"})
# the HA test config dir is shared across the module: look at ours, not all
plans = {p["name"]: p for p in (await client.receive_json())["result"]["plans"]}
assert {used, free} <= set(plans)
assert plans[used]["used_by"] and not plans[free]["used_by"]
assert plans[used]["size"] == 4 and plans[free]["url"].endswith(free)
# a plan a space still uses is refused — the config decides, not the client
await client.send_json_auto_id({"type": "houseplan/plans/delete", "name": used})
resp = await client.receive_json()
assert not resp["success"] and resp["error"]["code"] == "in_use"
# an unused one goes on request
await client.send_json_auto_id({"type": "houseplan/plans/delete", "name": free})
assert (await client.receive_json())["result"]["removed"] is True
# detach the other, and now it is deletable — and listed as free until then
await _save(client, await _cfg([{"id": "p1", "plan_url": None}]), rev)
await client.send_json_auto_id({"type": "houseplan/plans/list"})
plans = {p["name"]: p for p in (await client.receive_json())["result"]["plans"]}
assert used in plans, "the detached plan is still there, ready to re-attach"
assert not plans[used]["used_by"]
assert free not in plans, "and the one we deleted is gone"
# nothing outside the plans folder can be reached through the name
await client.send_json_auto_id(
{"type": "houseplan/plans/delete", "name": "../../configuration.yaml"}
)
bad = await client.receive_json()
assert not bad["success"] and bad["error"]["code"] == "invalid_name"
async def test_config_set_refuses_a_plan_that_no_longer_exists(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1470-02: a stored internal plan url must name a file that exists.
The card can pick a plan and delete it from the same dialog, and two clients
can do the same in either order. The lock serialises them; it says nothing
about whether the file survived, so the check has to be here.
"""
await _setup(hass)
client = await hass_ws_client(hass)
url, name = await _upload(client, "x1", b"PLAN", ext="png")
await client.send_json_auto_id({"type": "houseplan/plans/delete", "name": name})
assert (await client.receive_json())["result"]["removed"] is True
resp = await _save(client, await _cfg([{"id": "x1", "plan_url": url}]), 0)
assert not resp["success"] and resp["error"]["code"] == "missing_plan"
# an external or legacy url is the user's business, not ours to verify
assert (await _save(client, await _cfg([{"id": "x1", "plan_url": "/local/mine.png"}]), 0))["success"]
async def test_uploads_are_bounded_by_a_store_quota(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch
) -> None:
"""HP-1470-01: nothing is deleted for being old, so growth stops at the door."""
from custom_components.houseplan import websocket_api as wsapi
from pathlib import Path
from custom_components.houseplan.const import PLANS_DIR
from custom_components.houseplan.plans import dir_usage
await _setup(hass)
client = await hass_ws_client(hass)
# every test in this module shares one config directory, so the plans folder
# is not empty here — budget two more from whatever is already stored
stored, _b = await hass.async_add_executor_job(
lambda: dir_usage(Path(hass.config.path(PLANS_DIR)))[::-1]
)
monkeypatch.setattr(wsapi, "MAX_PLANS_FILES", stored + 2)
await _upload(client, "q1", b"one")
await _upload(client, "q1", b"two")
import base64
await client.send_json_auto_id({
"type": "houseplan/plan/set", "space_id": "q1", "ext": "png",
"data": base64.b64encode(b"three").decode(),
})
resp = await client.receive_json()
assert not resp["success"] and resp["error"]["code"] == "too_many_files"
+311 -40
View File
@@ -90,11 +90,19 @@ def test_room_schema_poly_or_rect():
v.ROOM_SCHEMA({"id": "r4", "name": "D", "poly": [[0, 0], [1, 1]]})
def test_space_schema_aspect_range():
ok = {"id": "f1", "title": "1", "aspect": 1.4, "view_box": [0, 0, 1, 1], "rooms": []}
v.SPACE_SCHEMA(ok)
def test_space_schema_drops_the_old_aspect_and_bounds_the_image_ratio():
"""v1.48.0: the canvas is square; only the IMAGE keeps proportions.
A stale tab may still send `aspect`. It is dropped rather than trusted —
the coordinates it arrives with were normalised against a different box, so
honouring the field would not make them right anyway.
"""
ok = {"id": "f1", "title": "1", "view_box": [0, 0, 1, 1], "rooms": []}
assert "aspect" not in v.SPACE_SCHEMA({**ok, "aspect": 1.4})
v.SPACE_SCHEMA({**ok, "plan_aspect": 1.4})
v.SPACE_SCHEMA({**ok, "plan_aspect": None})
with pytest.raises(vol.Invalid):
v.SPACE_SCHEMA({**ok, "aspect": 0})
v.SPACE_SCHEMA({**ok, "plan_aspect": 0})
with pytest.raises(vol.Invalid):
v.SPACE_SCHEMA({**ok, "view_box": [0, 0, 1]})
@@ -265,17 +273,6 @@ def test_collect_plans_keeps_a_fresh_unreferenced_upload(tmp_path):
assert (d / "f1.inflight.png").is_file()
def test_collect_plans_takes_an_aged_orphan(tmp_path):
PLAN_ORPHAN_TTL_S = const.PLAN_ORPHAN_TTL_S
collect_plans = plans.collect_plans
d = _plans(tmp_path, ["f1.keep.png"])
_plans(tmp_path, ["f1.abandoned.png"], age=PLAN_ORPHAN_TTL_S + 60)
removed = collect_plans(d, _cfg("/p/f1.keep.png"), _cfg("/p/f1.keep.png"))
assert removed == 1
assert (d / "f1.keep.png").is_file() and not (d / "f1.abandoned.png").exists()
def test_collect_plans_never_touches_a_referenced_or_foreign_file(tmp_path):
PLAN_ORPHAN_TTL_S = const.PLAN_ORPHAN_TTL_S
collect_plans = plans.collect_plans
@@ -477,31 +474,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.PLAN_ORPHAN_TTL_S - 60
os.utime(files / "m1" / "cancelled.pdf", (old, old))
assert collect_attachments(files, _acfg("m1/new.pdf"), _acfg("m1/new.pdf")) == 1
assert not (files / "m1" / "cancelled.pdf").exists()
assert (files / "m1" / "new.pdf").is_file()
def test_collect_attachments_removes_the_empty_folder_and_never_raises(tmp_path):
import os
import time
@@ -552,3 +524,302 @@ def test_legacy_segments_are_dropped_by_the_server():
"segments": [[1, 2, 3, 4]] * 100000,
})
assert "segments" not in out
def _aged(path, seconds):
import os
import time
t = time.time() - seconds
os.utime(path, (t, t))
def _sp(sid, url):
return {"id": sid, "plan_url": f"/api/houseplan/content/plans/_/{url}" if url else None}
def test_plan_collection_matrix(tmp_path):
"""Which config transition means "the user asked for this file to go"?
`old_refs - new_refs` cannot tell replace, detach and delete-space apart —
they look identical. v1.46.4/v1.46.5 added guards for detach and only ever
reached them on the scheduled pass, so the commit itself still deleted a
detached plan the moment it was detached (HP-1465-01). One case is a
deletion the user asked for; the rest are kept.
"""
collect = plans.collect_plans
d = tmp_path / "plans"
d.mkdir()
def seed(*names):
for n in names:
(d / n).write_bytes(b"x")
_aged(d / n, const.SCHEDULED_GRACE_S * 2) # old enough for any rule
# 1. replace: the user picked a different image for the same space
seed("f1.old.png", "f1.new.png")
assert collect(d, {"spaces": [_sp("f1", "f1.old.png")]},
{"spaces": [_sp("f1", "f1.new.png")]}) == 1
assert not (d / "f1.old.png").exists() and (d / "f1.new.png").is_file()
# 2. detach: same space, switched to "draw"
seed("f2.png")
assert collect(d, {"spaces": [_sp("f2", "f2.png")]},
{"spaces": [_sp("f2", None)]}) == 0
assert (d / "f2.png").is_file(), "the editor says the file stays — it stays"
# 3. the space is deleted outright
seed("f3.png")
assert collect(d, {"spaces": [_sp("f3", "f3.png")]}, {"spaces": []}) == 0
assert (d / "f3.png").is_file()
# 4. the scheduled pass, later, still keeps both
cfg = {"spaces": [_sp("f2", None)]}
assert collect(d, cfg, cfg) == 0
assert (d / "f2.png").is_file() and (d / "f3.png").is_file()
# 5. an upload whose save was rejected is kept too, at any age. Ageing those
# out raced the retry: the sweep deleted a file the save was committing a
# reference to (caught by test_sweep_and_a_config_write_do_not_race).
seed("f4.current.png", "f4.reject.png")
live = {"spaces": [_sp("f4", "f4.current.png")]}
assert collect(d, live, live) == 0
assert (d / "f4.current.png").is_file() and (d / "f4.reject.png").is_file()
# 6. the same file still referenced by another space is never touched
seed("shared.png")
assert collect(d, {"spaces": [_sp("a", "shared.png"), _sp("b", "shared.png")]},
{"spaces": [_sp("a", None), _sp("b", "shared.png")]}) == 0
assert (d / "shared.png").is_file()
def test_attachment_collection_matrix(tmp_path):
"""Removing an attachment is a trash button; deleting the device is not."""
collect = plans.collect_attachments
def case(name):
d = tmp_path / name
d.mkdir()
return d
def seed(root, folder, fname, age=None):
(root / folder).mkdir(parents=True, exist_ok=True)
p = root / folder / fname
p.write_bytes(b"x")
if age:
_aged(p, age)
return p
def cfg(*markers):
return {"markers": [
{"id": mid, "pdfs": [{"url": f"/api/houseplan/content/files/{mid}/{n}"} for n in names]}
for mid, names in markers
]}
# 1. the user removed one attachment from a device that still exists
d = case("dropped")
seed(d, "m1", "dropped.pdf")
seed(d, "m1", "kept.pdf")
assert collect(d, cfg(("m1", ["dropped.pdf", "kept.pdf"])), cfg(("m1", ["kept.pdf"]))) == 1
assert not (d / "m1" / "dropped.pdf").exists()
assert (d / "m1" / "kept.pdf").is_file()
# 2. the device itself is gone: its manuals are not ours to throw away
d = case("device_gone")
seed(d, "m2", "manual.pdf", age=const.SCHEDULED_GRACE_S * 2)
assert collect(d, cfg(("m2", ["manual.pdf"])), cfg()) == 0
assert (d / "m2" / "manual.pdf").is_file()
# and the scheduled pass, later, agrees
assert collect(d, cfg(), cfg()) == 0
assert (d / "m2" / "manual.pdf").is_file()
# 3. a dialog that was never saved, in its own staging folder
d = case("staging")
seed(d, "up_x", "manual.pdf", age=const.PLAN_ORPHAN_TTL_S + 60)
assert collect(d, cfg(), cfg()) == 1
assert not (d / "up_x").exists()
# 4. an upload into a live device's folder whose save was rejected: kept,
# for the same reason as a plan's — a retry may be about to reference it
d = case("reject")
seed(d, "m3", "current.pdf")
seed(d, "m3", "rejected.pdf", age=const.SCHEDULED_GRACE_S + 60)
live = cfg(("m3", ["current.pdf"]))
assert collect(d, live, live) == 0
assert (d / "m3" / "current.pdf").is_file()
assert (d / "m3" / "rejected.pdf").is_file()
def test_only_a_staging_folder_ages_out(tmp_path):
"""The one age rule left. Everything else waits for the user to say so."""
import os
import time
files = tmp_path / "files"
(files / "m1").mkdir(parents=True)
(files / "up_abandoned").mkdir(parents=True)
ancient = time.time() - const.SCHEDULED_GRACE_S * 12
hour_ago = time.time() - const.PLAN_ORPHAN_TTL_S - 60
for path, when in (
((files / "m1" / "ancient.pdf"), ancient),
((files / "up_abandoned" / "manual.pdf"), hour_ago),
):
path.write_bytes(b"x")
os.utime(path, (when, when))
cfg = {"markers": [{"id": "m1", "pdfs": []}]}
assert plans.collect_attachments(files, cfg, cfg) == 1
assert (files / "m1" / "ancient.pdf").is_file(), "age alone is never a reason"
assert not (files / "up_abandoned").exists(), "a cancelled dialog goes after an hour"
# ---------- square canvas migration (v1.48.0) ----------
gm = _load_pure("geometry_migration")
def _sq(space, layout=None):
cfg = {"spaces": [space]}
gm.migrate_config(cfg, layout if layout is not None else {})
return cfg["spaces"][0]
def test_the_viewport_becomes_the_whole_square():
"""The grid is drawn over the view box, and the fit fits it.
Transforming the old rectangle instead would leave the new margins outside
the canvas — no grid there and nothing to draw on — which is the room the
square canvas was meant to add.
"""
sp = _sq({"id": "f1", "aspect": 0.5, "view_box": [0.1, 0.2, 0.5, 0.5], "rooms": []})
assert sp["view_box"] == [0.0, 0.0, 1.0, 1.0]
def test_a_wide_plan_gains_margins_above_and_below():
sp = _sq({
"id": "f1", "aspect": 2.0, "cell_cm": 5, "view_box": [0, 0, 1, 1],
"rooms": [{"id": "r", "x": 0.0, "y": 0.0, "w": 1.0, "h": 1.0}],
})
r = sp["rooms"][0]
assert (r["x"], r["w"]) == (0.0, 1.0), "the width is untouched"
assert r["y"] == 0.25 and r["h"] == 0.5, "half the height, centred"
assert sp["cell_cm"] == 5, "the grid is tied to the width, which did not change"
assert "aspect" not in sp
def test_a_tall_plan_gains_margins_on_the_sides_and_rescales_the_grid():
sp = _sq({
"id": "f1", "aspect": 0.5, "cell_cm": 5, "view_box": [0, 0, 1, 1],
"rooms": [{"id": "r", "poly": [[0, 0], [1, 0], [1, 1], [0, 1]]}],
})
poly = sp["rooms"][0]["poly"]
assert [round(c, 6) for c in poly[0]] == [0.25, 0.0]
assert [round(c, 6) for c in poly[2]] == [0.75, 1.0], "half the width, centred"
assert sp["cell_cm"] == 10, "the canvas got twice as wide, so a cell is twice the cm"
def test_a_square_plan_is_left_alone():
before = {
"id": "f1", "aspect": 1.0, "cell_cm": 5, "view_box": [0, 0, 1, 1],
"rooms": [{"id": "r", "x": 0.1, "y": 0.2, "w": 0.3, "h": 0.4}],
}
sp = _sq({**before, "rooms": [dict(before["rooms"][0])]})
assert sp["rooms"][0] == before["rooms"][0]
assert sp["cell_cm"] == 5 and sp["view_box"] == [0.0, 0.0, 1.0, 1.0]
def test_migration_preserves_real_lengths_and_shapes():
"""A wall keeps its length in centimetres, and a square stays square."""
GRID = 1000.0
def wall_cm(space, p, q):
# render units per normalised unit is the canvas width, always 1000
dx = (q[0] - p[0]) * GRID
dy = (q[1] - p[1]) * GRID
pitch = GRID / 40 # whatever the grid is, the same constant both sides
return ((dx * dx + dy * dy) ** 0.5 / pitch) * float(space["cell_cm"])
for aspect in (2.0, 0.5, 0.8155784250916674, 1.4142):
# a square room, 0.2 x 0.2 of the OLD box, i.e. 200 x 200/aspect render
old = {"id": "f", "aspect": aspect, "cell_cm": 5,
"rooms": [{"id": "r", "poly": [[0.2, 0.2], [0.4, 0.2], [0.4, 0.4], [0.2, 0.4]]}]}
before_w = 0.2 * GRID
before_h = 0.2 * GRID / aspect
before_cm_w = (before_w / (GRID / 40)) * 5
sp = _sq(old)
poly = sp["rooms"][0]["poly"]
after_w = (poly[1][0] - poly[0][0]) * GRID
after_h = (poly[2][1] - poly[1][1]) * GRID
assert abs(after_w / after_h - before_w / before_h) < 1e-9, "shape preserved"
# cell_cm is stored rounded — a user reads it — so allow 0.01 cm on a
# 40 cm wall rather than pretending the scale is infinitely precise
assert abs(wall_cm(sp, poly[0], poly[1]) - before_cm_w) < 1e-2, "length in cm preserved"
def test_migration_moves_marker_positions_of_that_space_only():
layout = {
"a": {"s": "f1", "x": 0.5, "y": 0.5},
"b": {"s": "other", "x": 0.5, "y": 0.5},
"c": "not a dict",
}
cfg = {"spaces": [{"id": "f1", "aspect": 2.0, "rooms": []},
{"id": "other", "rooms": []}]}
assert gm.migrate_config(cfg, layout) is True
assert layout["a"] == {"s": "f1", "x": 0.5, "y": 0.5}, "x untouched for a wide plan"
assert layout["a"]["y"] == 0.5
assert layout["b"] == {"s": "other", "x": 0.5, "y": 0.5}, "another space is not touched"
def test_migration_runs_once_and_only_when_needed():
cfg = {"spaces": [{"id": "f1", "aspect": 2.0, "rooms": [], "cell_cm": 5}]}
assert gm.migrate_config(cfg, {}) is True
snapshot = repr(cfg)
assert gm.migrate_config(cfg, {}) is False, "already square: nothing to do"
assert repr(cfg) == snapshot
# ---------- 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"