Compare commits

...
41 Commits
Author SHA1 Message Date
Matysh 9282c28830 v1.50.0 2026-07-28 23:54:16 +03:00
Matysh 8c5d5ba5c5 v1.50.0: the v1.49.0 review (HP-1490-01..04) and the owner's zoom batch
Owner's batch (committed to dev earlier today, released here):
- devices count as content for the default zoom;
- the editor no longer shifts the plan — the stage measures its own top
  instead of assuming 118px of header;
- zoom goes out to 0.4x, centred.

From the review:
- HP-1490-01: the square-canvas migration wrote two stores in sequence, and
  the first write deleted the aspects the second needed — a crash between
  them stranded the layout in the old coordinates with nothing able to
  finish it. The intent {space: old aspect} is durable now: saved to the
  layout store before anything moves, cleared by the same write that stores
  the migrated layout, each half idempotent behind its own trigger. The
  update event fires only after both halves are on disk. Proven at the exact
  crash boundary by a harness test that fails the layout write once.
- HP-1490-02: check_quota and the file write were two executor jobs with
  nothing between them, so N parallel uploads all measured the store before
  any of them wrote. One job under a dedicated upload_lock now — narrower
  than write_lock on purpose, a directory scan must not stall config saves.
  A failed write reserves nothing.
- HP-1490-03: the content frame fed pan, zoom, clamp AND pointer maths, so
  the editors were boxed into yesterday's drawing. Edit modes measure from
  the full square; mode switches refit rather than carry a view clamped
  against the wrong base.
- HP-1490-04: Save could outrun the proportions read and ship the previous
  file's ratio. Picking a plan clears it immediately; Save awaits the
  bounded read and stores 'unknown' over a lie.
- §5: package-lock version synced, duplicated comment removed.

New: smoke_audit_1490.mjs, migration crash-recovery pure + harness tests,
parallel-quota harness test. Inventory: 138 unit / 49 pure / 40 harness / 64
smokes.
2026-07-28 23:50:59 +03:00
Matysh 6c90e03427 zoom-out, device-aware content frame, and the editor no longer shifts the plan
Three owner reports:

- The content frame behind the default zoom only looked at rooms, and devices
  are allowed to stand outside every one of them — a gate sensor by the fence
  was left outside the opening view. contentBounds() takes the marker positions
  now, and they count as content even on a space with no rooms at all.

- Entering an editor 'strangely shifted' the plan. The stage height was
  100dvh minus a hard-coded 118px of header, and the editor header is ~90px
  taller than that: the whole scene slid down by the difference and its bottom
  went below the fold. The card now measures where the stage actually starts
  (HA toolbar, margins and our header included) and gives it the rest of the
  viewport; the measurement is deferred a frame because setting state straight
  from a ResizeObserver callback trips the 'undelivered notifications' error,
  which smoke_dialog_zombie rightly counts as a page error.

- Zoom stopped at the base fit. The floor is 0.4× now, and zoomed out the
  clamp centres the content instead of pinning it to the top-left corner —
  with the view larger than the plan there is nothing to clamp against.

New smoke: smoke_zoom_out.mjs (editor keeps the stage inside the viewport,
0.4 floor, centring, the device-stretched frame); a unit test for the extra
points of contentBounds. Not released — the next release goes out after the
v1.49.0 audit.
2026-07-28 23:40:39 +03:00
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
Matysh b7ae3e7adf Release v1.46.2
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 7s
Validate / frontend (push) Successful in 1m51s
Validate / backend (push) Failing after 7m11s
Validate / smoke (push) Failing after 6m35s
Re-check of v1.46.1: the scheduled sweep now collects unreferenced attachments
and plans against the stored configuration, and a drag in flight survives a
concurrent remote position change.
2026-07-28 17:47:19 +03:00
Matysh 379fb68db2 v1.46.2: re-check of v1.46.1 — HP-1461-01, -02
Validate / hacs (push) Failing after 23s
Validate / hassfest (push) Failing after 22s
Validate / frontend (push) Successful in 1m40s
Validate / backend (push) Failing after 7m16s
Validate / smoke (push) Failing after 7m13s
HP-1461-01: collection was tied to config/set, which is the right scope for
what a commit supersedes but leaves a file nobody references with no future
write to notice it — cancel a dialog after the upload finished, drop the
connection just after, or call the upload API directly. The daily sweep added
in v1.46.1 only removed streaming temporaries, so the documented 'a cancelled
attachment is collected an hour later' did not hold on an instance nobody
edits. The scheduled pass now loads the stored config under the same write_lock
a commit uses and runs collect_attachments/collect_plans with it as BOTH sides:
nothing counts as superseded, referenced files are preserved, aged unreferenced
ones go. Doing it under the lock keeps it from deciding on a snapshot a commit
is about to replace.

HP-1461-02: _reloadLayoutOnly captured the dirty set AFTER flushing the pending
write, and the flush empties it first — so during a real drag (where a write is
already scheduled) the snapshot was empty and the server's older position was
merged over the user's move. The snapshot is taken before the flush, by value,
and a _sentPos map now holds positions that are sent but unacknowledged, which
closes the same window for a write that was already in flight.

Tests: the upload test now cancels the request task for real (the previous one
claimed to and only walked error paths); smoke_layout_sync schedules a genuine
debounced write and delays it — verified failing on a v1.46.1 build with
exactly the reported symptom; a new backend test reloads the entry and asserts
the scheduled sweep takes an aged cancelled attachment and an orphan plan while
keeping everything the config still references.
Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
2026-07-28 17:44:03 +03:00
Matysh 96a70495d3 Release v1.46.1
Re-check of v1.46.0: atomic filename reservation with a bounded collision name,
unconditional cleanup of streaming temporaries plus a scheduled sweep, and the
full card following layout events with a dirty-position merge.
2026-07-28 16:51:19 +03:00
Matysh d3db9e30e6 v1.46.1: re-check of v1.46.0 — HP-1460-01, -02, -03
HP-1460-01: v1.46.0 stopped overwriting attachments, but picking a free name
and taking it were two steps. Two uploads racing between them agreed on the
same name, both answered 200, and one set of bytes replaced the other;
files/migrate had the same check-then-copy gap. reserve_filename now claims the
name with O_CREAT|O_EXCL as it picks it, and both paths use it. It also splits
the extension off the RAW name and budgets the stem against MAX_FILENAME
including the collision tag — a maximal name lost its '.pdf' and then grew past
the limit, so the view sanitised the request back to a different name and the
attachment 404'd for good.

HP-1460-02: cleanup lived in an 'except Exception', which CancelledError walks
past, only one tmp_path was tracked, promotion had no finally, and the
collector only walks marker folders — an aborted transfer stranded a .upload-*
that nothing would ever remove. An outer finally owns every temporary, a second
'file' part is refused, promotion failure cleans up, and sweep_upload_temps
runs at setup, daily, and inside the commit-scoped collector. Chunks are
batched to 1 MB per disk task instead of one per 64 KB.

HP-1460-03: the layout event reached the static card and not the full one, so
two full cards diverged until a reload. The full card subscribes now and
re-reads ONLY the layout, keyed on its revision. Two hazards handled: it
records revisions it produced itself, and the reaction is deferred ~200 ms
because the event can beat the reply to our own write over the same socket;
positions dragged but not yet sent are flushed and merged on top, so a fix for
a stale UI cannot become a lost drag.

Tests: smoke_layout_sync (fails on a v1.46.0 build), four pure tests for atomic
reservation incl. 20-thread concurrency and the length boundary, a backend test
walking every failing exit path of an upload, and — as the report asked — an
HA-harness test that a repair issue disappears with its space.
Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
2026-07-28 16:48:32 +03:00
Matysh 2e731debd9 Release v1.46.0
Full external audit of v1.45.4: sandboxed SVG content (release blocker),
transactional attachments, serialized config writes, geometry-aware openPairs
cache, inner validation limits, streaming file I/O, static-card parity, layout
revisions and events, repair issue cleanup, dev dependency bump.
2026-07-28 16:18:58 +03:00
Matysh a49b5e6d2e fix: collision names must survive the sanitiser the content view applies
unique_filename produced 'manual (2).pdf'; HouseplanContentView sanitises the
name in the REQUEST too, turning ' (2)' into '_2_', so the file was written and
then 404'd. The same pattern was already in files/migrate, so a rebind that hit
a name collision has been producing dead links. Both use the shared helper now,
with '-2', which round-trips sanitize_filename — asserted.
2026-07-28 16:16:07 +03:00
Matysh 4418312b0b test: config cap under aiohttp's 4 MB frame; own marker id for the upload test
A 4 MB cap could never be reported: the frame limit rejects the message first
and the socket closes with 1009, so the user gets a dropped connection instead
of 'too_large'. 2 MB is ~30x a real three-floor configuration (70 KB measured).

The upload test listed a folder test_ha_upload.py also writes into.
2026-07-28 16:11:48 +03:00
Matysh 3f719cc32a test: match the new upload url shape; keep the config cap under the WS frame limit
test_upload_ok still asserted the old '<name>?v=<mtime>' url — uploads take a
free name now, so the name itself is the cache key and the query is gone.

MAX_CONFIG_BYTES was 12 MB, above the WebSocket frame limit: a payload that big
never reaches the handler, the socket just closes with 1009 and the user sees a
dropped connection instead of an actionable error. 4 MB is far above any real
configuration and comfortably inside the frame.

test_upload_never_overwrites listed the whole shared test config folder.
2026-07-28 16:09:00 +03:00
Matysh 260615a63f v1.46.0: full external audit of v1.45.4 — HP-1454-01 … -10
HP-1454-01 (high, release blocker): an uploaded SVG plan opened directly is a
top-level document of Home Assistant's own origin, so a <script> inside it
reaches the session's localStorage and API. Uploading needs write access, which
by default every authenticated user has. SVG responses now carry a sandbox CSP;
only SVG, because a CSP on a PDF can break the browser's viewer and a raster
image has nothing to disable. Verified in Chromium both ways: the script runs
without the header and does not with it.

HP-1454-02: attachment uploads wrote straight to <marker>/<filename>, outside
the config transaction — a cancelled dialog or a rejected save left the stored
url serving new bytes, and every new icon shared one 'new' folder, so two of
them attaching manual.pdf pointed at one file. Uploads take a free name, a new
icon gets a per-dialog staging folder promoted on an accepted save, and
config/set collects superseded and aged-orphan attachments like it does plans.

HP-1454-03: the debounce spaced out the starts of a write, not the writes. A
save slower than 500 ms let the next edit go out with the same expected_rev;
the server accepted the first, rejected the second, and the conflict handler
reloaded over the local copy. Writes are chained now — one in flight, each with
the revision the previous returned.

HP-1454-04: _openPairsCache keyed on room ids and links only, so an aspect
change or a dragged vertex left open boundaries and their glow cuts at old
coordinates. It keys on the rendered model object now — the same invalidation
the model cache already has, not a second strategy. The fingerprint also gained
an O(1) geometry roll-up per room.

HP-1454-05: outer collections were capped, inner ones were not. Limits for
poly points, open_to, controls, pdfs, text and url lengths, plus a total
serialized size cap; legacy  is dropped server-side.

HP-1454-06: upload streams to a temp file and downloads use FileResponse, so a
50 MB manual no longer costs ~100 MB of RSS per transfer.

HP-1454-07: spaceModels() dropped room.settings, so the static card ignored the
per-room fill override. HP-1454-08: layout had no revision on point-wise writes
and no event, leaving static cards stale forever; it now keeps a revision,
returns it and fires houseplan_layout_updated. HP-1454-09: repair cleanup only
walked existing spaces, so a deleted space kept its warning. HP-1454-10:
serialize-javascript pinned past two advisories.

Tests: smoke_svg_sandbox (proves both directions), smoke_config_writer and
smoke_render_parity (both verified failing against a v1.45.4 build), six pure
tests for attachment collection and inner limits, four HA-harness tests for the
CSP, non-overwriting uploads, the size cap and layout revisions.
Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
2026-07-28 16:06:21 +03:00
Matysh e4e300adaa Release v1.45.4
Validate / hacs (push) Failing after 1m19s
Validate / hassfest (push) Failing after 1m18s
Validate / frontend (push) Successful in 2m13s
Validate / backend (push) Failing after 10m58s
Validate / smoke (push) Failing after 6m7s
Review of v1.45.3: R5-1 a partial signing answer no longer skips the backoff,
R5-2 the status snapshot matches the repository and no longer carries counts
that go stale.
2026-07-28 08:51:57 +03:00
Matysh 96d387ff1d v1.45.4: review of v1.45.3 — R5-1, R5-2
Validate / hassfest (push) Failing after 49s
Validate / hacs (push) Failing after 52s
Validate / frontend (push) Successful in 1m43s
Validate / backend (push) Failing after 8m30s
Validate / smoke (push) Successful in 4m52s
R5-1: the backend signs each path independently and answers successfully with
whatever it managed, skipping (and logging) the rest. The card read any
successful call as 'the batch is done', cleared the backoff for every path in
it, then wrote only the urls that came back — so a path the backend kept
skipping was asked for again on every render, the exact amplification the
backoff was added to stop. A path now counts as signed only when the answer
carries a url for it; the others back off individually, keys that were not
requested are ignored, and onUpdate fires only when a new signature landed.

R5-2: docs/STATUS.md still described main as holding releases up to v1.40.1 and
quoted test counts several releases old, while the version line beside them was
kept current — a handoff reader got a wrong branch model and less coverage than
exists. Branch roles are now accurate, and the counts are gone rather than
corrected: scripts/inventory.mjs (npm run inventory) prints them from the tree,
so there is nothing left to drift.

Tests: three unit cases for empty/partial/foreign-key answers, verified to fail
against a v1.45.3 checkout; a backend test pinning the partial-success contract
by making async_sign_path raise for one path of two.
Docs: CHANGELOG.md + CHANGELOG.ru.md + TESTING.md + STATUS.md.
2026-07-28 08:49:11 +03:00
Matysh 8b531db3f5 docs: the value-display bug lived six days, not a year and a half
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 6s
Validate / frontend (push) Successful in 1m44s
Validate / backend (push) Failing after 6m15s
Validate / smoke (push) Failing after 12m28s
Version distance is not calendar distance. v1.26.0 shipped 2026-07-21 and the
report came in on 2026-07-27; the project itself is three weeks old. The point
stands and is unchanged — nothing in the suite could have caught it, because the
option list and the schema were written in two languages and never compared —
but the 'year and a half' was wrong.
2026-07-28 00:29:40 +03:00
Matysh 68aa1f04ba Release v1.45.3
Validate / hacs (push) Failing after 5s
Validate / hassfest (push) Failing after 5s
Validate / frontend (push) Successful in 1m34s
Validate / backend (push) Failing after 6m18s
Validate / smoke (push) Failing after 10m27s
issue #3: display='value' was offered by the editor since v1.26.0 but rejected
by the schema, which blocked saving the configuration entirely. Option lists
are now shared and checked across languages.
2026-07-28 00:26:27 +03:00
Matysh 3d41fe16b8 v1.45.3: 'value instead of an icon' could never be saved (issue #3)
The device editor has offered display='value' since v1.26.0; MARKER_SCHEMA
accepted only badge/ripple/icon_ripple. Picking it produced

  not a valid value for dictionary value @ data['config']['markers'][n]['display']

and since one rejected marker fails the whole config write, the user could not
save the plan at all until the setting was undone. Reported by @RemyRoux with
the exact error text, 2026-07-27 — a year and a half after the feature shipped.

The schema now accepts it, and the class of bug is closed rather than the
instance: DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES and ROOM_FILL_MODES are
exported from src/logic.ts, the editors render their options from them, and a
backend test parses those lists out of the TypeScript source and asserts the
schema accepts every one (and rejects a bogus value). Reverting the one-word
schema fix fails that test, which is the check that was missing.

Plus an HA-harness test saving a config that contains a value-display marker —
the exact call the user's card was making.
Docs: CHANGELOG.md + CHANGELOG.ru.md + TESTING.md + STATUS.md.
2026-07-28 00:23:37 +03:00
64 changed files with 5250 additions and 580 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.
+93
View File
@@ -2,11 +2,13 @@
from __future__ import annotations
import logging
from datetime import timedelta
from pathlib import Path
from homeassistant.components.frontend import add_extra_js_url
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.event import async_track_time_interval
from . import websocket_api as hp_ws
from .const import (
@@ -18,6 +20,8 @@ from .const import (
PLANS_URL,
VERSION,
)
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
from .plans import collect_attachments, collect_plans, sweep_upload_temps
from .repairs import async_check_plan_files
from .store import HouseplanConfigEntry, create_data
@@ -96,7 +100,96 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
module_url, module_url,
)
# One-time move to the square canvas (v1.48.0). Coordinates used to be
# normalised against a per-space aspect ratio; the canvas is now always
# square and a plan is centred inside it. Nothing about the drawing changes
# — the box is padded and the numbers re-expressed against it.
# The two stores are written independently, and the lock is no transaction:
# a crash between the writes used to leave the config in square coordinates
# with the layout still in the old ones — permanently, because the config
# write had already deleted the `aspect` fields the layout half needed
# (HP-1490-01). So the intent is made durable FIRST, in the layout store,
# and each half carries its own trigger with its own write: the config half
# removes `aspect`, the layout half removes the saved intent. Whatever
# half is missing after a crash, the next start finishes exactly it.
async with data.write_lock:
stored = await data.config_store.async_load() or {}
cfg = stored.get("config")
lay_stored = await data.store.async_load() or {}
layout = lay_stored.get("layout") or {}
pending = {
str(k): v for k, v in (lay_stored.get("geom_pending") or {}).items()
}
merged = {**pending, **pending_from_config(cfg)}
if merged:
lay_rev = int(lay_stored.get("rev", 0))
if merged != pending: # 1. the durable intent, before anything moves
await data.store.async_save(
{"layout": layout, "rev": lay_rev, "geom_pending": merged}
)
rev = int(stored.get("rev", 0))
if cfg and migrate_config(cfg): # 2. the config half
rev += 1
await data.config_store.async_save({"config": cfg, "rev": rev})
migrate_layout(layout, merged) # 3. the layout half + intent cleared
await data.store.async_save({"layout": layout, "rev": lay_rev + 1})
_LOGGER.info(
"House Plan: migrated %s space(s) to the square canvas", len(merged)
)
# only once both halves are durable — a client refetching on this
# event must never see one migrated half and one old one
hass.bus.async_fire("houseplan_config_updated", {"rev": rev})
await async_check_plan_files(hass, entry)
# Scheduled collection of everything nobody ended up referencing.
#
# A commit collects what that commit superseded, which is the right rule for
# a commit — but it only ever runs when somebody saves. Cancel a dialog
# after the file has already uploaded, lose the connection after the upload
# succeeded, or call the API directly, and the file is unreferenced with no
# future write to notice it (HP-1461-01). The earlier version of this sweep
# only removed streaming temporaries, which are a different, narrower case.
#
# Passing the CURRENT configuration as both sides means "nothing was
# superseded": every referenced file is preserved and only unreferenced ones
# past PLAN_ORPHAN_TTL_S go. It runs under the same lock as a config write,
# so it cannot decide from a snapshot that a commit is about to replace.
async def _sweep(_now=None) -> None:
files_dir = Path(hass.config.path(FILES_DIR))
plans_dir = Path(hass.config.path(PLANS_DIR))
try:
# `data` from the closure, NOT get_data(hass): during
# async_setup_entry the entry is still SETUP_IN_PROGRESS, so
# async_loaded_entries() does not list it and the lookup returned
# None. The startup pass then silently degraded to removing
# streaming temporaries only, and the real collection waited a full
# day — restarting more often than that meant it never ran at all
# (HP-1462-01). The callback is unregistered with the entry, so
# closing over its runtime data matches the lifecycle exactly.
async with data.write_lock:
stored = await data.config_store.async_load() or {}
cfg = stored.get("config") or {}
def _collect() -> int:
n = sweep_upload_temps(files_dir)
# same config on both sides: nothing is superseded, so this
# only ever collects what the shared rules call abandoned
n += collect_attachments(files_dir, cfg, cfg)
n += collect_plans(plans_dir, cfg, cfg)
return n
n = await hass.async_add_executor_job(_collect)
if n:
_LOGGER.info("House Plan: removed %s unreferenced file(s)", n)
except Exception: # noqa: BLE001 — housekeeping must never fail a setup
_LOGGER.exception("House Plan: sweeping unreferenced files failed")
data.sweep = _sweep
await _sweep()
entry.async_on_unload(
async_track_time_interval(hass, _sweep, timedelta(hours=24))
)
return True
+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.45.2"
VERSION = "1.50.0"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
@@ -0,0 +1,161 @@
"""One-time migration to a square canvas — pure, so it can be tested alone.
Until v1.48.0 a space had an `aspect`, and coordinates were normalised against
it: x by the width, y by the HEIGHT. Making every canvas square without touching
the numbers would stretch every plan vertically.
Nothing about the drawing changes here. The canvas is padded to a square —
top and bottom for a wide plan, left and right for a tall one — and the
coordinates are re-expressed against that larger box. In render units it is a
uniform scale plus an offset, so angles, room proportions and relative positions
survive exactly. `cell_cm` follows, because the grid is tied to the width: for a
tall plan the width grew, so the same wall would otherwise measure less.
"""
from __future__ import annotations
import logging
from typing import Any
_LOGGER = logging.getLogger(__name__)
def transform_for(aspect: float) -> tuple[float, float, float, float]:
"""(dx, dy, kx, ky) that map old normalised coordinates onto the square.
x' = dx + x * kx, y' = dy + y * ky. Lengths along an axis scale by that
axis's factor; both are the same uniform scale in RENDER units, which is
why angles are preserved.
"""
a = float(aspect)
if not a or a <= 0:
a = 1.0
k = min(1.0, a) # how much the old box shrinks inside the square
kx = k # x was normalised by the width
ky = k / a # y was normalised by the height (= width / aspect)
return (1.0 - kx) / 2, (1.0 - ky) / 2, kx, ky
def _pt(p: Any, dx: float, dy: float, kx: float, ky: float) -> Any:
if isinstance(p, (list, tuple)) and len(p) >= 2:
return [dx + float(p[0]) * kx, dy + float(p[1]) * ky]
return p
def migrate_space(space: dict[str, Any]) -> bool:
"""Rewrite one space in place. Returns True when anything was changed."""
if "aspect" not in space:
return False
try:
aspect = float(space.get("aspect") or 1)
except (TypeError, ValueError):
aspect = 1.0
dx, dy, kx, ky = transform_for(aspect)
space.pop("aspect", None)
for room in space.get("rooms") or []:
if room.get("x") is not None:
room["x"] = dx + float(room["x"]) * kx
if room.get("y") is not None:
room["y"] = dy + float(room["y"]) * ky
if room.get("w") is not None:
room["w"] = float(room["w"]) * kx
if room.get("h") is not None:
room["h"] = float(room["h"]) * ky
if room.get("poly"):
room["poly"] = [_pt(p, dx, dy, kx, ky) for p in room["poly"]]
for op in space.get("openings") or []:
op["x"] = dx + float(op.get("x", 0)) * kx
op["y"] = dy + float(op.get("y", 0)) * ky
# a length is measured along the wall, and the render scale is uniform
if op.get("length") is not None:
op["length"] = float(op["length"]) * kx
for shape in space.get("decor") or []:
for a, b, fx, fy in (("x1", "y1", kx, ky), ("x2", "y2", kx, ky), ("x", "y", kx, ky)):
if shape.get(a) is not None:
shape[a] = dx + float(shape[a]) * fx
if shape.get(b) is not None:
shape[b] = dy + float(shape[b]) * fy
if shape.get("w") is not None:
shape["w"] = float(shape["w"]) * kx
if shape.get("h") is not None:
shape["h"] = float(shape["h"]) * ky
# The viewport becomes the whole square rather than the transformed old
# rectangle. It is what the grid is drawn over and what "fit to screen"
# fits, so keeping the old box would leave the new margins outside the
# canvas — no dots, nothing to draw on — which is exactly the room this
# change was meant to give.
space["view_box"] = [0.0, 0.0, 1.0, 1.0]
# The grid pitch is a fraction of the WIDTH. A tall plan just got a wider
# canvas, so a wall now covers fewer cells; without this every measurement
# in the plan would silently shrink.
if kx != 1:
try:
cell = float(space.get("cell_cm") or 5)
except (TypeError, ValueError):
cell = 5.0
space["cell_cm"] = round(cell / kx, 4)
# The image keeps its own proportions and is centred; the space no longer
# has any of its own.
if space.get("plan_url") and not space.get("plan_aspect"):
space["plan_aspect"] = round(aspect, 6)
return True
def pending_from_config(config: dict[str, Any] | None) -> dict[str, float]:
"""{space_id: old aspect} for every space still carrying one.
This is the migration INTENT. The two stores are written independently and
either write can fail, so the intent has to survive on its own: it is saved
into the layout store BEFORE anything changes (HP-1490-01), and cleared by
the same write that stores the migrated layout. A crash between the writes
leaves the intent behind, and the next start finishes the missing half —
each half is idempotent because its trigger (`aspect` in the config, the
saved intent for the layout) travels with that half's own write.
"""
out: dict[str, float] = {}
for space in (config or {}).get("spaces") or []:
if "aspect" not in space:
continue
try:
out[str(space.get("id"))] = float(space.get("aspect") or 1) or 1.0
except (TypeError, ValueError):
out[str(space.get("id"))] = 1.0
return out
def migrate_config(config: dict[str, Any], layout: dict[str, Any] | None = None) -> bool:
"""The config half: migrate every space still carrying an `aspect`.
`layout` is accepted for backward compatibility and migrated with the
factors found in the config — callers that can crash between store writes
should use `pending_from_config()` + `migrate_layout()` instead, so the
layout half does not depend on state the config half just deleted.
"""
factors = pending_from_config(config)
if not factors:
return False
for space in config.get("spaces") or []:
migrate_space(space)
if layout:
migrate_layout(layout, factors)
return True
def migrate_layout(layout: dict[str, Any] | None, pending: dict[str, float]) -> bool:
"""The layout half: marker and label positions of the spaces in `pending`."""
changed = False
for pos in (layout or {}).values():
if not isinstance(pos, dict) or str(pos.get("s")) not in pending:
continue
dx, dy, kx, ky = transform_for(pending[str(pos.get("s"))])
if pos.get("x") is not None:
pos["x"] = dx + float(pos["x"]) * kx
if pos.get("y") is not None:
pos["y"] = dy + float(pos["y"]) * ky
changed = True
return changed
+159 -56
View File
@@ -6,6 +6,8 @@ breaks the connection on a large PDF) but via a plain multipart POST — like me
from __future__ import annotations
import logging
import os
import tempfile
from pathlib import Path
from aiohttp import web
@@ -18,8 +20,12 @@ except ImportError: # older HA versions
KEY_HASS = "hass" # type: ignore[assignment]
from homeassistant.core import HomeAssistant
from .const import CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, PLANS_DIR
from .const import (
CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, MAX_FILES_BYTES,
MAX_FILES_COUNT, PLANS_DIR,
)
from .auth import may_write
from .plans import TMP_PREFIX, QuotaError, check_quota, reserve_filename
from .validation import (
FILE_EXTENSIONS,
MAX_FILE_BYTES,
@@ -31,6 +37,8 @@ from .validation import (
_LOGGER = logging.getLogger(__name__)
_CHUNK = 64 * 1024
# batch disk writes: one executor job per megabyte instead of per chunk
_FLUSH_AT = 1024 * 1024
_MIME = {
".pdf": "application/pdf",
@@ -73,17 +81,35 @@ class HouseplanContentView(HomeAssistantView):
if not str(path).startswith(str(base)):
return web.Response(status=404)
def _read() -> bytes | None:
return path.read_bytes() if path.is_file() else None
blob = await hass.async_add_executor_job(_read)
if blob is None:
if not await hass.async_add_executor_job(path.is_file):
return web.Response(status=404)
return web.Response(
body=blob,
content_type=_MIME.get(path.suffix.lower(), "application/octet-stream"),
headers={"Cache-Control": "private, max-age=3600"},
)
suffix = path.suffix.lower()
headers = {
"Cache-Control": "private, max-age=3600",
"Content-Type": _MIME.get(suffix, "application/octet-stream"),
}
if suffix == ".svg":
# An uploaded SVG is user content served from Home Assistant's own
# origin. Inside the card it is referenced by <image>, where scripts
# never run — but the same url opened as a top-level document is a
# live document of this origin, and a <script> in it reaches the
# session's localStorage and API (HP-1454-01, 2026-07-28: uploading
# needs write access, which by default every authenticated user has,
# and the signed url is easy to hand to an admin).
#
# `sandbox` with no allow-* tokens drops the document into an opaque
# origin: no scripts, no same-origin access, no forms. The explicit
# directives below are belt and braces for older engines. Only SVG
# gets this — a CSP on a PDF response can break the browser's built-in
# viewer, and a raster image cannot execute anything in the first place.
headers["Content-Security-Policy"] = (
"sandbox; default-src 'none'; script-src 'none'; object-src 'none'; "
"base-uri 'none'; form-action 'none'; style-src 'unsafe-inline'; img-src data:"
)
# FileResponse streams from disk: a 50 MB manual used to be read whole
# into memory and copied into the response body, so a couple of parallel
# downloads could push a small Home Assistant host into swap (HP-1454-06).
return web.FileResponse(path, chunk_size=_CHUNK, headers=headers)
class HouseplanUploadView(HomeAssistantView):
@@ -98,55 +124,132 @@ class HouseplanUploadView(HomeAssistantView):
if not may_write(hass, request.get("hass_user")):
return web.json_response({"error": "unauthorized"}, status=403)
files_root = Path(hass.config.path(FILES_DIR))
marker_id = "misc"
filename: str | None = None
blob: bytes | None = None
too_large = False
# Every temporary file this request creates, promoted or not. The outer
# `finally` removes whatever is left: a dropped connection, a second
# `file` part or a failure while promoting used to leave a `.upload-*`
# behind for good, and the collector only ever walks marker folders, so
# nothing would have picked it up (HP-1460-02).
temps: list[Path] = []
error: tuple[dict, int] | None = None
def _new_tmp() -> Path:
files_root.mkdir(parents=True, exist_ok=True)
fd, name = tempfile.mkstemp(prefix=TMP_PREFIX, dir=str(files_root))
os.close(fd)
return Path(name)
def _flush(target: Path, blocks: list[bytes]) -> None:
with open(target, "ab") as fh:
for block in blocks:
fh.write(block)
def _cleanup(paths: list[Path]) -> None:
for path in paths:
try:
path.unlink()
except OSError:
pass
try:
reader = await request.multipart()
async for part in reader:
if part.name == "marker_id":
marker_id = sanitize_marker_id(await part.text())
elif part.name == "file":
filename = part.filename or "file"
# read in chunks, aborting at the limit, instead of loading the whole file into memory
chunks: list[bytes] = []
size = 0
while chunk := await part.read_chunk(_CHUNK):
size += len(chunk)
if size > MAX_FILE_BYTES:
too_large = True
try:
reader = await request.multipart()
async for part in reader:
if part.name == "marker_id":
marker_id = sanitize_marker_id(await part.text())
elif part.name == "file":
if filename is not None:
# one upload per request: a second part would strand
# the first temporary file and make the response
# ambiguous about which url was returned
error = ({"error": "one_file_only"}, 400)
break
chunks.append(chunk)
if too_large:
break
blob = b"".join(chunks)
except Exception as err: # noqa: BLE001
_LOGGER.warning("House Plan upload: multipart read error: %s", err)
return web.json_response({"error": "bad_request"}, status=400)
filename = part.filename or "file"
if file_ext(filename) not in FILE_EXTENSIONS:
error = ({"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, 400)
break
# Stream to a temporary file instead of collecting the
# whole upload in memory and copying it again into one
# buffer: a 50 MB manual used to cost ~100 MB of RSS
# mid-request (HP-1454-06). Blocks are batched so this
# is one executor job per megabyte, not per 64 KB.
tmp = await hass.async_add_executor_job(_new_tmp)
temps.append(tmp)
size = 0
pending: list[bytes] = []
buffered = 0
while chunk := await part.read_chunk(_CHUNK):
size += len(chunk)
if size > MAX_FILE_BYTES:
error = (
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024},
413,
)
break
pending.append(chunk)
buffered += len(chunk)
if buffered >= _FLUSH_AT:
await hass.async_add_executor_job(_flush, tmp, pending)
pending, buffered = [], 0
if error:
break
if pending:
await hass.async_add_executor_job(_flush, tmp, pending)
except Exception as err: # noqa: BLE001
_LOGGER.warning("House Plan upload: multipart read error: %s", err)
error = ({"error": "bad_request"}, 400)
if too_large:
if error:
return web.json_response(error[0], status=error[1])
if not temps or not filename:
return web.json_response({"error": "no_file"}, status=400)
tmp_path = temps[0]
try:
await hass.async_add_executor_job(
check_quota, files_root, tmp_path.stat().st_size,
MAX_FILES_BYTES, MAX_FILES_COUNT,
)
except QuotaError as err:
_LOGGER.warning("House Plan upload refused: %s", err.detail)
return web.json_response({"error": err.reason, "detail": err.detail}, status=507)
except OSError:
pass
target_dir = files_root / marker_id
safe_name = filename
def _promote() -> str:
"""Claim a free name, then move the finished upload onto it.
Never overwrite an existing attachment: its bytes may be
referenced by the stored configuration, and this upload is not
part of that transaction — a cancelled dialog or a rejected save
would leave the old url serving the new content (HP-1454-02).
The name is reserved atomically, so two uploads racing on the
same filename cannot agree on it (HP-1460-01).
"""
name = reserve_filename(target_dir, safe_name)
try:
os.replace(tmp_path, target_dir / name)
except OSError:
(target_dir / name).unlink(missing_ok=True)
raise
return name
try:
name = await hass.async_add_executor_job(_promote)
except OSError as err:
_LOGGER.warning("House Plan upload: could not store the file: %s", err)
return web.json_response({"error": "io_error"}, status=500)
temps.remove(tmp_path) # it is the attachment now, not a temporary
return web.json_response(
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024}, status=413
{"ok": True, "url": f"{CONTENT_URL}/files/{marker_id}/{name}", "name": filename}
)
if blob is None or not filename:
return web.json_response({"error": "no_file"}, status=400)
ext = file_ext(filename)
if ext not in FILE_EXTENSIONS:
return web.json_response(
{"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, status=400
)
safe_name = sanitize_filename(filename)
target_dir = Path(hass.config.path(FILES_DIR)) / marker_id
path = target_dir / safe_name
def _write() -> int:
target_dir.mkdir(parents=True, exist_ok=True)
path.write_bytes(blob)
return int(path.stat().st_mtime)
mtime = await hass.async_add_executor_job(_write)
return web.json_response(
{"ok": True, "url": f"{CONTENT_URL}/files/{marker_id}/{safe_name}?v={mtime}", "name": filename}
)
finally:
# BaseException too: cancelling the request task raises
# asyncio.CancelledError, which an `except Exception` never saw —
# an aborted large upload leaked its temporary file every time
if temps:
await hass.async_add_executor_job(_cleanup, list(temps))
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.45.2"
"version": "1.50.0"
}
+274 -14
View File
@@ -1,22 +1,243 @@
"""Plan-file collection — pure, so it is unit-testable without Home Assistant.
"""Blob lifecycle — pure, so it is unit-testable without Home Assistant.
The file system is not part of the configuration store's transaction, so who
may delete a plan file, and when, is a correctness question rather than a
housekeeping one. It lives here, apart from the WebSocket plumbing, precisely
because it is the part that has to be reasoned about and tested.
may write or delete a plan or an attachment, and when, is a correctness
question rather than housekeeping. It lives here, apart from the WebSocket and
HTTP plumbing, precisely because it is the part that has to be reasoned about
and tested.
"""
from __future__ import annotations
import logging
import os
import time
from pathlib import Path
from typing import Any
from .const import PLAN_ORPHAN_TTL_S
from .validation import PLAN_EXTENSIONS
from .const import MIN_FREE_BYTES, PLAN_ORPHAN_TTL_S
from .validation import MAX_FILENAME, PLAN_EXTENSIONS, sanitize_filename
_LOGGER = logging.getLogger(__name__)
# Streaming uploads land here first. The prefix is a dot so the name can never
# collide with an attachment (sanitize_filename strips leading dots) and is easy
# to sweep.
TMP_PREFIX = ".upload-"
def reserve_filename(directory: Path, name: str) -> str:
"""Atomically claim a free name inside `directory` and return it.
Creates the file, empty, with `O_CREAT | O_EXCL`, so the name is *taken* the
moment it is chosen. The previous version asked `exists()` and returned a
string; two uploads racing between the check and the write agreed on the
same name and one silently overwrote the other, both reporting success
(HP-1460-01). The caller writes the real bytes over the placeholder — it
owns the name by then — and must remove it if it never gets that far.
The result is guaranteed to satisfy `sanitize_filename(result) == result`:
the content view sanitises the name in the request too, so a name it would
shorten or rewrite is a file that is written and then never served.
"""
directory.mkdir(parents=True, exist_ok=True)
# Split the extension off the RAW name: sanitize_filename() truncates to
# MAX_FILENAME, so sanitising first would cut ".pdf" off a long name and the
# attachment would be stored — and served — without its type.
base = name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
stem, dot, suffix = base.rpartition(".")
if not dot:
stem, suffix = base, ""
stem = sanitize_filename(stem)
ext = f".{sanitize_filename(suffix)[:16]}" if suffix else ""
i = 1
while True:
tag = "" if i == 1 else f"-{i}"
# budget the stem so the WHOLE name fits, including the collision tag —
# appending "-2" to an already maximal name produced a url the view
# truncated back to something else, i.e. a permanent 404
room = MAX_FILENAME - len(ext) - len(tag)
candidate = (stem[:room] if room > 0 else "f") + tag + ext
candidate = sanitize_filename(candidate)
if candidate.startswith("."): # a name that is only an extension
candidate = "file" + candidate
try:
fd = os.open(directory / candidate, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
except FileExistsError:
i += 1
if i > 10000: # pathological directory; do not spin forever
raise
continue
os.close(fd)
return candidate
def attachment_refs(cfg: dict[str, Any] | None) -> set[str]:
""""<marker>/<file>" for every attachment a configuration references."""
out: set[str] = set()
for m in (cfg or {}).get("markers") or []:
for pdf in m.get("pdfs") or []:
url = pdf.get("url") if isinstance(pdf, dict) else None
if not isinstance(url, str) or "/files/" not in url:
continue
rel = url.split("?", 1)[0].split("/files/", 1)[1]
if rel.count("/") == 1:
out.add(rel)
return out
def sweep_upload_temps(files_dir: Path, now: float | None = None) -> int:
"""Remove abandoned streaming temporaries (HP-1460-02).
The request itself deletes its own, but a hard kill — a restart mid-upload,
an OOM — leaves one behind, and the attachment collector only walks marker
folders, so it would never be seen. Age-gated for the same reason as the
rest: a fresh one belongs to a request still in flight.
"""
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
removed = 0
try:
items = [p for p in files_dir.iterdir() if p.is_file()] if files_dir.is_dir() else []
except OSError as err:
_LOGGER.warning("House Plan: could not list %s: %s", files_dir, err)
return 0
for item in items:
if not item.name.startswith(TMP_PREFIX):
continue
try:
if item.stat().st_mtime >= cutoff:
continue
item.unlink()
removed += 1
except OSError:
continue
return removed
def collect_attachments(
files_dir: Path,
old_cfg: dict[str, Any] | None,
new_cfg: dict[str, Any],
now: float | None = None,
) -> int:
"""The same commit-scoped rule as `collect_plans`, for marker attachments.
A file the old revision referenced and the new one does not, whose marker
still exists, was removed on purpose — the dialog has a trash button and
promises nothing. It goes. Everything else is kept, except a staging folder
(`up_*`), which by construction only ever holds an upload from a dialog that
was never saved: those go after PLAN_ORPHAN_TTL_S. Never raises: it runs
behind a durable write.
"""
new_refs = attachment_refs(new_cfg)
old_refs = attachment_refs(old_cfg)
# Removing an attachment from a device that still exists is the user saying
# "drop this one" — a trash button, no promise that anything is kept. A
# device that is GONE is a different transition, and its files follow the
# same rule as a deleted space's plan: kept.
live_markers = {str(m.get("id")) for m in (new_cfg or {}).get("markers") or []}
# Same distinction as for plans. A staging folder (`up_*`) is different: it
# only ever holds an upload from a dialog that was never saved, so the short
# rule is exactly right there even on the timer.
now_s = time.time() if now is None else now
staging_cutoff = now_s - PLAN_ORPHAN_TTL_S
removed = 0
try:
folders = sorted(p for p in files_dir.iterdir() if p.is_dir()) if files_dir.is_dir() else []
except OSError as err:
_LOGGER.warning("House Plan: could not list %s: %s", files_dir, err)
return 0
removed += sweep_upload_temps(files_dir, now)
for folder in folders:
# A staging folder only ever holds an upload from a dialog that was never
# saved — unambiguous, so an hour is right, and no device owns it.
staging = folder.name.startswith("up_")
try:
items = sorted(p for p in folder.iterdir() if p.is_file())
except OSError:
continue
for item in items:
rel = f"{folder.name}/{item.name}"
if rel in new_refs:
continue
dropped = rel in old_refs and folder.name in live_markers
if not dropped:
if not staging:
# Same rule as for plans: not asked for, so kept. A file in
# a device's folder that the device does not list is an
# upload whose save was rejected — and ageing those out
# raced the retry that was about to reference them.
continue
try:
if item.stat().st_mtime >= staging_cutoff:
continue
except OSError:
continue
try:
item.unlink()
removed += 1
except OSError as err:
_LOGGER.warning("House Plan: could not remove the attachment %s: %s", item, err)
try:
next(folder.iterdir())
except StopIteration:
try:
folder.rmdir()
except OSError:
pass
except OSError:
pass
return removed
class QuotaError(Exception):
"""A store limit would be exceeded. Carries what to tell the user."""
def __init__(self, reason: str, detail: str) -> None:
super().__init__(detail)
self.reason = reason
self.detail = detail
def dir_usage(path: Path) -> tuple[int, int]:
"""(bytes, files) below `path`, ignoring what we cannot read."""
total = count = 0
if not path.is_dir():
return 0, 0
for item in path.rglob("*"):
try:
if item.is_file():
total += item.stat().st_size
count += 1
except OSError:
continue
return total, count
def check_quota(path: Path, incoming: int, max_bytes: int, max_files: int) -> None:
"""Raise QuotaError unless `incoming` more bytes fit.
Deliberately not an age rule. Files are never removed for getting old — that
cost real plans twice — so the limit sits where a decision is being made
anyway: at the moment somebody asks to store something new.
"""
import shutil
used, count = dir_usage(path)
if count + 1 > max_files:
raise QuotaError("too_many_files", f"{count} files already stored, the limit is {max_files}")
if used + incoming > max_bytes:
raise QuotaError(
"quota_exceeded",
f"{(used + incoming) // 1024 // 1024} MB would be stored, the limit is "
f"{max_bytes // 1024 // 1024} MB",
)
try:
free = shutil.disk_usage(str(path if path.is_dir() else path.parent)).free
except OSError:
return
if free - incoming < MIN_FREE_BYTES:
raise QuotaError("low_disk_space", f"only {free // 1024 // 1024} MB free on the disk")
def plan_basename(url: Any) -> str:
"""File name a stored plan_url points at ('' when there is none)."""
@@ -35,6 +256,14 @@ def plan_refs(cfg: dict[str, Any] | None) -> set[str]:
return out
def plan_by_space(cfg: dict[str, Any] | None) -> dict[str, str]:
"""space id -> the plan file it references ('' when it has none)."""
return {
str(sp.get("id")): plan_basename(sp.get("plan_url"))
for sp in (cfg or {}).get("spaces") or []
}
def is_plan_file(name: str) -> bool:
"""Does this look like a plan we wrote: <space>.<ext> or <space>.<token>.<ext>?"""
parts = name.split(".")
@@ -61,7 +290,7 @@ def collect_plans(
* a file the OLD configuration referenced and the new one does not was
authoritative and has been superseded — remove it;
* any other unreferenced plan file is a rejected or abandoned upload, and
is removed only once PLAN_ORPHAN_TTL_S has passed: a fresh one may
is KEPT — see the rule above; only a staging folder ages out: a fresh one may
belong to a transaction that has not committed yet.
Never raises: the configuration is already stored by the time this runs, so
@@ -69,7 +298,29 @@ def collect_plans(
"""
new_refs = plan_refs(new_cfg)
old_refs = plan_refs(old_cfg)
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
# A commit knows what it superseded. The timer only knows what nothing
# points at *right now*, and for a plan that is a reversible state: the
# editor detaches the image when a space switches to "draw" and says the
# file stays on disk. So the scheduled pass keeps anything belonging to a
# space that still exists, and waits a month for the rest.
# A space with NO plan_url has had its image detached — reversible, and the
# editor promises the file stays. A space that HAS one is different: any
# other file of its own is a superseded or rejected upload, so the short
# rule is right for those. Getting this distinction wrong (protecting
# nothing) destroyed two detached plans on 2026-07-28.
# The short rule fits exactly one case: a space that HAS a plan, where any
# other file of its own can only be a superseded or rejected upload.
old_by_space = plan_by_space(old_cfg)
new_by_space = plan_by_space(new_cfg)
# A file that left the configuration tells us nothing on its own: replacing a
# plan, detaching one and deleting a space all look identical from
# `old_refs - new_refs`. Only the first is a deletion the user asked for
# (HP-1465-01 — the guards below were written and then never reached,
# because the code decided "superseded" before asking why).
replaced = {
name for space, name in old_by_space.items()
if new_by_space.get(space) and new_by_space[space] != name
}
removed = 0
try:
items = sorted(plans_dir.iterdir()) if plans_dir.is_dir() else []
@@ -82,12 +333,21 @@ def collect_plans(
for item in items:
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
continue
superseded = item.name in old_refs
try:
stale = item.stat().st_mtime < cutoff
except OSError:
stale = False
if not superseded and not stale:
if item.name not in replaced:
# PRODUCT RULE (owner's decision, 2026-07-28): a plan file we were
# not told to delete is kept, however long it sits there. Detaching
# is one click to undo and the editor says the image stays; deleting
# a space is deliberate but the image was imported and may be
# nowhere else. The errors are not symmetrical — unnecessary
# megabytes can be removed by hand, a deleted file cannot be
# brought back.
#
# There is deliberately no age rule here. An earlier version aged
# out "rejected uploads" — a file of a space that has a plan, which
# was never the plan — and that raced a save: the sweep deleted the
# upload from the failed attempt while a retry was committing a
# reference to it. A rule that can delete a file somebody is about
# to point at is not worth the disk it reclaims.
continue
try:
item.unlink()
+14 -5
View File
@@ -51,8 +51,17 @@ async def async_check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntr
translation_key="broken_plan",
translation_placeholders={"space": space_id, "file": fname},
)
# clear stale issues for spaces that are fine again (or gone)
for sp in spaces:
sid = sp.get("id", "?")
if sid not in broken:
ir.async_delete_issue(hass, DOMAIN, f"broken_plan_{sid}")
# Clear stale issues. Iterating the CURRENT spaces could only ever clear
# issues for spaces that still exist, so deleting or renaming a space with a
# missing plan left its warning in Repairs forever, with nothing left to fix
# it (HP-1454-09). Enumerate what we actually published instead.
registry = ir.async_get(hass)
stale = [
issue_id
for (domain, issue_id) in list(registry.issues)
if domain == DOMAIN
and issue_id.startswith("broken_plan_")
and issue_id[len("broken_plan_") :] not in broken
]
for issue_id in stale:
ir.async_delete_issue(hass, DOMAIN, issue_id)
+12
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,17 @@ class HouseplanData:
# One lock for every load→modify→save cycle of both stores: prevents
# lost updates from concurrent WS calls and makes the rev check atomic.
write_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
# A separate, narrower lock for the check-quota→write-file pair of an
# upload. Without it N parallel uploads all measure the store BEFORE any
# of them writes, and all pass a quota only one of them fits under
# (HP-1490-02). Separate from write_lock so a slow directory scan does not
# stall config/layout commits.
upload_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
# Collect files nothing references any more. Set during setup, which also
# runs it once and schedules it daily. Exposed so it can be invoked
# directly — a test that fakes a 24 h jump proves the timer fires, not that
# the work happens, and those are different claims.
sweep: Callable[[], Awaitable[None]] | None = None
HouseplanConfigEntry = ConfigEntry[HouseplanData]
+62 -20
View File
@@ -16,6 +16,9 @@ MAX_FILE_BYTES = 50 * 1024 * 1024
SPACE_ID_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
# The name length the content view will accept back in a request. Anything a
# generated name must fit inside, collision tag included (HP-1460-01).
MAX_FILENAME = 120
# ---------- sanitizers ----------
@@ -33,7 +36,7 @@ def sanitize_marker_id(value: str) -> str:
def sanitize_filename(value: str) -> str:
"""Drop the path and leading dots, keep a safe file name."""
raw = value.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
return _SAFE_NAME_RE.sub("_", raw).lstrip(".")[:120] or "file"
return _SAFE_NAME_RE.sub("_", raw).lstrip(".")[:MAX_FILENAME] or "file"
def file_ext(filename: str) -> str:
@@ -66,6 +69,31 @@ MAX_MARKERS = 2000
MAX_OPENINGS = 500
MAX_DECOR = 1000
MAX_LAYOUT = 5000
# Inner limits (HP-1454-05). The outer collections were capped, the collections
# INSIDE them were not: a 150 000-point polygon or a 100 000-entry known_devices
# list passed validation, then made the card build gigantic SVG attributes and
# walk them on every render. Any authenticated writer could store one, and with
# `admin_only` off that is every user. These are product limits, not guesses: a
# hand-drawn room does not need 500 vertices, and no home has 200 lights behind
# one switch.
MAX_POLY_POINTS = 500
MAX_OPEN_TO = 50
MAX_CONTROLS = 200
MAX_PDFS = 50
MAX_KNOWN_DEVICES = 20000
MAX_TEXT = 500 # names, models, ids
MAX_DESCRIPTION = 4000
MAX_URL = 2000
# Comfortably below the WebSocket frame limit (aiohttp's default is 4 MB): a
# payload larger than the frame never reaches the handler at all — the socket
# closes with 1009 and the user sees a dropped connection instead of an error
# they can act on. For scale, a real three-floor home with ~200 devices stores
# about 70 KB, so this is ~30x headroom.
MAX_CONFIG_BYTES = 2 * 1024 * 1024
_TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
_TEXT_OR_NONE = vol.Any(None, _TEXT)
_URL = vol.All(str, vol.Length(max=MAX_URL))
POS_SCHEMA = vol.Schema(
{vol.Required("x"): _finite, vol.Required("y"): _finite},
@@ -85,10 +113,10 @@ def _require_geometry(room: dict) -> dict:
ROOM_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("id"): str,
vol.Required("name"): str,
vol.Optional("area"): vol.Any(str, None),
vol.Optional("open_to"): [str],
vol.Required("id"): _TEXT,
vol.Required("name"): _TEXT,
vol.Optional("area"): _TEXT_OR_NONE,
vol.Optional("open_to"): vol.All([_TEXT], vol.Length(max=MAX_OPEN_TO)),
vol.Optional("settings"): vol.Any(
None,
vol.Schema(
@@ -106,7 +134,7 @@ ROOM_SCHEMA = vol.All(
vol.Optional("y"): _finite,
vol.Optional("w"): _finite,
vol.Optional("h"): _finite,
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3)),
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3, max=MAX_POLY_POINTS)),
},
extra=vol.ALLOW_EXTRA,
),
@@ -160,7 +188,15 @@ SPACE_SCHEMA = vol.Schema(
vol.Required("title"): str,
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
vol.Optional("plan_url"): vol.Any(str, None),
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
# The canvas is square since v1.48.0. What used to be the space's own
# `aspect` is gone; the background image keeps its own proportions and
# is centred, so only the IMAGE's ratio is stored. A stale tab may still
# send the old field — it is dropped rather than trusted, because the
# coordinates it comes with were normalised against a different box.
vol.Remove("aspect"): object,
vol.Optional("plan_aspect"): vol.Any(
None, vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20))
),
vol.Required("view_box"): vol.All([_finite], vol.Length(min=4, max=4)),
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
@@ -185,7 +221,10 @@ SPACE_SCHEMA = vol.Schema(
# Legacy: walls are derived from room outlines since v1.19.0 — a line has no
# independent existence. Still accepted so a stale browser tab cannot fail a save;
# the card strips the field on every write.
vol.Optional("segments"): [vol.All([vol.Coerce(float)], vol.Length(min=4, max=4))],
# Accepted so a stale browser tab cannot fail a save, then DROPPED here
# (HP-1454-05): relying on a modern client to strip an unbounded legacy
# list is not a limit, it is a hope. `Remove` returns the key stripped.
vol.Remove("segments"): object,
},
extra=vol.ALLOW_EXTRA,
)
@@ -197,24 +236,27 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("space"): vol.Any(str, None),
vol.Optional("area"): vol.Any(str, None),
vol.Optional("hidden"): bool,
vol.Optional("name"): vol.Any(str, None),
vol.Optional("icon"): vol.Any(str, None),
vol.Optional("model"): vol.Any(str, None),
vol.Optional("link"): vol.Any(str, None),
vol.Optional("description"): vol.Any(str, None),
vol.Optional("name"): _TEXT_OR_NONE,
vol.Optional("icon"): _TEXT_OR_NONE,
vol.Optional("model"): _TEXT_OR_NONE,
vol.Optional("link"): vol.Any(None, _URL),
vol.Optional("description"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DESCRIPTION))),
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
vol.Optional("controls"): vol.Any([str], None),
vol.Optional("controls"): vol.Any(None, vol.All([_TEXT], vol.Length(max=MAX_CONTROLS))),
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
vol.Optional("is_light"): vol.Any(bool, None),
vol.Optional("room_id"): vol.Any(str, None),
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", None),
# keep in sync with DISPLAY_MODES in src/logic.ts — a cross-language test
# asserts every option the editor offers is accepted here (issue #3)
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", "value", None),
vol.Optional("ripple_color"): vol.Any(str, None),
vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None),
vol.Optional("size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0.2, max=6)), None),
vol.Optional("angle"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-360, max=360)), None),
vol.Optional("pdfs"): [
vol.Schema({vol.Required("name"): str, vol.Required("url"): str}, extra=vol.ALLOW_EXTRA)
],
vol.Optional("pdfs"): vol.All(
[vol.Schema({vol.Required("name"): _TEXT, vol.Required("url"): _URL}, extra=vol.ALLOW_EXTRA)],
vol.Length(max=MAX_PDFS),
),
},
extra=vol.ALLOW_EXTRA,
)
@@ -225,8 +267,8 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional("settings", default=dict): vol.Schema(
{
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
vol.Optional("known_devices"): [str],
vol.Optional("new_device_ids"): [str],
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("fill_colors"): vol.Schema(
{
str: vol.Schema(
+287 -49
View File
@@ -5,6 +5,7 @@ import logging
import base64
import binascii
import json
import secrets
from pathlib import Path
from typing import Any
@@ -16,14 +17,19 @@ from homeassistant.core import HomeAssistant, callback
from .const import (
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
CONTENT_URL, MAX_SIGN_PATHS, PLANS_DIR, PLANS_URL,
CONTENT_URL, FILES_DIR, MAX_PLANS_BYTES, MAX_PLANS_FILES, MAX_PLANS_LISTED,
MAX_SIGN_PATHS,
PLANS_DIR, PLANS_URL,
)
from .auth import may_write
from .plans import collect_plans
from .plans import (
QuotaError, check_quota, collect_attachments, collect_plans, is_plan_file,
plan_basename, plan_refs, reserve_filename,
)
from .store import HouseplanData, get_data, get_entry
from .validation import (
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_PLAN_BYTES,
PLAN_EXTENSIONS, POS_SCHEMA, valid_space_id,
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
PLAN_EXTENSIONS, POS_SCHEMA, sanitize_filename, valid_space_id,
)
@@ -40,6 +46,8 @@ def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_config_get)
websocket_api.async_register_command(hass, ws_config_set)
websocket_api.async_register_command(hass, ws_plan_set)
websocket_api.async_register_command(hass, ws_plans_list)
websocket_api.async_register_command(hass, ws_plans_delete)
websocket_api.async_register_command(hass, ws_files_migrate)
websocket_api.async_register_command(hass, ws_files_cleanup)
websocket_api.async_register_command(hass, ws_content_sign)
@@ -74,7 +82,9 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
if rt is None:
return
data = await rt.store.async_load() or {}
connection.send_result(msg["id"], {"layout": data.get("layout", {})})
connection.send_result(
msg["id"], {"layout": data.get("layout", {}), "rev": int(data.get("rev", 0))}
)
@websocket_api.websocket_command(
@@ -107,8 +117,10 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
msg["id"], "conflict", f"Layout changed elsewhere (rev {current_rev})"
)
return
await rt.store.async_save({"layout": msg["layout"], "rev": current_rev + 1})
connection.send_result(msg["id"], {"ok": True, "rev": current_rev + 1})
new_rev = current_rev + 1
await rt.store.async_save({"layout": msg["layout"], "rev": new_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@websocket_api.websocket_command(
@@ -131,8 +143,13 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
data = await rt.store.async_load() or {}
layout = data.get("layout", {})
layout[msg["device_id"]] = msg["pos"]
await rt.store.async_save({"layout": layout})
connection.send_result(msg["id"], {"ok": True})
# keep the revision: a point-wise write used to drop it, which made the
# optimistic locking on layout/set meaningless — every drag reset the
# counter to 0 (HP-1454-08)
new_rev = int(data.get("rev", 0)) + 1
await rt.store.async_save({"layout": layout, "rev": new_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@websocket_api.websocket_command(
@@ -183,17 +200,18 @@ async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any])
for f in sorted(src.iterdir()):
if not f.is_file():
continue
target = dst / f.name
if target.exists():
# a different file already owns this name — do NOT silently
# point the url at it; give the copy a unique name instead
stem, suffix = f.stem, f.suffix
i = 2
while (dst / f"{stem} ({i}){suffix}").exists():
i += 1
target = dst / f"{stem} ({i}){suffix}"
shutil.copy2(str(f), str(target))
mapping[f.name] = target.name
# a different file may already own this name — do NOT silently point
# the url at it. The shared helper CLAIMS a free one atomically, so
# a concurrent migrate or upload cannot pick the same one, and the
# name it returns is one the content view accepts back in a request.
name = reserve_filename(dst, f.name)
target = dst / name
try:
shutil.copy2(str(f), str(target))
except OSError:
target.unlink(missing_ok=True) # never leave an empty placeholder
raise
mapping[f.name] = name
return mapping
try:
@@ -204,6 +222,105 @@ async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any])
connection.send_result(msg["id"], {"ok": True, "mapping": mapping, "copied": len(mapping)})
@websocket_api.websocket_command({vol.Required("type"): "houseplan/plans/list"})
@websocket_api.async_response
async def ws_plans_list(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Plan images on the server, with what still uses them.
Files are never removed for being unreferenced (docs/SCOPE.md), which only
works as a policy if the user can see them: detaching a plan keeps the
image, and this is how it gets picked up again — or deleted on purpose.
"""
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
stored = await rt.config_store.async_load() or {}
cfg = stored.get("config") or {}
used: dict[str, list[str]] = {}
for space in cfg.get("spaces") or []:
name = plan_basename(space.get("plan_url"))
if name:
used.setdefault(name, []).append(space.get("title") or space.get("id") or "?")
plans_dir = Path(hass.config.path(PLANS_DIR))
def _scan() -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
if not plans_dir.is_dir():
return out
for item in sorted(plans_dir.iterdir()):
if not item.is_file() or not is_plan_file(item.name):
continue
try:
st = item.stat()
except OSError:
continue
out.append({
"name": item.name,
"url": f"{CONTENT_URL}/plans/_/{item.name}",
"size": st.st_size,
"modified": int(st.st_mtime),
"used_by": used.get(item.name, []),
})
out.sort(key=lambda x: -x["modified"])
return out
plans = await hass.async_add_executor_job(_scan)
# newest first and capped: a folder with thousands of files would otherwise
# become one huge message, one huge list and a signing request per row
connection.send_result(
msg["id"], {"plans": plans[:MAX_PLANS_LISTED], "total": len(plans)}
)
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/plans/delete",
vol.Required("name"): str,
}
)
@websocket_api.async_response
async def ws_plans_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Delete a plan image because the user asked — the only way one goes.
Refuses while a space still references it: the answer to "can I delete this"
is the stored configuration's, not the client's.
"""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may delete plans")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
name = sanitize_filename(msg["name"])
if not is_plan_file(name):
connection.send_error(msg["id"], "invalid_name", "Not a plan file")
return
async with rt.write_lock:
stored = await rt.config_store.async_load() or {}
cfg = stored.get("config") or {}
if name in plan_refs(cfg):
connection.send_error(
msg["id"], "in_use", "A space still uses this plan — detach it first"
)
return
path = Path(hass.config.path(PLANS_DIR)) / name
def _rm() -> bool:
try:
path.unlink()
return True
except FileNotFoundError:
return False
except OSError as err:
_LOGGER.warning("House Plan: could not delete %s: %s", path, err)
return False
removed = await hass.async_add_executor_job(_rm)
connection.send_result(msg["id"], {"ok": True, "removed": removed})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/content/sign",
@@ -252,34 +369,68 @@ async def ws_content_sign(hass: HomeAssistant, connection, msg: dict[str, Any])
)
@websocket_api.async_response
async def ws_files_cleanup(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Delete a marker's file folder — called only AFTER the config is committed."""
"""Drop a marker folder's leftovers after its files moved elsewhere.
Called after a rebind: the files were copied to the new marker id and the
config that references them is committed, so the source folder is spent.
It used to `rmtree` the folder on the client's word alone. Two ways that
ends badly: a partial copy leaves some urls still pointing INTO this folder
(the migration deliberately does not rewrite those), and a wrong or stale
id from any client deletes a live marker's attachments outright. So the
server checks for itself — under the config lock — and removes only files
the stored configuration does not reference. Same principle as the
collector: a client may say what it no longer needs, never what may go.
"""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit files")
return
import shutil
from pathlib import Path
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
from .const import FILES_DIR
from .plans import attachment_refs
from .validation import sanitize_marker_id
mid = sanitize_marker_id(msg["marker_id"])
if not mid:
connection.send_result(msg["id"], {"ok": True, "removed": False})
return
base = Path(hass.config.path(FILES_DIR)).resolve()
target = (base / mid).resolve()
if not str(target).startswith(str(base)) or target == base:
connection.send_result(msg["id"], {"ok": True, "removed": False})
target = (base / mid).resolve() if mid else base
if not mid or not str(target).startswith(str(base)) or target == base:
connection.send_result(msg["id"], {"ok": True, "removed": 0, "kept": 0})
return
def _rm() -> bool:
if not target.is_dir():
return False
shutil.rmtree(target, ignore_errors=True)
return True
async with rt.write_lock:
stored = await rt.config_store.async_load() or {}
refs = attachment_refs(stored.get("config") or {})
removed = await hass.async_add_executor_job(_rm)
connection.send_result(msg["id"], {"ok": True, "removed": removed})
def _rm() -> tuple[int, int]:
if not target.is_dir():
return 0, 0
removed = kept = 0
for item in sorted(target.iterdir()):
if not item.is_file():
continue
if f"{mid}/{item.name}" in refs:
kept += 1
continue
try:
item.unlink()
removed += 1
except OSError as err:
_LOGGER.warning("House Plan: could not remove %s: %s", item, err)
if not kept:
try:
target.rmdir()
except OSError:
pass
return removed, kept
removed, kept = await hass.async_add_executor_job(_rm)
if kept:
_LOGGER.info(
"House Plan: kept %s file(s) in %s — the configuration still references them", kept, mid
)
connection.send_result(msg["id"], {"ok": True, "removed": removed, "kept": kept})
@websocket_api.websocket_command(
@@ -297,13 +448,17 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
new_rev: int | None = None
async with rt.write_lock:
data = await rt.store.async_load() or {}
layout = data.get("layout", {})
if msg["device_id"] in layout:
del layout[msg["device_id"]]
await rt.store.async_save({"layout": layout})
connection.send_result(msg["id"], {"ok": True})
new_rev = int(data.get("rev", 0)) + 1
await rt.store.async_save({"layout": layout, "rev": new_rev})
if new_rev is not None:
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
# ---------------- space configuration ----------------
@@ -322,6 +477,46 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
def _internal_plan_names(config: dict[str, Any]) -> set[str]:
"""Plan file names a configuration names through OUR urls.
Only `/api/houseplan/content/plans/_/<name>` and the legacy static path
count. Anything else belongs to the user and may point wherever they like.
"""
out: set[str] = set()
for space in (config or {}).get("spaces") or []:
url = space.get("plan_url")
if not isinstance(url, str) or not url:
continue
if not (url.startswith(CONTENT_URL + "/plans/") or url.startswith(PLANS_URL + "/")):
continue
name = plan_basename(url)
if name:
out.add(name)
return out
def _missing_internal_plans(
plans_dir: Path, config: dict[str, Any], previous: dict[str, Any] | None = None
) -> set[str]:
"""Newly named plan files that are not on disk.
Guards the pick-then-save window: another client may delete a plan between
the moment this one chose it and the moment it saves, which would otherwise
store a url with nothing behind it (HP-1470-02).
A name the stored configuration already carries is deliberately let through.
It is already broken — repairs says so — and refusing the write would lock
the owner out of every other edit, including the one that detaches it.
"""
known = _internal_plan_names(previous or {})
return {
name
for name in _internal_plan_names(config)
if name not in known and not (plans_dir / name).is_file()
}
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/config/set",
@@ -343,6 +538,16 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
# Per-field limits bound each list; this bounds their product (HP-1454-05).
# Everything below the caps can still add up to something no dashboard can
# render, and the store writes it to disk on every save.
size = len(json.dumps(msg["config"], separators=(",", ":")))
if size > MAX_CONFIG_BYTES:
connection.send_error(
msg["id"], "too_large",
f"Configuration is {size // 1024} KB, the limit is {MAX_CONFIG_BYTES // 1024} KB",
)
return
async with rt.write_lock:
data = await rt.config_store.async_load() or {}
current_rev = data.get("rev", 0)
@@ -361,6 +566,23 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
f"Configuration was changed in another window (rev {current_rev} != {msg['expected_rev']})",
)
return
# An internal plan url must name a file that exists. The card can pick a
# plan and then delete it from the same dialog, and two clients can do
# the same thing in either order — the lock serialises them but says
# nothing about whether the file survived (HP-1470-02). External and
# legacy urls are not ours to check and are left alone.
missing = await hass.async_add_executor_job(
_missing_internal_plans,
Path(hass.config.path(PLANS_DIR)),
msg["config"],
data.get("config"),
)
if missing:
connection.send_error(
msg["id"], "missing_plan",
"Plan file no longer exists: " + ", ".join(sorted(missing)),
)
return
new_rev = current_rev + 1
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
# Still holding the lock: the file system is not part of the store's
@@ -369,12 +591,14 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
# failure here must not withhold the event and the success response,
# or the client retries an edit the server has already accepted and
# gets a conflict for its trouble (R4-1).
def _collect() -> None:
collect_plans(Path(hass.config.path(PLANS_DIR)), data.get("config"), msg["config"])
collect_attachments(Path(hass.config.path(FILES_DIR)), data.get("config"), msg["config"])
try:
await hass.async_add_executor_job(
collect_plans, Path(hass.config.path(PLANS_DIR)), data.get("config"), msg["config"]
)
await hass.async_add_executor_job(_collect)
except Exception: # noqa: BLE001 — see above: the commit stands regardless
_LOGGER.exception("House Plan: collecting superseded plan files failed")
_LOGGER.exception("House Plan: collecting superseded files failed")
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
# refresh repair issues (broken plan references) without waiting for a restart
entry = get_entry(hass)
@@ -419,10 +643,11 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
# deleted here (review R2-1). The old name stays readable, so a config write
# that is later rejected — revision conflict, validation, lost connection —
# leaves the stored plan exactly as it was. The card calls
# nothing here; the superseded file is collected by `config/set` itself,
# inside the write lock, once a revision that no longer references it has
# been accepted (review R3-1). A crash in between leaves an orphan, which
# the same collector removes on a later commit once it is old enough.
# nothing here; the file a commit REPLACES is collected by `config/set`
# itself, inside the write lock (review R3-1). An upload that never gets
# committed is not collected at all — it is offered back in the space
# dialog's "already uploaded" list, where the user can attach or delete it.
# Every attempt to age these out ended in data loss or a race (v1.46.4-6).
#
# `.` separates the id from the token because a space id cannot contain one
# (SPACE_ID_RE), so "<space>.<token>.<ext>" can never be confused with the
@@ -431,9 +656,22 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
name = f"{space_id}.{secrets.token_hex(4)}.{msg['ext']}"
path = plans_dir / name
def _write() -> None:
def _check_and_write() -> None:
# one executor job for the pair, under upload_lock: the measurement
# is only a bound if nothing else writes between it and our write
# (HP-1490-02). A failed write reserves nothing — the file either
# exists and is counted by the next scan, or does not and is not.
check_quota(plans_dir, len(raw), MAX_PLANS_BYTES, MAX_PLANS_FILES)
plans_dir.mkdir(parents=True, exist_ok=True)
path.write_bytes(raw)
await hass.async_add_executor_job(_write)
data = _runtime(hass, connection, msg["id"])
if data is None:
return
async with data.upload_lock:
try:
await hass.async_add_executor_job(_check_and_write)
except QuotaError as err:
connection.send_error(msg["id"], err.reason, err.detail)
return
connection.send_result(msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{name}"})
+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;
+75
View File
@@ -0,0 +1,75 @@
// Аудит v1.49.0: HP-1490-03 (редакторы видят весь холст) и HP-1490-04
// (Save ждёт пропорции выбранного сохранённого плана, старые не наследуются).
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 1000 }, 1);
const out = {};
// ---- HP-1490-03: content-fit только в просмотре -------------------------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
// рукописное пространство: одна маленькая комната в центре квадрата
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
cfg.spaces[0].plan_url = null; cfg.spaces[0].plan_aspect = null;
cfg.spaces[0].rooms = [{ id: 'r1', name: 'One', area: 'living_room',
poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }];
c._serverCfg = cfg; c._model = null; c._view = null; c.requestUpdate();
await c.updateComplete;
await new Promise((r) => requestAnimationFrame(r));
const inView = c._baseVb();
o.viewIsContentFit = inView[2] < 999; // меньше холста (устройства тоже содержимое)
c._setMode('plan'); await c.updateComplete;
await new Promise((r) => requestAnimationFrame(r));
const inPlan = c._baseVb();
o.editorSeesWholeCanvas = inPlan[2] === 1000 && inPlan[3] === 1000;
const v = c._viewOr(c._baseVb());
o.editorViewCoversCanvas = v.w >= 999; // старый cropped view не пережил смену
// в редакторе можно ткнуть в дальний угол холста
o.canReachFarCorner = (() => {
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
const pt = c._screenToVb(stage.clientWidth - 1, stage.clientHeight - 1);
return pt[0] > 900 || pt[1] > 900;
})();
c._setMode('view'); await c.updateComplete;
await new Promise((r) => requestAnimationFrame(r));
const back = c._baseVb();
o.contentFitRestored = back[2] < 999;
return o;
}));
// ---- HP-1490-04: Save ждёт aspect --------------------------------------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
const base = c.hass.callWS;
let saved = null;
let signDelay = 500; // подпись приходит поздно
c.hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/plans/list') return { plans: [
{ name: 'wide.svg', url: '/api/houseplan/content/plans/_/wide.svg', size: 10, modified: 1, used_by: [] },
] };
if (m.type === 'houseplan/content/sign') {
await new Promise((r) => setTimeout(r, signDelay));
const urls = {}; for (const p of m.paths) urls[p] = '/assets/wide.svg'; return { urls };
}
if (m.type === 'houseplan/config/set') { saved = m.config; return { rev: (c._cfgRev || 0) + 1 }; }
if (m.type === 'houseplan/config/get') return { config: saved || c._serverCfg, rev: c._cfgRev || 0 };
return base(m);
} };
// страница отдаёт /assets/wide.svg размером 800x200 (создан рядом)
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, source: 'file', planUrl: null, planFile: null };
await c.updateComplete;
c._useServerPlan('/api/houseplan/content/plans/_/wide.svg');
o.oldAspectCleared = c._spaceDialog.savedAspect === undefined;
// Save сразу, до прихода подписи
const p = c._saveSpaceDialog();
await p;
o.savedUrl = saved?.spaces?.[0]?.plan_url === '/api/houseplan/content/plans/_/wide.svg';
const a = saved?.spaces?.[0]?.plan_aspect;
o.savedAspectIsReal = Math.abs((a || 0) - 4) < 0.01; // 800x200
o.notTheOldAspect = a !== 1.25;
return o;
}));
await finish(browser, checkAll(out));
+67
View File
@@ -0,0 +1,67 @@
// HP-1454-03: две локальные правки уходили с одной ревизией, вторая терялась.
// Debounce разносил только СТАРТЫ. Если первый config/set отвечал дольше 500 мс,
// вторая правка уходила с тем же expected_rev, сервер принимал первую и
// отклонял вторую как conflict — а обработчик конфликта перечитывал серверную
// копию поверх локальной. Правка исчезала, и тост винил «другое окно».
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const base = c.hass.callWS;
const writes = [];
let rev = 10;
let releaseFirst;
const firstGate = new Promise((r) => { releaseFirst = r; });
c.hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/set') {
const n = writes.length + 1;
writes.push({ expected: m.expected_rev, titles: m.config.spaces.map((s) => s.title) });
if (n === 1) await firstGate; // первый ответ задержан
if (m.expected_rev !== rev) { const e = new Error('conflict'); e.code = 'conflict'; throw e; }
rev += 1;
return { ok: true, rev };
}
if (m.type === 'houseplan/config/get') {
const r = await base(m);
return { config: JSON.parse(JSON.stringify(r.config)), rev };
}
return base(m);
} };
c._cfgRev = rev;
// правка №1 и, пока первая запись висит, правка №2
c._serverCfg.spaces[0].title = 'FIRST';
c._saveConfig();
c._saveConfigDebounced.flush();
await new Promise((r) => setTimeout(r, 30));
out.oneInFlight = writes.length === 1;
c._serverCfg.spaces[0].title = 'SECOND';
c._saveConfig();
c._saveConfigDebounced.flush();
await new Promise((r) => setTimeout(r, 30));
out.stillOneInFlight = writes.length === 1; // вторая ждёт очереди, не летит параллельно
releaseFirst();
await new Promise((r) => setTimeout(r, 120));
out.writes = writes.length;
out.revisions = writes.map((w) => w.expected); // вторая обязана взять новую ревизию
out.secondCarriedTheEdit = writes[1]?.titles[0] === 'SECOND';
out.editSurvived = c._serverCfg.spaces[0].title === 'SECOND';
out.noConflictToast = !(c._toast || '').length;
return out;
});
// зафиксировано прогоном на v1.46.0 и сверено с кодом
checkAll(res, {
oneInFlight: true,
stillOneInFlight: true,
writes: 2,
revisions: [10, 11],
secondCarriedTheEdit: true,
editSurvived: true,
noConflictToast: true,
});
await finish(browser);
+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);
+103
View File
@@ -0,0 +1,103 @@
// HP-1460-03: позиции — отдельное состояние. В v1.46.0 событие layout_updated
// научилась слушать статическая карточка, а полная — нет, поэтому две полные
// карточки рядом расходились до перезагрузки. Проверяем и обратное: приход
// чужой ревизии не должен затирать перетаскивание, которое ещё не улетело.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const base = c.hass.callWS;
// общий «сервер»: layout с ревизией и подписчики на событие
let rev = 5;
let layout = { dev_a: { x: 10, y: 10 }, dev_b: { x: 20, y: 20 } };
const subs = [];
let gets = 0;
const hass = { ...c.hass,
callWS: async (m) => {
if (m.type === 'houseplan/layout/get') { gets++; return { layout: JSON.parse(JSON.stringify(layout)), rev }; }
if (m.type === 'houseplan/layout/update') {
layout = { ...layout, [m.device_id]: m.pos }; rev += 1;
subs.forEach((f) => f({ data: { rev } }));
return { ok: true, rev };
}
return base(m);
},
connection: { subscribeEvents: async (cb, ev) => {
if (ev === 'houseplan_layout_updated') { subs.push(cb); return () => {}; }
return () => {};
} },
};
c.hass = hass;
c._serverStorage = true;
c._layout = JSON.parse(JSON.stringify(layout));
c._layoutRev = rev;
c._unsubLayout = await hass.connection.subscribeEvents(
(e) => c._onLayoutEvent(Number(e?.data?.rev ?? -1)),
'houseplan_layout_updated',
);
out.subscribed = subs.length === 1;
// 1) чужая карточка подвинула иконку — наша обязана подхватить без перезагрузки
layout = { ...layout, dev_a: { x: 77, y: 88 } }; rev += 1;
subs.forEach((f) => f({ data: { rev } }));
await new Promise((r) => setTimeout(r, 350));
out.adoptedRemoteMove = JSON.stringify(c._layout.dev_a) === JSON.stringify({ x: 77, y: 88 });
out.revFollowed = c._layoutRev === rev;
// 2) собственная запись не вызывает лишнего перечитывания
const before = gets;
c._layout = { ...c._layout, dev_b: { x: 31, y: 32 } };
c._dirtyPos.add('dev_b');
c._persistLayout();
c._persistLayout.flush();
await new Promise((r) => setTimeout(r, 350));
out.ownWriteNoReload = gets === before;
out.ownWriteKept = JSON.stringify(c._layout.dev_b) === JSON.stringify({ x: 31, y: 32 });
// 3) НАСТОЯЩЕЕ перетаскивание: debounce запланирован, запись задержана, а
// layout/get отвечает мгновенно. Именно этот порядок и терял позицию:
// flush() внутри перечитывания опустошал _dirtyPos ДО снятия снимка.
let releaseUpdate;
const updateGate = new Promise((r) => { releaseUpdate = r; });
let delayUpdate = true;
const plain = hass.callWS;
c.hass = { ...hass, callWS: async (m) => {
if (m.type === 'houseplan/layout/update' && delayUpdate) {
delayUpdate = false;
await updateGate;
}
return plain(m);
} };
c._layout = { ...c._layout, dev_a: { x: 5, y: 6 } };
c._dirtyPos.add('dev_a');
c._persistLayout(); // debounce запланирован, не сброшен вручную
layout = { ...layout, dev_b: { x: 99, y: 99 } }; rev += 1;
subs.forEach((f) => f({ data: { rev } }));
await new Promise((r) => setTimeout(r, 400));
out.dragKeptWhileWriteInFlight = JSON.stringify(c._layout.dev_a) === JSON.stringify({ x: 5, y: 6 });
out.remoteChangeApplied = JSON.stringify(c._layout.dev_b) === JSON.stringify({ x: 99, y: 99 });
releaseUpdate();
await new Promise((r) => setTimeout(r, 350));
out.localDragSurvived = JSON.stringify(c._layout.dev_a) === JSON.stringify({ x: 5, y: 6 });
out.serverAgrees = JSON.stringify(layout.dev_a) === JSON.stringify({ x: 5, y: 6 });
out.sentPosDrained = c._sentPos.size === 0;
return out;
});
// зафиксировано прогоном на v1.46.1 и сверено с кодом
checkAll(res, {
subscribed: true,
adoptedRemoteMove: true,
revFollowed: true,
ownWriteNoReload: true,
ownWriteKept: true,
dragKeptWhileWriteInFlight: true,
remoteChangeApplied: true,
localDragSurvived: true,
serverAgrees: true,
sentPosDrained: true,
});
await finish(browser);
+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);
+59
View File
@@ -0,0 +1,59 @@
// HP-1454-07: статическая карточка строит модель другой функцией, и room.settings
// в неё не переносились — переопределение заливки на уровне комнаты она
// игнорировала и красила комнату, которую полная карточка оставляет прозрачной.
// Плюс HP-1454-08: layout-события должны доходить до статической карточки без
// перезагрузки страницы.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
const res = await page.evaluate(async () => {
const out = {};
await customElements.whenDefined('houseplan-space-card');
const main = window.__card;
// заливка по свету на пространстве, у первой комнаты — переопределение "none"
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
const f1 = cfg.spaces.find((s) => s.id === 'f1');
f1.settings = { ...(f1.settings || {}), show_borders: true, show_names: true, fill_mode: 'light' };
f1.rooms[0].settings = { fill_mode: 'none' };
const hass = { ...main.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
if (m.type === 'houseplan/layout/get') return { layout: {}, rev: 1 };
return { ok: true };
} };
main._serverCfg = cfg;
main._cfgEpoch++;
main.requestUpdate(); await main.updateComplete;
const host = document.createElement('div');
document.body.appendChild(host);
const card = document.createElement('houseplan-space-card');
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
card.hass = hass;
host.appendChild(card);
const t0 = Date.now();
while (!card.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) {
await new Promise((r) => setTimeout(r, 60));
}
await card.updateComplete;
const overridden = (root) => {
const rooms = [...root.querySelectorAll('.room')];
return rooms.length ? ((rooms[0].getAttribute('style') || '').match(/--room-fill:([^;]+)/) || [])[1] || null : 'missing';
};
out.fullCardRoom0 = overridden(main.shadowRoot || main.renderRoot);
out.staticCardRoom0 = overridden(card.renderRoot);
out.parity = out.fullCardRoom0 === out.staticCardRoom0;
out.overrideRespected = out.staticCardRoom0 === 'transparent';
return out;
});
// зафиксировано прогоном на v1.46.0 и сверено с кодом
checkAll(res, {
fullCardRoom0: 'transparent',
staticCardRoom0: 'transparent',
parity: true,
overrideRespected: true,
});
await finish(browser);
+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);
+78
View File
@@ -0,0 +1,78 @@
// HP-1454-01: загруженный SVG — пользовательский контент, который Home Assistant
// отдаёт со своего origin. Внутри карточки он подключён через <image>, где
// скрипты не выполняются, но тот же URL, открытый как отдельный документ,
// становится живым документом этого origin: <script> в нём получает доступ к
// localStorage сессии и к API. Проверяем, что заголовок sandbox это снимает,
// и что обычный SVG при этом продолжает отображаться.
import { chromium } from 'playwright';
import { check, finish } from './serve.mjs';
const EVIL = `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<rect width="100" height="100" fill="#eee"/>
<script>
document.title = 'HOUSEPLAN_XSS_EXECUTED';
try { localStorage.setItem('hp_xss', 'executed'); } catch (e) {}
</script>
</svg>`;
// ровно тот набор, который отдаёт HouseplanContentView для .svg
const CSP = "sandbox; default-src 'none'; script-src 'none'; object-src 'none'; "
+ "base-uri 'none'; form-action 'none'; style-src 'unsafe-inline'; img-src data:";
const browser = await chromium.launch({ args: ['--no-sandbox'] });
const ctx = await browser.newContext();
async function serve(page, { csp }) {
await page.route('**/*', (route) => {
const url = route.request().url();
if (url.endsWith('/evil.svg')) {
const headers = { 'Content-Type': 'image/svg+xml', 'X-Content-Type-Options': 'nosniff' };
if (csp) headers['Content-Security-Policy'] = CSP;
return route.fulfill({ status: 200, headers, body: EVIL });
}
return route.fulfill({ status: 200, contentType: 'text/html', body: '<html><body>host</body></html>' });
});
}
// 1) как было до фикса: скрипт исполняется в origin Home Assistant
const before = await ctx.newPage();
await serve(before, { csp: false });
await before.goto('https://ha.example/api/houseplan/content/plans/_/evil.svg');
await before.waitForTimeout(200);
const noCsp = await before.evaluate(() => ({
title: document.title,
storage: (() => { try { return localStorage.getItem('hp_xss'); } catch (e) { return 'blocked'; } })(),
}));
// 2) с заголовком: opaque origin, скрипт не выполняется, storage недоступен
const after = await ctx.newPage();
await serve(after, { csp: true });
await after.goto('https://ha.example/api/houseplan/content/plans/_/evil.svg');
await after.waitForTimeout(200);
const withCsp = await after.evaluate(() => ({
title: document.title,
storage: (() => { try { return localStorage.getItem('hp_xss'); } catch (e) { return 'blocked'; } })(),
}));
// 3) тот же файл как <image> внутри страницы — рисуется и без скрипта
const card = await ctx.newPage();
await serve(card, { csp: true });
await card.goto('https://ha.example/');
const drawn = await card.evaluate(async () => {
const img = new Image();
const ok = await new Promise((res) => {
img.onload = () => res(true);
img.onerror = () => res(false);
img.src = '/api/houseplan/content/plans/_/evil.svg';
});
return { loaded: ok, width: img.naturalWidth, title: document.title };
});
check('без CSP скрипт выполняется (иначе тест ничего не доказывает)', noCsp.title, 'HOUSEPLAN_XSS_EXECUTED');
check('без CSP скрипт пишет в storage origin', noCsp.storage, 'executed');
check('с CSP скрипт не выполняется', withCsp.title !== 'HOUSEPLAN_XSS_EXECUTED', true);
check('с CSP storage origin недоступен', withCsp.storage !== 'executed', true);
check('SVG по-прежнему грузится как картинка', drawn.loaded, true);
check('и имеет размеры', drawn.width, 100);
check('картинка ничего не выполнила на странице-хосте', drawn.title, '');
await finish(browser);
+64
View File
@@ -0,0 +1,64 @@
// v1.49.x: zoom goes below the base fit, the editor does not shift the plan.
// - the stage height follows the MEASURED header, not a hard-coded 118px, so
// entering an editor keeps the plan inside the viewport;
// - zoom < 1 centres the content instead of pinning it to a corner;
// - the content frame (default zoom) includes devices standing outside rooms.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = {};
// -- editor entry keeps the stage inside the viewport --------------------
const stageBox = () => page.evaluate(() => {
const sr = window.__card.shadowRoot || window.__card.renderRoot;
const b = sr.querySelector('.stage').getBoundingClientRect();
return { top: Math.round(b.top), bottom: Math.round(b.bottom) };
});
const vh = await page.evaluate(() => window.innerHeight);
const inView = await stageBox();
await page.evaluate(() => window.__card._setMode('plan'));
await page.waitForTimeout(400);
const inPlan = await stageBox();
out.viewFitsViewport = inView.bottom <= vh + 2;
out.editorFitsViewport = inPlan.bottom <= vh + 2; // used to overflow by ~90px
out.editorStageShrinks = inPlan.top > inView.top && inPlan.bottom <= inView.bottom + 2;
await page.evaluate(() => window.__card._setMode('view'));
await page.waitForTimeout(300);
// -- zoom out below the base fit -----------------------------------------
out.zoomOut = await page.evaluate(() => {
const c = window.__card;
c._resetZoom();
const fit = { ...c._viewOr(c._baseVb()) };
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
c._zoomAt(stage.clientWidth / 2, stage.clientHeight / 2, 0.5);
const v = { ...c._view };
const base = c._baseVb();
const cx = v.x + v.w / 2, cy = v.y + v.h / 2;
return {
zoom: c._zoom,
wider: v.w > fit.w * 1.9, // actually zoomed out
centredX: Math.abs(cx - (base[0] + base[2] / 2)) < 1, // not pinned to a corner
centredY: Math.abs(cy - (base[1] + base[3] / 2)) < 1,
};
});
out.zoomOutWorks = out.zoomOut.zoom === 0.5 && out.zoomOut.wider
&& out.zoomOut.centredX && out.zoomOut.centredY;
delete out.zoomOut;
out.floorIsHalf = await page.evaluate(() => { window.__card._resetZoom(); const c = window.__card;
c._applyView(0.1); return c._zoom; }) === 0.4; // clamped at the floor
await page.evaluate(() => window.__card._resetZoom());
// -- devices outside rooms stretch the default frame ---------------------
out.devicesStretchFrame = await page.evaluate(() => {
const c = window.__card;
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
cfg.spaces[0].plan_url = null; cfg.spaces[0].plan_aspect = null;
c._serverCfg = cfg; c._model = null;
const before = c._baseVb();
// walk one lamp far outside every room
c._layout = { ...c._layout, d_lamp: { s: 'f1', x: 0.99, y: 0.5 } };
const after = c._baseVb();
return after[0] + after[2] > before[0] + before[2] + 20; // right edge follows the lamp
});
await finish(browser, checkAll(out));
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="200" viewBox="0 0 800 200"><rect width="800" height="200" fill="#eee"/></svg>

After

Width:  |  Height:  |  Size: 137 B

+7 -7
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: {}
+112 -45
View File
File diff suppressed because one or more lines are too long
+95 -8
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)
@@ -172,14 +172,92 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
| Command | Parameters | Response |
|---|---|---|
| `houseplan/layout/get` | — | `{layout: {device_id: {x,y}}}` |
| `houseplan/layout/set` | `layout` | `{ok}` (admin_only optional) |
| `houseplan/layout/update` | `device_id`, `pos` | `{ok}` |
| `houseplan/layout/get` | — | `{layout: {device_id: {x,y}}, rev}` |
| `houseplan/layout/set` | `layout`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_layout_updated` |
| `houseplan/layout/update` | `device_id`, `pos` | `{ok, rev}`; event `houseplan_layout_updated` |
| `houseplan/config/get` | — | `{config, rev}` |
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` — writes `<space>.<token>.<ext>`, deletes nothing |
| `houseplan/plans/list` | — | `{plans: [{name, url, size, modified, used_by}]}` |
| `houseplan/plans/delete` | `name` | `{ok, removed}` / err `in_use` |
| `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) |
**The canvas is square, the image is not** (v1.48.0). A space used to carry an
`aspect`, and coordinates were normalised against it — x by the width, y by the
height. That made every geometric question depend on a per-space number for no
benefit. Now the render space is `NORM_W × NORM_W` and a plan image is fitted
inside it by its own ratio (`fitInSquare`, shared by both renderers), which is
stored as `plan_aspect` so the layout does not jump before the file loads.
Upgrading runs `geometry_migration.migrate_config` once: it pads the old box out
to a square and re-expresses every coordinate against it — a uniform scale plus
an offset in render units, so angles and proportions are exact — and scales
`cell_cm` for tall plans, since the grid pitch is a fraction of the width.
**User content is served inert** (HP-1454-01). An uploaded SVG is the only
thing here that a browser will happily treat as a *document* rather than an
image, and it would be a document of Home Assistant's own origin. Inside the
card that never matters — `<image>` does not run scripts — but the url is
reachable directly, and uploading needs only write access, which by default
every user has. `HouseplanContentView` therefore sends a `sandbox` CSP with SVG
and only with SVG: a CSP on a PDF response can break the browser's built-in
viewer, and a raster image has no execution model to disable.
**Attachments follow the same commit-scoped lifecycle as plans** (HP-1454-02).
An upload takes a free name and never overwrites, because the bytes under an
existing name may be referenced by the stored configuration and an upload is
not part of that transaction. `reserve_filename` *claims* the name as it picks
it (`O_CREAT | O_EXCL`) — asking `exists()` and returning a string let two
uploads agree on one name and quietly overwrite each other. It also budgets the
length so the result survives the sanitiser the content view applies to the
request, since a name the view rewrites is a file written and never served.
Streaming temporaries live in the files root under `.upload-`, are removed on
every exit path of the request (including cancellation, which is a
BaseException and slips past `except Exception`), and are swept at startup and
daily. That scheduled pass also runs the two collectors with the stored
configuration as *both* sides — nothing superseded, so every referenced file is
kept and only aged unreferenced ones go. Without it, collection would only ever
happen when somebody saves, and a file uploaded into a dialog that was then
cancelled would wait for a write that may never come. A new icon has no id yet, so its
files go to a per-dialog staging folder and move to the real id once the config
write is accepted — the same copy → save → cleanup order as a rebind.
`config/set` collects what its commit superseded — that much a commit knows for
certain. *Unreferenced* is a far weaker signal, and the policy follows from one
asymmetry: **a few unnecessary megabytes can always be removed by hand; a file
we should not have removed cannot be brought back.** When the evidence is weak,
keep the file. Owner's decision, 2026-07-28, after the one-hour rule applied to
every unreferenced file destroyed two detached plans.
The classification is by **owner**, not by "is it referenced". A file leaving
the configuration looks identical whether the plan was replaced, detached, or
its space deleted — and only the first is a deletion the user asked for. Reading
`old_refs - new_refs` and calling it "superseded" deleted a plan the moment it
was detached, under documentation promising the opposite (HP-1465-01).
| Case | What it means | Rule |
|---|---|---|
| Space in both, plan A → plan B | the user picked another image | removed immediately |
| Space in both, plan → none | detached; one click undoes it | **kept** |
| Space gone | deliberate, but the image was imported and may be nowhere else | **kept** |
| Space has a plan, plus another file of its own | an upload whose save was rejected | **kept** — ageing these out raced the retry that referenced them |
| Marker in both, attachment dropped from its list | a trash button, promising nothing | removed immediately |
| Marker gone | same call as a deleted space's plan | **kept** |
| Attachment in `up_*` | a dialog that was never saved; no device owns it | `PLAN_ORPHAN_TTL_S` (1 h) |
| Marker there, file it never listed | a rejected upload | **kept**, same reason |
Nothing is deleted for being old, with one exception: a per-dialog staging
folder (`up_*`), which by construction can only hold an upload from a dialog
that was never saved. The disk therefore stays bounded by the user, not by a
timer — `houseplan/plans/list` shows every stored plan with its size and which
space uses it, and `houseplan/plans/delete` removes one on request, refusing
while a space still references it. That listing is what makes "we never delete"
livable: a detached plan is not lost, it is one click away in the space dialog.
**Config writes are serialized** (HP-1454-03). `_writeConfig()` chains onto a
single promise: one `config/set` in flight, each carrying the revision the
previous one returned. The debounce still spaces out *when* a write starts;
what it cannot do — and used to be relied on for — is keep two writes from
overlapping, which produced a self-inflicted conflict and lost the newer edit.
**Plan uploads are copy-on-write, and collection belongs to the commit**
(reviews R2-1, R3-1). The file system is not part of the config's
optimistic-locking transaction, so nothing referenced may be overwritten or
@@ -188,12 +266,21 @@ removes nothing. Deciding what may then go is *not* a client's call — a cleanu
request cannot be ordered against another client's commit, and a delayed one
deletes a plan that was just saved. So `config/set` collects itself, inside its
write lock, from the pair of configurations that bracket the commit
(`plans.collect_plans`): superseded files go immediately, other unreferenced
uploads only once `PLAN_ORPHAN_TTL_S` has passed, since a fresh one may belong
to a transaction still in flight. The `.` between id and token is load-bearing —
(`plans.collect_plans`): a file the commit REPLACED goes immediately, and
nothing else goes at all — see the table above; only a per-dialog staging folder
ages out. Growth is bounded at the door instead, by `plans.check_quota` on every
upload (store size, file count, free disk), because a limit that deletes is how
plans were lost twice. The `.` between id and token is load-bearing —
a space id cannot contain one, so `<space>.<token>.<ext>` can never be confused
with the files of a space whose name merely starts the same way.
**An internal plan url must exist when it is stored** (HP-1470-02). The picker
can attach a plan and then delete it, and two clients can do the same in either
order — the write lock orders the requests but says nothing about whether the
file survived. `config/set` therefore checks every `/api/houseplan/content/plans/`
url against the disk before saving, and refuses with `missing_plan`. External and
legacy urls are the user's own and are never second-guessed.
**Signed content urls are batched, aged and deduplicated** (reviews R2-2, R3-2, R4-2). `ContentSigner`
in `src/signing.ts` is the single implementation, used by both cards; the
duplicate inside houseplan-space-card signed correctly and never handed the
@@ -234,7 +321,7 @@ Shared, framework-light modules keep the two views from diverging:
`roomCenter`, `defaultPositions`, `markerPos`, `labelPos`; no Lit import) — unit-tested,
mirrors the full card's private geometry.
- `src/space-render.ts` — `renderSpaceStatic()` draws the plan + configured room
borders/names + device markers (via `buildDevices`, same curation) with NO handlers,
borders/names + device markers (via `buildDevices`, same filtering) with NO handlers,
NO live states, NO status/temperature fills. Uses the same CSS classes as the full card
(the space-card imports `cardStyles`) for visual parity.
- `src/config-store.ts` — module-level `{config, rev, layout}` cache shared by all embedded
+348
View File
@@ -1,5 +1,353 @@
# Changelog
## v1.50.0 — 2026-07-28
**Owner's batch**
- **The default zoom counts devices as content.** They are allowed to stand
outside every room — a gate sensor by the fence, a camera on a pole — and the
opening view now includes them, even on a space with no rooms at all.
- **Entering an editor no longer shifts the plan.** The stage height assumed a
fixed 118px of header, and the editor header is taller: the scene slid down
by the difference and its bottom went below the fold. The card measures where
the stage actually starts and gives it the rest of the viewport.
- **The zoom goes out as well as in.** Down to 0.4×, and zoomed out the plan
floats centred instead of being pinned to a corner.
**From the v1.49.0 review**
- **The square-canvas migration survives a crash between its two writes
(HP-1490-01).** Config and layout live in separate stores, written one after
the other, and the first write deleted the very fields the second needed — a
failure between them stranded markers in the old coordinates for good. The
migration intent is durable now, saved before anything moves and cleared by
the layout write itself; whichever half is missing after a crash, the next
start finishes exactly that half, once.
- **Parallel uploads cannot slip past the store quota together (HP-1490-02).**
N uploads all measured the store before any of them wrote, and all passed a
limit only one of them fit under. The measure-and-write pair is one atomic
step under its own lock — separate from the config lock, so a slow directory
scan does not stall saves.
- **The editors see the whole canvas again (HP-1490-03).** The content frame
also bounded pan, zoom and pointer maths, so after the first room there was
nowhere left to draw the second one. Edit modes now measure from the full
square; the view keeps its content fit, and switching modes refits instead of
carrying a view clamped against the wrong base.
- **Save waits for the proportions of a picked plan (HP-1490-04).** Saving
before the image had answered used to ship the PREVIOUS file's ratio, and the
new plan kept the old shape for good. Picking a plan clears the old ratio at
once, and Save awaits the bounded read; if it fails, "unknown" is stored —
a square fallback is honest, an inherited ratio is not.
- Release hygiene from §5: package-lock.json caught up with the package
version, and a duplicated comment in space-geometry.ts is gone.
## v1.49.0 — 2026-07-28
**The canvas is square** (see v1.48.0, released together with this one).
- **Zoom opens on what is drawn, not on the whole canvas.** A space without a
background image now fits its rooms with a 5% margin, so a small plan on a big
canvas fills the screen instead of sitting in the middle of it as a speck.
With a background image nothing changes: the image is the plan, and cropping
to the rooms would hide the parts nobody has outlined yet.
- **Switching spaces by swipe, or on the kiosk carousel, slides.** The plan
leaves the way the finger went and the next one arrives from the other side.
Respects "reduce motion".
- The room settings button says "Room settings" rather than just "Room", and
lightens slightly under the cursor.
- "Curation" is called filtering everywhere — the interface, the documentation
and the code.
**From the v1.47.0 review**
- **A plan you have just picked can no longer be deleted from the same dialog
(HP-1470-02).** It was not saved yet, so the server correctly considered it
free — and the save then stored a url with no file behind it. The button is
disabled now, and, because two clients can do the same in either order, the
server checks every internal plan url a configuration adds against the disk
and refuses a new reference that is already broken. A url the stored
configuration already carries is let through — a file can vanish from outside
Home Assistant, and refusing then would block the very edit that detaches it.
Urls that are not ours are left alone.
- **Uploads are bounded (HP-1470-01).** Nothing is deleted for being old — that
cost real plans twice — so the limit sits where a decision is being made
anyway: an upload is refused if the store would pass 256 MB or 200 plans
(1 GB / 1000 for attachments), or if the disk would drop below 512 MB free.
The plan list is capped at the 60 newest and its thumbnails load lazily.
- **Picking a saved plan reads its real proportions (HP-1470-03).** The card
waited for nothing and, when the signature for the protected url had not
arrived yet, saved a fallback ratio — a square plan came out stretched. It now
waits for the signature, ties the result to the dialog that asked, and the
preview in the dialog is signed like everything else.
## v1.48.0 — 2026-07-28 (the canvas is always square)
- **A space no longer has proportions of its own.** The drawing area is a square;
a plan image keeps its own shape and is centred inside it, so a wide plan gets
margins above and below and a tall one gets them at the sides. There is
nothing left to choose — the canvas orientation setting for hand-drawn spaces
is gone with it.
- **Existing plans are migrated once, on upgrade.** Nothing about a drawing
changes: the box is padded out to a square and every coordinate is
re-expressed against it — rooms, doors and windows, decor, marker positions
and the saved viewport. Angles, room proportions and relative positions are
preserved exactly. For a tall plan the scale in centimetres per grid cell is
adjusted along with it, because the grid is tied to the width; without that a
wall would silently measure less than it does.
## v1.47.0 — 2026-07-28 (pick a plan you already uploaded)
- **The space dialog can now show the plans stored on the server.** Detaching a
plan keeps the image on disk — that has been the rule since v1.46.4, but until
now the only way back was to find the original file on your computer and
upload it again. "Already uploaded" lists what is there, with a thumbnail, the
file size and which space uses it. One click attaches it; the aspect ratio is
read from the image, exactly as on upload.
- **And it is where you delete one.** A plan file is never removed automatically
— not for being detached, not for being old — which is only a sensible policy
if you can see what is being kept and get rid of it deliberately. The trash
button does that, and refuses while a space still uses the plan: the answer to
"may this go" comes from the stored configuration, not from the browser.
- Documentation caught up with the code: several comments still described the
age-based collection that v1.46.6 removed.
## v1.46.6 — 2026-07-28 (the detach promise, actually kept this time)
- **Switching a space to "draw" no longer deletes its image.** v1.46.4 and
v1.46.5 said it did not, and the scheduled cleanup indeed left detached plans
alone — but the save itself deleted the file the moment the reference was
cleared, before any of those guards were reached. The cause: a file that left
the configuration was called "superseded", and from that difference alone
replacing a plan, detaching one and deleting its space are indistinguishable.
Only the first is a deletion anybody asked for. The transition is now
classified by the space that owned the file, and the same distinction applies
to attachments: dropping one from a device that still exists removes it,
deleting the device keeps its manuals.
- **A plan whose space was deleted is kept**, rather than the thirty days
v1.46.5 promised — thirty days measured from the file's age is meaningless
anyway, since it was usually uploaded months earlier.
- **Nothing is deleted for being old any more**, except a per-dialog staging
folder. The rule that aged out "rejected uploads" turned out to race a retry:
the cleanup removed the file from a failed save while the next attempt was
committing a reference to it. A rule that can delete a file somebody is about
to point at is not worth the disk it reclaims. Files therefore go when an
action says so, and otherwise stay.
## v1.46.5 — 2026-07-28 (audit of every automatic deletion)
- **A detached plan is never deleted, at any age.** v1.46.4 gave it a month;
this makes it permanent and writes the reason down where the next change will
see it. The rule, now in docs/SCOPE.md: the component may delete a file only
when a user action says so — replacing a plan, removing an attachment,
deleting a device. "Nothing points at this any more" is not such an action.
The errors are not symmetrical: wasted disk is visible, cheap and reversible;
a deleted file is none of those.
- **`houseplan/files/cleanup` no longer takes a folder on the client's word.**
After a device is rebound its files are copied to the new id and the old
folder is dropped — with `rmtree`, on whatever id the card sent. Two ways that
ends badly: a partial copy leaves some urls still pointing into that folder
(the migration deliberately does not rewrite those, so they were live links to
files being deleted), and a wrong or stale id from any client would destroy a
live device's manuals. The server now checks the stored configuration itself,
under the config lock, and removes only files nothing references.
- **A plan of a space that was deleted waits thirty days instead of an hour.**
Deleting a space is deliberate, but an hour is a short window in which to
notice it was a misclick.
## v1.46.4 — 2026-07-28 (data loss: detached plans were collected as garbage)
- **A plan you detach is no longer deleted an hour later.** Switching a space to
"draw" clears the reference and, as the editor has always said, leaves the
image on disk so you can put it back. The collection added in v1.46.0 did not
make that distinction: it treated "nothing points at this right now" as
abandoned and applied a one-hour rule. On the author's own instance the
scheduled pass then removed two floor plans that had been detached weeks
earlier, with no way to get them back. If you have detached a plan since
v1.46.0 and your instance restarted or ran for a day, check
`config/houseplan/plans/` before updating anything else — and please report it
in the Telegram chat if a file is missing.
The rule now: **a commit still removes exactly what it replaced**, because
that it knows for certain. Beyond that the question is whether "unreferenced"
means "abandoned", and the answer depends on the case. A space with no plan at
all has had one detached and may want it back — its files are never collected.
A space that does have a plan can only be holding rejected uploads of its own,
so those still go after an hour. Attachments outside a per-dialog staging
folder wait a month; a staging folder, which by construction only ever holds
an upload from a dialog that was never saved, keeps the one-hour rule.
## v1.46.3 — 2026-07-28 (re-check of v1.46.2: HP-1462-01)
- **The cleanup at startup now actually cleans up.** It looked its own runtime
data up by domain, and during startup Home Assistant does not yet consider
the integration loaded — so the lookup came back empty and the pass quietly
degraded to removing half-finished transfers, leaving the real work to a timer
24 hours away. Restart more often than that and it never ran at all. It uses
the object it was given at startup now.
- **The test that was supposed to prove this was passing for the wrong
reason.** It created the stray files *before* saving the configuration — and
saving collects too, so everything was already gone by the time the restart
happened. Rewritten to seed after the save, plus a second test that fires the
scheduled timer on its own, and a third that runs a restart and a save at the
same time and asserts the accepted configuration never points at a file the
cleanup removed.
## v1.46.2 — 2026-07-28 (re-check of v1.46.1: HP-1461-01, -02)
- **A file nobody ended up using is now cleaned up even if nothing is ever
saved again (HP-1461-01).** Collection is tied to a configuration write,
which is right for what a write supersedes but leaves a gap: cancel a dialog
after the file has already uploaded, lose the connection just after, or call
the upload API directly, and nothing references the file and no future write
notices it. The daily sweep added in v1.46.1 only removed half-finished
transfers, so the promise that a cancelled attachment disappears after an hour
did not hold on an instance nobody edits. The sweep now compares against the
stored configuration — under the same lock a write uses — and collects aged
unreferenced attachments and plans as well.
- **A drag is no longer undone by someone else's move (HP-1461-02).** When the
full card learned to follow position changes in v1.46.1, it protected the
positions you had moved but not yet sent — except it read that list *after*
flushing the pending write, and flushing empties it first. In a real drag,
where a write is already scheduled, the list was therefore empty and the
server's older position was painted over your move. The card now takes the
snapshot before flushing and also holds on to positions that are sent but not
yet acknowledged: until the server confirms a position, the card that moved it
is the authority on it.
- Two tests grew up to their docstrings: the upload test now actually cancels
the request task instead of only exercising error paths, and the position-sync
smoke schedules a real debounced write and delays it, which is the ordering
that lost the drag.
## v1.46.1 — 2026-07-28 (re-check of v1.46.0: HP-1460-01 … -03)
- **Two uploads of the same file name can no longer collide (HP-1460-01).**
v1.46.0 stopped overwriting attachments, but choosing a free name and taking
it were two steps: two uploads racing between them agreed on the same name,
both reported success, and one set of bytes replaced the other. The name is
now claimed atomically as it is chosen — twenty simultaneous uploads of
`manual.pdf` produce twenty files. The same helper is used when rebinding
moves files, which had the same gap.
Also fixed there: a name at the length limit lost its extension, and the
collision suffix pushed it past the limit, so the attachment was stored under
a name the server would not serve back — a permanent 404 on a file the UI
reported as attached.
- **An interrupted upload no longer leaves a temporary file forever
(HP-1460-02).** Cleanup ran in an `except Exception`, which a cancelled
request walks straight past, and the collector only ever looks inside marker
folders — so an aborted transfer left a `.upload-*` in place with nothing able
to remove it. Every exit path now cleans up, a request carrying two files is
refused outright, and abandoned temporaries are swept at startup and daily.
Uploads also write in 1 MB batches instead of one disk task per 64 KB.
- **Two full cards side by side keep the same positions (HP-1460-03).** v1.46.0
taught the static card to follow position changes and left the full one
behind, so dragging an icon in one window did not move it in another until a
reload. It follows now, without disturbing a drag of its own: a revision
arriving mid-drag is merged rather than applied over the top, and a card does
not re-read what it just wrote itself.
## v1.46.0 — 2026-07-28 (full external audit of v1.45.4: HP-1454-01 … -10)
**Security**
- **An uploaded SVG plan is no longer a live document of your Home Assistant
origin (HP-1454-01, high — release blocker).** Inside the card a plan is
referenced by `<image>`, where scripts never run; but the same url opened
directly became a top-level document of HA's own origin, and a `<script>` in
it could read the session's `localStorage` and call the API. Uploading needs
write access, which by default every authenticated user has, and a signed url
is easy to hand to an administrator. Plan responses for SVG now carry a
`sandbox` Content-Security-Policy, which drops the document into an opaque
origin. Only SVG gets it — a CSP on a PDF can break the browser's built-in
viewer, and a raster image cannot execute anything. Nothing changes for
existing plans: the card renders them exactly as before.
**Data integrity**
- **A manual attached to a device no longer overwrites the previous one
(HP-1454-02).** The upload wrote straight to `<marker>/<filename>`, outside
the configuration transaction: cancelling the dialog, or a rejected save, left
the stored url serving the new bytes. And every new icon uploaded into one
shared folder, so two of them attaching `manual.pdf` ended up pointing at the
same physical file. Uploads now take a free name and never overwrite, a new
icon gets its own staging folder whose files move to the real icon when the
save is accepted, and an upload nobody saved is collected an hour later. The
name a collision falls back to changed from `manual (2).pdf` to `manual-2.pdf`
— the old one was sanitised on the way back in, so a renamed attachment was
written and then never served (found by the new test, and it applied to
rebind collisions before this release too).
- **Two quick edits can no longer lose the second one (HP-1454-03).** The
debounce spaced out the starts of a save, not the saves themselves. If one
took longer than half a second — a busy instance, a slow link — the next edit
went out with the same revision, the server accepted the first and rejected
the second, and the conflict handler reloaded the server copy over the local
one. The edit was gone, with a message blaming another window when there was
none. Writes are now serialized: one at a time, each carrying the revision the
previous one returned.
**Correctness and limits**
- **Open boundaries follow the geometry again (HP-1454-04).** Their cache was
keyed on room ids and links only, so changing a space's aspect ratio or
dragging a vertex left the open boundaries — and the light spilling through
them — at their old coordinates until a reload. The cache is now keyed on the
rendered model itself, which is exactly what it is computed from.
- **The configuration can no longer be made arbitrarily heavy (HP-1454-05).**
The outer collections were capped; the ones inside them were not. A polygon
with 150 000 points, or a list of 100 000 device ids, validated fine and then
made every render walk it. There are now limits on polygon vertices, open-to
links, controls, attachments, text and url lengths, plus a cap on the whole
serialized configuration. The obsolete `segments` field is dropped by the
server instead of trusting the card to strip it.
- **Large files stream instead of being held in memory (HP-1454-06).** A 50 MB
manual was read whole into memory on the way in and again on the way out; a
couple of parallel downloads were real pressure on a small Home Assistant
host. Uploads stream to a temporary file, downloads stream from disk.
**Consistency**
- **The static space card honours per-room fill settings (HP-1454-07).** It
builds its model with a different function, and room settings were not carried
into it, so a room you had set to "no fill" was still painted.
- **Moving an icon updates the static card immediately (HP-1454-08).** Layout is
separate state with no revision and no event: a drag on the full card left a
static card next to it showing the old position until the configuration
changed or the page was reloaded. Layout writes now keep a revision, return
it, and announce themselves — which also makes the optimistic locking on a
wholesale layout write mean something, since a point-wise write used to reset
the counter.
- **A repair warning about a missing plan disappears with its space
(HP-1454-09).** The cleanup only looked at spaces that still exist, so
deleting or renaming one left its warning in Repairs with nothing able to
clear it.
- Build chain: `serialize-javascript` pinned past two advisories (HP-1454-10).
Not reachable at runtime and production dependencies were already clean, but
it is one line.
## v1.45.4 — 2026-07-28 (review of v1.45.3: R5-1, R5-2)
- **A partly successful signing answer no longer skips the backoff (R5-1).**
The backend signs each path independently: one it cannot sign is logged,
skipped, and the call still succeeds with the remaining urls. The card took
any successful call as "the whole batch is done", cleared the backoff for
every path in it, and then wrote only the urls that came back — so a path the
backend kept skipping was requested again on every single render, which is
exactly the amplification v1.45.2 added the backoff to prevent. A path is now
counted as signed only if the answer actually carries a url for it; the rest
back off individually, keys nobody asked for are ignored, and a re-render is
only triggered when at least one new signature arrived.
- **The status snapshot no longer contradicts the repository (R5-2).** It still
described `main` as carrying releases up to v1.40.1 and quoted test counts
from several releases back, while the version line right beside them was kept
current — a maintainer or an agent reading it for handoff got a wrong branch
model and a smaller picture of the coverage than exists. The branch roles are
described accurately, and the counts are gone: `npm run inventory` prints them
from the tree, so there is nothing left to go stale.
## v1.45.3 — 2026-07-27
- **"Value instead of an icon" could not be saved (issue #3).** The option was
added to the device editor in v1.26.0, but the server-side schema only ever
accepted `badge`, `ripple` and `icon_ripple`. Choosing it produced
`not a valid value for dictionary value @ data['config']['markers'][n]['display']`
— and because a single rejected marker fails the whole configuration write,
the plan could not be saved at all until the setting was undone. Thanks to
@RemyRoux for the report and the exact error text.
- **The option lists now live in one place and are checked across languages.**
`DISPLAY_MODES`, `TAP_ACTIONS`, `SPACE_FILL_MODES` and `ROOM_FILL_MODES` are
exported from the card and read by a backend test that asserts the schema
accepts every value a user can actually pick. Adding an option to an editor
and forgetting the schema now fails the test suite instead of surfacing
through somebody's error message.
## v1.45.2 — 2026-07-27 (hardening from the v1.45.1 review: R4-1, R4-2)
- **A failed cleanup no longer reports an accepted save as an error (R4-1).**
Collecting superseded plan files runs after the configuration is already
+352
View File
@@ -6,6 +6,358 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
## v1.50.0 — 2026-07-28
**Задачи владельца**
- **Масштаб по умолчанию считает устройства содержимым.** Им можно стоять вне
комнат — датчик калитки у забора, камера на столбе — и стартовый вид теперь
включает их, даже в пространстве совсем без комнат.
- **Вход в редактор больше не сдвигает план.** Высота сцены считалась как
«экран минус 118px шапки», а шапка редактора выше: сцена уезжала вниз на
разницу, низ пропадал за краём. Карточка измеряет, где сцена начинается на
самом деле, и отдаёт ей остаток экрана.
- **Масштаб теперь и отдаляется.** До 0.4×; в отдалении план висит по центру,
а не прилипает к углу.
**По ревью v1.49.0**
- **Миграция на квадратный холст переживает сбой между двумя записями
(HP-1490-01).** Конфиг и позиции живут в разных хранилищах, пишутся по
очереди, и первая запись удаляла именно те поля, которые нужны второй — сбой
между ними навсегда оставлял значки в старых координатах. Теперь намерение
миграции сохраняется до того, как что-либо меняется, и снимается той же
записью, что сохраняет позиции: какая половина не успела — ту следующий
запуск и доделает, ровно один раз.
- **Параллельные загрузки не проскакивают квоту вместе (HP-1490-02).** N
загрузок мерили хранилище до того, как любая из них записала файл, и все
проходили предел, под который помещалась одна. Замер и запись — один
атомарный шаг под отдельным замком; отдельным — чтобы медленный обход папки
не тормозил сохранения конфига.
- **Редакторы снова видят весь холст (HP-1490-03).** Рамка содержимого
ограничивала и панорамирование, и координаты указателя, так что после первой
комнаты рисовать вторую было негде. Режимы редактирования меряют от полного
квадрата; просмотр остаётся по содержимому, а смена режима пересчитывает
вид, вместо того чтобы тащить его прижатым не к той основе.
- **Сохранение ждёт пропорции выбранного плана (HP-1490-04).** Save до ответа
картинки записывал пропорции ПРЕДЫДУЩЕГО файла, и новый план навсегда
оставался в чужой форме. Выбор плана сразу стирает старое значение, Save
дожидается ограниченного по времени чтения; не дождался — записывается
«неизвестно»: честный квадрат лучше унаследованной формы.
- Гигиена релиза из §5: package-lock.json догнал версию пакета, задвоенный
комментарий в space-geometry.ts убран.
## v1.49.0 — 2026-07-28
**Холст стал квадратным** (см. v1.48.0, выпущена вместе с этой).
- **Масштаб открывается по нарисованному, а не по всему холсту.** Пространство
без подложки теперь вписывается по границам своих комнат с полями 5%: маленький
план на большом холсте заполняет экран, а не сидит посередине точкой. С
подложкой ничего не меняется — картинка и есть план, и обрезать её по комнатам
значило бы спрятать то, что ещё не обведено.
- **Переключение пространств свайпом и в киоске стало с анимацией.** План
уезжает туда, куда пошёл палец, следующий приходит с другой стороны.
Уважает системную настройку «уменьшить движение».
- Кнопка настроек комнаты подписана «Настройки комнаты», а не просто «Комната»,
и слегка светлеет под курсором.
- Слово «курирование» заменено на «фильтрацию» — в интерфейсе, документации и
коде.
**По ревью v1.47.0**
- **Только что выбранный план больше нельзя удалить из того же диалога
(HP-1470-02).** Он ещё не сохранён, поэтому сервер справедливо считал его
свободным — а сохранение потом записывало ссылку, за которой нет файла.
Кнопка заблокирована, и, поскольку два клиента могут сделать это в любом
порядке, сервер сверяет с диском каждую внутреннюю ссылку, которую
конфигурация добавляет, и отказывает, если файла нет. Ссылку, уже записанную
в конфигурации, он пропускает: файл может исчезнуть и мимо Home Assistant, а
отказ заблокировал бы ровно ту правку, которая его отцепляет. Чужие ссылки не
трогаются.
- **Загрузки ограничены (HP-1470-01).** По возрасту не удаляется ничего — это
дважды стоило настоящих планов, — поэтому предел стоит там, где решение и так
принимается: загрузка отклоняется, если хранилище перевалит за 256 МБ или 200
планов (1 ГБ и 1000 для вложений) либо если на диске останется меньше 512 МБ.
Список планов отдаётся по 60 самых свежих, миниатюры грузятся лениво.
- **Выбор сохранённого плана читает его настоящие пропорции (HP-1470-03).**
Карточка ничего не ждала и, если подпись для защищённой ссылки ещё не пришла,
записывала пропорции «по умолчанию» — квадратный план получался растянутым.
Теперь она дожидается подписи, привязывает результат к тому диалогу, который
спрашивал, а превью в диалоге подписывается, как и всё остальное.
## v1.48.0 — 2026-07-28 (холст всегда квадратный)
- **У пространства больше нет собственных пропорций.** Область рисования —
квадрат, а картинка плана сохраняет свою форму и вписывается в него по
центру: у широкого плана появляются поля сверху и снизу, у вытянутого — по
бокам. Выбирать нечего, поэтому настройка ориентации холста для пространств
без картинки убрана.
- **Существующие планы переносятся один раз, при обновлении.** В самом рисунке
ничего не меняется: коробка дополняется до квадрата, и все координаты
пересчитываются относительно неё — комнаты, двери и окна, декор, позиции
значков и сохранённая область просмотра. Углы, пропорции комнат и взаимное
расположение сохраняются точно. Для вытянутых планов заодно пересчитывается
масштаб в сантиметрах на клетку: сетка привязана к ширине, и без этого стены
молча стали бы короче.
## v1.47.0 — 2026-07-28 (выбор из уже загруженных планов)
- **Диалог пространства показывает планы, сохранённые на сервере.** Отцепление
плана оставляет картинку на диске — так с v1.46.4, но вернуть её можно было
только найдя исходный файл у себя и загрузив заново. «Уже загруженные»
показывают, что есть: миниатюра, размер файла и то, какое пространство его
использует. Клик прикрепляет, пропорции читаются из самой картинки — так же,
как при загрузке.
- **Там же план и удаляется.** Файл плана никогда не удаляется автоматически —
ни за отцепление, ни за возраст, — и это разумная политика ровно до тех пор,
пока видно, что именно хранится, и есть способ убрать это осознанно. Кнопка
корзины делает это и отказывает, пока план используется пространством: ответ
на вопрос «можно ли удалить» даёт сохранённая конфигурация, а не браузер.
- Документация догнала код: несколько комментариев всё ещё описывали удаление по
возрасту, убранное в v1.46.6.
## v1.46.6 — 2026-07-28 (обещание про отцепление, теперь выполненное)
- **Переключение пространства в «нарисовать» больше не удаляет его картинку.**
v1.46.4 и v1.46.5 утверждали, что не удаляет, и плановая уборка действительно
отцеплённые планы не трогала — но само сохранение удаляло файл в тот момент,
когда снималась ссылка, ещё до всех этих проверок. Причина: файл, покинувший
конфигурацию, считался «заменённым», а по одной этой разнице замену плана,
отцепление и удаление пространства различить невозможно. Удалением, о котором
просили, является только первое. Теперь переход классифицируется по
пространству-владельцу, и та же разница применяется к вложениям: убрали файл у
существующего устройства — он удаляется, удалили устройство — его инструкции
остаются.
- **План удалённого пространства сохраняется**, а не тридцать дней, как обещала
v1.46.5: тридцать дней по возрасту файла всё равно бессмысленны — обычно он
загружен месяцы назад.
- **По возрасту больше не удаляется ничего**, кроме промежуточной папки диалога.
Правило, которое вычищало «отвергнутые загрузки», оказалось в гонке с
повторной попыткой: уборка удаляла файл неудавшегося сохранения ровно тогда,
когда следующая попытка коммитила ссылку на него. Правило, способное удалить
файл, на который кто-то вот-вот сошлётся, не стоит освобождаемого места.
Файлы уходят по действию, в остальных случаях остаются.
## v1.46.5 — 2026-07-28 (ревизия всех автоматических удалений)
- **Отцеплённый план не удаляется никогда, ни в каком возрасте.** В v1.46.4 ему
давался месяц; теперь это навсегда, и причина записана там, где её увидит
следующая правка. Правило, оно же в docs/SCOPE.md: компонент вправе удалить
файл только тогда, когда об этом говорит действие пользователя — замена
плана, удаление вложения, удаление устройства. «На это больше никто не
ссылается» таким действием не является. Ошибки несимметричны: занятое зря
место видно, стоит копейки и обратимо; удалённый файл — ничего из этого.
- **`houseplan/files/cleanup` больше не сносит папку по слову клиента.** После
перепривязки устройства файлы копируются под новый id, а старая папка
удалялась — через `rmtree`, по тому id, который прислала карточка. Два плохих
исхода: при частичном копировании часть ссылок продолжает указывать внутрь
этой папки (миграция намеренно их не переписывает — то есть это были живые
ссылки на удаляемые файлы), а неверный или устаревший id от любого клиента
уничтожил бы инструкции существующего устройства. Теперь сервер сам сверяется
с сохранённой конфигурацией, под её блокировкой, и удаляет только то, на что
никто не ссылается.
- **План удалённого пространства ждёт тридцать дней вместо часа.** Удаление
пространства осознанно, но час — короткое окно, чтобы заметить промах.
## v1.46.4 — 2026-07-28 (потеря данных: отцеплённые планы убирались как мусор)
- **Отцеплённый план больше не удаляется через час.** Переключение пространства
в режим «нарисовать» снимает ссылку и, как редактор всегда и говорил,
оставляет картинку на диске, чтобы её можно было вернуть. Уборка, добавленная
в v1.46.0, этой разницы не делала: считала «на файл сейчас никто не
ссылается» синонимом «файл брошен» и применяла часовое правило. На установке
автора плановый проход в итоге удалил два плана этажей, отцеплённых
несколькими неделями раньше, — восстановить их было нечем. Если вы отцепляли
план после v1.46.0 и инстанс перезапускался или проработал сутки — загляните в
`config/houseplan/plans/` и напишите в Telegram-чат, если файла нет.
Правило теперь такое: **коммит по-прежнему удаляет ровно то, что заменил**, —
это он знает наверняка. Дальше вопрос в том, означает ли «непривязан»
«брошен», и ответ зависит от случая. У пространства, у которого плана нет
вовсе, его отцепили — и, возможно, вернут: его файлы не удаляются никогда. У
пространства, у которого план есть, лишние файлы могут быть только его же
отвергнутыми загрузками — они по-прежнему уходят через час. Вложения вне
промежуточной папки диалога ждут месяц; сама промежуточная папка, где по
построению лежит только загрузка из несохранённого диалога, сохраняет часовое
правило.
## v1.46.3 — 2026-07-28 (перепроверка v1.46.2: HP-1462-01)
- **Уборка при старте действительно убирает.** Она искала свои же runtime-данные
по домену, а во время запуска Home Assistant ещё не считает интеграцию
загруженной — поэтому поиск возвращал пустоту, и проход тихо вырождался в
удаление незавершённых передач, оставляя настоящую работу таймеру через
24 часа. При перезапусках чаще, чем раз в сутки, она не выполнялась вообще.
Теперь используется объект, который у неё и так был на руках.
- **Тест, который должен был это доказать, проходил по неверной причине.** Он
создавал лишние файлы *до* сохранения конфигурации, а сохранение тоже
собирает мусор — так что к моменту перезапуска убирать было уже нечего.
Переписан: файлы создаются после сохранения; добавлен второй тест, который
дёргает плановый таймер отдельно, и третий, который запускает перезапуск и
сохранение одновременно и проверяет, что принятая конфигурация никогда не
ссылается на удалённый уборкой файл.
## v1.46.2 — 2026-07-28 (перепроверка v1.46.1: HP-1461-01, -02)
- **Файл, который в итоге никому не понадобился, убирается, даже если больше
ничего не сохраняют (HP-1461-01).** Сборка привязана к записи конфигурации —
это верно для того, что запись вытесняет, но оставляет зазор: отмените диалог
после того, как файл уже загрузился, потеряйте связь сразу после, или
вызовите API загрузки напрямую — и на файл никто не ссылается, а будущей
записи, которая бы это заметила, нет. Добавленное в v1.46.1 ежедневное
подметание убирало только незавершённые передачи, поэтому обещание «отменённое
вложение исчезнет через час» не выполнялось там, где никто ничего не правит.
Теперь подметание сверяется с сохранённой конфигурацией — под той же
блокировкой, что и запись, — и собирает устаревшие непривязанные вложения и
планы тоже.
- **Перетаскивание больше не отменяется чужим перемещением (HP-1461-02).** Когда
в v1.46.1 полная карточка научилась следить за позициями, она защищала те,
что вы подвинули, но ещё не отправили, — вот только читала этот список *после*
сброса отложенной записи, а сброс его первым делом опустошает. При настоящем
перетаскивании, когда запись уже запланирована, список оказывался пустым, и
старая серверная позиция закрашивала ваше движение. Теперь снимок снимается до
сброса, и вдобавок удерживаются позиции, отправленные, но ещё не
подтверждённые: пока сервер не подтвердил позицию, авторитет по ней — та
карточка, которая её подвинула.
- Два теста доросли до своих же описаний: тест загрузки теперь действительно
отменяет задачу запроса, а не только проходит по путям ошибок, а смоук
синхронизации позиций планирует настоящую отложенную запись и задерживает её —
именно этот порядок и терял перетаскивание.
## v1.46.1 — 2026-07-28 (перепроверка v1.46.0: HP-1460-01 … -03)
- **Две загрузки с одинаковым именем больше не сталкиваются (HP-1460-01).**
v1.46.0 перестала затирать вложения, но выбор свободного имени и его занятие
были двумя шагами: две загрузки, попавшие между ними, сходились на одном
имени, обе рапортовали успех, и одни байты заменяли другие. Теперь имя
занимается атомарно в момент выбора — двадцать одновременных загрузок
`manual.pdf` дают двадцать файлов. Тот же механизм используется при переносе
файлов на перепривязке, где был ровно такой же зазор.
Заодно там же: имя предельной длины теряло расширение, а суффикс коллизии
выталкивал его за предел, и вложение сохранялось под именем, которое сервер
обратно не отдаёт — вечный 404 на файл, который интерфейс считал
прикреплённым.
- **Прерванная загрузка больше не оставляет временный файл навсегда
(HP-1460-02).** Уборка стояла в `except Exception`, мимо которого отменённый
запрос проходит насквозь, а сборщик заглядывает только в папки маркеров —
поэтому оборванная передача оставляла `.upload-*`, и убрать его было некому.
Теперь убирает любой путь выхода, запрос с двумя файлами отклоняется сразу, а
брошенные временные подметаются при старте и раз в сутки. Запись идёт
порциями по мегабайту, а не отдельной задачей на каждые 64 КБ.
- **Две полные карточки рядом держат одинаковые позиции (HP-1460-03).** v1.46.0
научила следить за перемещениями статическую карточку и оставила позади
полную, поэтому перетаскивание иконки в одном окне не двигало её в другом до
перезагрузки. Теперь следит — и не мешает собственному перетаскиванию: чужая
ревизия, пришедшая в его разгар, сливается, а не накатывается сверху, и
карточка не перечитывает то, что записала сама.
## v1.46.0 — 2026-07-28 (полный внешний аудит v1.45.4: HP-1454-01 … -10)
**Безопасность**
- **Загруженный SVG-план больше не является живым документом origin вашего
Home Assistant (HP-1454-01, high — блокер релиза).** Внутри карточки план
подключён через `<image>`, где скрипты не выполняются; но тот же URL,
открытый напрямую, становился документом верхнего уровня в origin самого HA,
и `<script>` в нём мог читать `localStorage` сессии и обращаться к API. Для
загрузки нужно право записи, а оно по умолчанию есть у каждого
аутентифицированного пользователя, и подписанную ссылку несложно передать
администратору. Ответы с SVG теперь несут заголовок Content-Security-Policy
`sandbox`, который помещает документ в opaque origin. Только SVG — CSP на PDF
способен сломать встроенный просмотрщик браузера, а растровая картинка ничего
выполнить не может. Для существующих планов ничего не меняется: карточка
рисует их ровно как раньше.
**Целостность данных**
- **Инструкция, приложенная к устройству, больше не затирает предыдущую
(HP-1454-02).** Загрузка писала прямо в `<маркер>/<имя файла>`, вне
транзакции конфигурации: отмена диалога или отвергнутое сохранение оставляли
сохранённую ссылку указывающей на новые байты. А все новые иконки грузили в
одну общую папку, поэтому две с файлом `manual.pdf` начинали ссылаться на
один физический файл. Теперь загрузка занимает свободное имя и никогда не
перезаписывает, новая иконка получает собственную промежуточную папку, файлы
из которой переезжают к настоящей иконке при принятом сохранении, а загрузка,
которую никто не сохранил, убирается через час. Имя, на которое уходит
коллизия, сменилось с `manual (2).pdf` на `manual-2.pdf`: старое санитайзилось
на обратном пути, поэтому переименованное вложение записывалось и больше не
отдавалось (нашёл новый тест; до этого релиза так же ломались коллизии при
перепривязке).
- **Две быстрые правки больше не теряют вторую (HP-1454-03).** Debounce
разносил старты сохранения, а не сами сохранения. Если одно длилось дольше
полусекунды — занятый сервер, медленная связь, — следующая правка уходила с
той же ревизией, сервер принимал первую и отклонял вторую, а обработчик
конфликта перечитывал серверную копию поверх локальной. Правка исчезала, и
сообщение винило «другое окно», которого не было. Записи сериализованы: по
одной за раз, каждая с ревизией, которую вернула предыдущая.
**Корректность и пределы**
- **Открытые границы снова следуют за геометрией (HP-1454-04).** Их кэш
ключевался только по id комнат и связям, поэтому смена пропорций
пространства или перетаскивание вершины оставляли открытые границы — и свет,
который через них проходит, — в старых координатах до перезагрузки. Ключом
стала сама отрисованная модель, из которой они и вычисляются.
- **Конфигурацию больше нельзя сделать сколь угодно тяжёлой (HP-1454-05).**
Внешние коллекции были ограничены, вложенные — нет. Полигон на 150 000 точек
или список из 100 000 идентификаторов проходили валидацию, а потом каждый
рендер по ним ходил. Появились пределы на вершины полигона, связи открытых
границ, управляемые сущности, вложения, длину текста и ссылок, плюс общий
предел размера сериализованной конфигурации. Устаревшее поле `segments`
отбрасывает сервер, а не надежда на современный клиент.
- **Большие файлы передаются потоком, а не через память (HP-1454-06).**
Инструкция на 50 МБ целиком читалась в память на приёме и ещё раз на отдаче;
пара параллельных скачиваний — заметная нагрузка на слабый хост Home
Assistant. Загрузка пишется во временный файл потоком, отдача идёт с диска.
**Согласованность**
- **Статическая карточка пространства учитывает настройки заливки комнаты
(HP-1454-07).** Она строит модель другой функцией, и настройки комнаты в неё
не переносились — комната, которой вы выключили заливку, всё равно
закрашивалась.
- **Перемещение иконки сразу видно на статической карточке (HP-1454-08).**
Layout — отдельное состояние без ревизии и без события: перетаскивание на
полной карточке оставляло соседнюю статическую со старой позицией до
изменения конфигурации или перезагрузки страницы. Теперь записи layout хранят
ревизию, возвращают её и сообщают о себе — заодно оптимистическая блокировка
на полной записи layout начала что-то значить, ведь точечная запись раньше
сбрасывала счётчик.
- **Предупреждение о пропавшем плане исчезает вместе с пространством
(HP-1454-09).** Уборка смотрела только на существующие пространства, поэтому
удаление или переименование оставляло предупреждение в «Ремонте» навсегда.
- Сборка: `serialize-javascript` поднят выше двух advisory (HP-1454-10). В
рантайме недостижим, production-зависимости и так были чисты, но это одна
строка.
## v1.45.4 — 2026-07-28 (ревью v1.45.3: R5-1, R5-2)
- **Частично успешный ответ на подпись больше не пропускает выдержку (R5-1).**
Бэкенд подписывает каждый путь независимо: тот, что подписать не удалось,
логируется, пропускается, и вызов всё равно завершается успешно с остальными
ссылками. Карточка считала любой успешный вызов «весь батч готов», сбрасывала
выдержку для всех путей в нём и записывала только вернувшиеся ссылки — и путь,
который бэкенд стабильно пропускал, запрашивался заново на каждом рендере, то
есть ровно то усиление, ради которого выдержка и вводилась в v1.45.2. Теперь
путь считается подписанным, только если в ответе действительно есть ссылка на
него; остальные уходят в выдержку по отдельности, ключи, которых не просили,
игнорируются, а перерисовка запускается лишь при появлении хотя бы одной новой
подписи.
- **Снимок состояния больше не противоречит репозиторию (R5-2).** Там всё ещё
было написано, что в `main` лежат релизы только до v1.40.1, и приводились
счётчики тестов на несколько релизов назад — при том что строка версии рядом
исправно обновлялась. Читающий его человек или агент получал неверную
модель веток и заниженное представление о покрытии. Роли веток описаны точно,
а счётчики убраны: `npm run inventory` печатает их из дерева, и устаревать
больше нечему.
## v1.45.3 — 2026-07-27
- **«Значение вместо иконки» невозможно было сохранить (issue #3).** Опция
появилась в редакторе устройств ещё в v1.26.0, но серверная схема всё это
время принимала только `badge`, `ripple` и `icon_ripple`. При её выборе
сохранение падало с
`not a valid value for dictionary value @ data['config']['markers'][n]['display']`,
а поскольку один отвергнутый маркер валит всю запись конфигурации, план не
сохранялся вообще, пока настройку не отменишь. Спасибо @RemyRoux за отчёт и
точный текст ошибки.
- **Списки опций теперь в одном месте и сверяются между языками.**
`DISPLAY_MODES`, `TAP_ACTIONS`, `SPACE_FILL_MODES` и `ROOM_FILL_MODES`
экспортируются из карточки, и backend-тест читает их, проверяя, что схема
принимает каждое значение, которое пользователь реально может выбрать.
Теперь добавить опцию в редактор и забыть про схему — значит уронить тесты, а
не узнать об этом из чужого сообщения об ошибке.
## v1.45.2 — 2026-07-27 (закалка по ревью v1.45.1: R4-1, R4-2)
- **Сбой уборки больше не превращает принятое сохранение в ошибку (R4-1).**
Сборка вытесненных файлов плана идёт уже после того, как конфигурация
+13 -1
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.
+5 -5
View File
@@ -11,18 +11,18 @@
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
> ARCHITECTURE.md for design changes, ROADMAP.md when plans move.
## Snapshot (2026-07-27)
## Snapshot (2026-07-28)
| Item | State |
|---|---|
| Version | **v1.45.2** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Version | **v1.50.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
| GitHub | https://github.com/Matysh/houseplan-card — `main` = releases up to **v1.40.1**; `dev` ahead with v1.40.2+ (speaker icons, kiosk). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 1–3 months (checked 2026-07-24) |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.45.2** via direct copy (HACS custom repo also installed) |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.50.0** via direct copy (HACS custom repo also installed) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
| Tests | 121 frontend (node:test) + 12 pure backend + 12 HA-harness (CI, py3.13); ~30 demo smoke suites (headless chromium) |
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
| Product scope | docs/SCOPE.md (2026-07-22) is the feature guard rail — check before accepting any feature |
+127 -2
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
@@ -234,6 +234,131 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
are only reachable through /api/houseplan/content/… with a session; the
old /houseplan_files/plans|files paths return 404 after a restart; old
stored URLs keep working (rewritten on read) [auto+manual]
- [ ] Every editor option is storable (v1.45.3, issue #3): set a sensor to
"value instead of an icon" and save — no validation error, the value shows
on the plan after a reload. Same for each tap action and each fill mode
[auto: backend test_every_display_mode_the_editor_offers_is_accepted and
neighbours, test_a_marker_showing_its_value_can_be_saved]
- [ ] Editors see the whole canvas (v1.50.0, HP-1490-03): a hand-drawn space
with one small room opens content-fit in View; switching to the plan
editor shows the full square with room to draw a second room far away;
back to View restores the content fit [auto: smoke_audit_1490]
- [ ] Save waits for a picked plan's proportions (v1.50.0, HP-1490-04): pick a
saved plan and hit Save before the thumbnail loads — the stored aspect is
the real one, never the previous file's [auto: smoke_audit_1490]
- [ ] Zoom goes below the fit (v1.50.0): minus past 100% floats the plan
centred, floor at 0.4x; entering an editor keeps the stage inside the
viewport [auto: smoke_zoom_out]
- [ ] Migration crash recovery (v1.50.0, HP-1490-01): kill HA between the two
store writes of the square migration — the next start finishes the layout
half from the saved intent
[auto: test_square_migration_finishes_after_a_crash_between_the_writes]
- [ ] Parallel upload quota (v1.50.0, HP-1490-02): two simultaneous uploads
with one slot left — exactly one succeeds
[auto: test_parallel_uploads_cannot_slip_past_the_quota_together]
- [ ] Zoom opens on the content (v1.49.0): a space with no background and one
small room opens with that room filling the screen, with a small margin.
With a background it still fits the whole image
[auto: unit: contentBounds]
- [ ] Deleting a picked plan is refused (v1.49.0, HP-1470-02): pick a saved
plan, reopen the list — its delete button is disabled. Ask the server to
store a plan url whose file is gone: `missing_plan`, and the revision does
not move [auto: smoke_saved_plans + backend
test_config_set_refuses_a_plan_that_no_longer_exists]
- [ ] Uploads are bounded (v1.49.0, HP-1470-01): past the store quota an upload
is refused with a clear error and the disk does not grow; the plan list
returns the newest 60 with a total
[auto: unit: test_check_quota_counts_the_whole_store_not_one_request,
backend test_uploads_are_bounded_by_a_store_quota]
- [ ] Square canvas migration (v1.48.0): after the upgrade every existing plan
looks exactly as before, just with margins where the canvas was extended.
Measure a wall in the plan editor — the length in cm is unchanged. Marker
positions, doors, decor and the saved zoom are all where they were
[auto: unit: test_a_wide_plan_gains_margins_above_and_below and neighbours,
test_migration_preserves_real_lengths_and_shapes]
- [ ] A plan image is centred (v1.48.0): a wide image sits in the middle with
empty bands above and below, a tall one with bands at the sides, and it is
never stretched [auto: unit: fitInSquare + smoke_space_settings]
- [ ] Re-attaching a detached plan (v1.47.0): detach a plan, save, RELOAD THE
PAGE, open space settings → "Already uploaded" → the image is listed with
its size and no "in use" note → attach it → it renders. The one a space
uses shows that space and cannot be deleted; a free one can, with a
confirm, and disappears from the list
[auto: smoke_saved_plans + backend test_stored_plans_can_be_listed_and_deleted_on_request]
- [ ] Detaching a plan keeps the file (v1.46.6): switch a space to "draw" and
SAVE — the image is still in `config/houseplan/plans/` right afterwards,
and after a restart, and can be re-attached. Deleting the space keeps it
too. Replacing a plan still removes the one it replaced, immediately.
Check straight after the save: the earlier bug deleted the file at that
moment, while every scheduled-pass test passed
[auto: unit: test_plan_collection_matrix, test_attachment_collection_matrix,
backend test_detaching_a_plan_keeps_the_file]
- [ ] Rebinding a device does not eat its manuals (v1.46.5): attach two files to
a device, rebind it to another HA device — both are readable afterwards.
If a copy failed, the file it failed on is still there rather than deleted
with the folder [auto: backend test_files_cleanup_keeps_referenced_files]
- [ ] Nothing accumulates on an idle instance (v1.46.2/v1.46.3, HP-1461-01,
HP-1462-01): attach a file, cancel the dialog, and do not save anything
else — the file is gone after a restart AND after the daily pass, while
every file the configuration still references is untouched. Seed the
strays AFTER the last save, or `config/set` collects them and the check
proves nothing
[auto: backend test_startup_sweep_collects_what_no_commit_will,
test_daily_sweep_callback_collects_too, test_sweep_and_a_config_write_do_not_race]
- [ ] A drag wins over a concurrent remote move (v1.46.2, HP-1461-02): drag an
icon and, while the save is still in flight, have another window move a
different icon — your icon stays where you put it and the other one
updates [auto: smoke_layout_sync]
- [ ] Concurrent uploads of one name (v1.46.1, HP-1460-01): attach the same
file from two browser tabs at once — two attachments, two sets of bytes,
neither lost. A file whose name is at the length limit still downloads
[auto: unit: test_reserve_filename_is_safe_under_concurrency and neighbours]
- [ ] No temporary files survive (v1.46.1, HP-1460-02): abort a large upload
mid-transfer, send two files in one request, make promotion fail — in each
case the files folder holds no `.upload-*`. An old one is swept at startup
[auto: backend test_upload_leaves_no_temporary_behind + unit: sweep_upload_temps]
- [ ] Two full cards agree on positions (v1.46.1, HP-1460-03): open the plan in
two windows, drag an icon in one — it moves in the other without a reload;
a drag in progress in the second window is not thrown away
[auto: smoke_layout_sync]
- [ ] Uploaded SVG is inert as a document (v1.46.0, HP-1454-01): open a plan's
signed url directly in a tab — a `<script>` inside it must not run and must
not reach the HA session's localStorage; the same plan still renders in the
card. PDFs still open in the browser viewer
[auto: smoke_svg_sandbox + backend test_uploaded_svg_is_sandboxed_and_a_pdf_is_not]
- [ ] An attachment never overwrites another (v1.46.0, HP-1454-02): attach a file,
cancel the dialog — the previously stored file is byte-identical. Attach
`manual.pdf` to two NEW icons — two independent files. A cancelled upload is
gone an hour later
[auto: backend test_upload_never_overwrites_an_existing_attachment + unit: collect_attachments]
- [ ] Two quick edits both survive (v1.46.0, HP-1454-03): with a slow connection,
make an edit and another one before the first save answers — both are in the
stored config, only one write is ever in flight, and no conflict toast fires
[auto: smoke_config_writer]
- [ ] Open boundaries follow geometry (v1.46.0, HP-1454-04): change a space's
aspect or drag a room vertex — the open boundary and the light through it
move with the walls, without a reload [auto: smoke via model-identity key]
- [ ] Inner limits (v1.46.0, HP-1454-05): max and max+1 for polygon points,
open_to, controls, pdfs, text and url lengths; an oversized config as a
whole is refused with `too_large`
[auto: unit: test_inner_collection_limits + backend test_config_write_is_capped_by_total_size]
- [ ] Big files stream (v1.46.0, HP-1454-06): upload a ~50 MB manual and download
it twice in parallel — HA's memory does not grow by a file per transfer
[manual]
- [ ] Static card parity (v1.46.0, HP-1454-07): a room whose fill is set to "none"
under a space filled by light is transparent on BOTH cards
[auto: smoke_render_parity]
- [ ] Layout reaches the static card (v1.46.0, HP-1454-08): drag an icon on the
full card — a static card on the same dashboard moves it too, with no
config write and no reload
[auto: backend test_layout_keeps_its_revision_and_announces_changes + manual]
- [ ] Repair issues are not immortal (v1.46.0, HP-1454-09): create a missing-plan
warning, then delete the space — the warning disappears [manual]
- [ ] A path the backend cannot sign does not become a request loop (v1.45.4,
review R5-1): when `content/sign` answers successfully but omits a path,
the card backs that path off individually and keeps the urls it did get;
a re-render asks only for what is still missing, and only after the wait
[auto: unit: signing.test + backend test_signing_one_path_may_fail_without_failing_the_request]
- [ ] Signing does not amplify on a bad connection (v1.45.2, review R4-2): with
the WebSocket slow or refusing, the card issues ONE sign request per url
and backs off after a failure instead of asking again on every render; a
+7 -38
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.41.2",
"version": "1.50.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.41.2",
"version": "1.50.0",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
@@ -817,16 +817,6 @@
"splaytree-ts": "^1.0.2"
}
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "^5.1.0"
}
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -894,35 +884,14 @@
"fsevents": "~2.3.2"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/serialize-javascript": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
"version": "7.0.7",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.7.tgz",
"integrity": "sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"randombytes": "^2.1.0"
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/smob": {
+6 -2
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.45.2",
"version": "1.50.0",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
@@ -8,7 +8,8 @@
"build": "tsc --noEmit && rollup -c",
"watch": "rollup -c --watch",
"typecheck": "tsc --noEmit",
"test": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/*.test.mjs"
"test": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/*.test.mjs",
"inventory": "node scripts/inventory.mjs"
},
"devDependencies": {
"@mdi/js": "^7.4.47",
@@ -24,5 +25,8 @@
"dependencies": {
"lit": "^3.1.3",
"polyclip-ts": "^0.16.8"
},
"overrides": {
"serialize-javascript": "^7.0.5"
}
}
+24
View File
@@ -0,0 +1,24 @@
// Current test inventory, printed on demand.
//
// docs/STATUS.md used to carry these numbers inline; they went stale within a
// couple of releases while the version line next to them was kept current,
// which is worse than no number at all — a maintainer reading the snapshot
// underestimates the coverage that exists (review R5-2). The counts live here
// now, one command away, and STATUS.md describes the layers instead.
import { readdirSync, readFileSync } from 'node:fs';
const count = (dir, match, re) =>
readdirSync(dir)
.filter((f) => match.test(f))
.reduce((n, f) => n + (readFileSync(`${dir}/${f}`, 'utf8').match(re) || []).length, 0);
const files = (dir, match) => readdirSync(dir).filter((f) => match.test(f)).length;
const rows = [
['frontend unit (node:test)', count('test', /\.test\.mjs$/, /^test\(/gm)],
['pure backend (pytest, no HA)', count('tests_backend', /^test_validation\.py$/, /^def test_/gm)],
['HA-harness backend (CI, py3.13)', count('tests_backend', /^test_ha_.*\.py$/, /^async def test_|^def test_/gm)],
['browser smokes (headless chromium)', files('demo', /^smoke_.*\.mjs$/)],
];
const w = Math.max(...rows.map(([n]) => n.length));
for (const [name, n] of rows) console.log(`${name.padEnd(w)} ${n}`);
+10 -4
View File
@@ -47,11 +47,17 @@ async function fetchFresh(hass: any): Promise<HpConfigSnapshot> {
};
if (!subscribed && hass.connection?.subscribeEvents) {
subscribed = true;
const invalidate = () => {
cache = null; // invalidate; listeners reload
listeners.forEach((l) => l());
};
try {
await hass.connection.subscribeEvents(() => {
cache = null; // invalidate; listeners reload
listeners.forEach((l) => l());
}, 'houseplan_config_updated');
await hass.connection.subscribeEvents(invalidate, 'houseplan_config_updated');
// Layout is separate state: dragging an icon on the full card writes only
// the layout, so a static card on the same dashboard kept showing the old
// position until the config changed or the page was reloaded — possibly
// forever on a wall tablet (HP-1454-08).
await hass.connection.subscribeEvents(invalidate, 'houseplan_layout_updated');
} catch {
subscribed = false;
}
+7 -7
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); }
+466 -121
View File
File diff suppressed because it is too large Load Diff
+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}"
}
+21
View File
@@ -531,6 +531,27 @@ export function safeUrl(url: string | null | undefined): string | null {
export type TapAction = 'info' | 'more-info' | 'toggle';
/** Domains a card-wide `tap_action: toggle` may toggle (accidental-tap safe). */
/**
* The option lists the editors offer, in one place — and the reason they are
* here rather than inline in the templates.
*
* `display` gained 'value' in v1.26.0 ("show the measurement instead of the
* icon") but the backend schema still only accepted badge/ripple/icon_ripple,
* so saving any marker configured that way was rejected outright — and since
* one bad marker fails the whole config write, the plan could not be saved at
* all. Shipped 2026-07-21, found by a user on 2026-07-27: six days, and only
* because they pasted the error text. Nothing in the suite could have caught
* it, because the option list and the schema that stores it were written in
* two languages and never compared. They are exported here so a backend test
* can read them and assert the schema accepts every value a user can pick.
* Adding an option here and forgetting the schema now fails the test suite.
*/
export const DISPLAY_MODES = ['badge', 'ripple', 'icon_ripple', 'value'] as const;
export const TAP_ACTIONS = ['info', 'more-info', 'toggle'] as const;
/** Space-level fill: 'glow' is a whole-space light model, not a per-room one. */
export const SPACE_FILL_MODES = ['none', 'lqi', 'light', 'temp', 'glow'] as const;
export const ROOM_FILL_MODES = ['none', 'lqi', 'light', 'temp'] as const;
export const TOGGLE_SAFE_DOMAINS = new Set(['light', 'switch', 'fan', 'humidifier']);
/**
+2 -2
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',
+26 -9
View File
@@ -114,23 +114,33 @@ export class ContentSigner {
hass
.callWS({ type: 'houseplan/content/sign', paths: batch })
.then((r: any) => {
for (const p of batch) this.retry.delete(p);
if (!r?.urls || this.disposed) return;
if (this.disposed) return;
// A successful call does NOT mean every path was signed: the backend
// skips a path it cannot sign, logs it and still answers `{urls: …}`
// with the rest. Treating the whole batch as done then cleared the
// backoff for the missing ones, so every later render asked again —
// the very amplification the backoff exists to stop (review R5-1).
const at = this.now();
const next = { ...this.signed };
for (const [k, v] of Object.entries<string>(r.urls)) next[k] = { url: v, at };
let accepted = 0;
for (const p of batch) {
const url = r?.urls?.[p]; // only keys we asked for
if (typeof url === 'string' && url) {
next[p] = { url, at };
this.retry.delete(p);
accepted++;
} else {
this.backOff(p);
}
}
if (!accepted) return;
this.signed = next;
this.onUpdate();
})
.catch(() => {
// back off rather than retry on the very next frame: a socket that
// is refusing sign requests would otherwise be hammered per render
const now = this.now();
for (const p of batch) {
const prev = this.retry.get(p)?.delay || 0;
const delay = Math.min(SIGN_BACKOFF_MAX_MS, prev ? prev * 2 : SIGN_BACKOFF_MIN_MS);
this.retry.set(p, { notBefore: now + delay, delay });
}
for (const p of batch) this.backOff(p);
})
.finally(() => {
// release only our own attempt: a later one may have superseded it
@@ -139,6 +149,13 @@ export class ContentSigner {
}
}
/** Next attempt for this url waits, and each failure waits twice as long. */
private backOff(url: string): void {
const prev = this.retry.get(url)?.delay || 0;
const delay = Math.min(SIGN_BACKOFF_MAX_MS, prev ? prev * 2 : SIGN_BACKOFF_MIN_MS);
this.retry.set(url, { notBefore: this.now() + delay, delay });
}
/**
* Re-sign what is still in use. A wall tablet outlives a signature, and an
* entry for a plan replaced months ago must not consume a slot in the capped
+65 -8
View File
@@ -6,20 +6,41 @@
import { declump, contentUrl } from './logic';
import type { ServerConfig, SpaceModel, RoomCfg, DevItem } from './types';
export const NORM_W = 1000; // width of the render space for normalized configs
export const NORM_W = 1000; // side of the render space — the canvas is square
/**
* Where a plan image sits inside the square canvas (v1.48.0).
*
* The canvas has no proportions of its own any more; the image keeps its own
* and is centred, so a wide plan gets margins above and below and a tall one
* gets them at the sides. `ratio` is the image's width/height; without it we
* assume square, which is only ever a brief guess before the file loads.
*/
export function fitInSquare(ratio: number | null | undefined, side: number) {
const r = Number(ratio);
const a = Number.isFinite(r) && r > 0 ? r : 1;
const w = a >= 1 ? side : side * a;
const h = a >= 1 ? side / a : side;
return { x: (side - w) / 2, y: (side - h) / 2, w, h };
}
export type Pt = { x: number; y: number };
export type Layout = Record<string, { s?: string; x: number; y: number } | undefined>;
/** Build render-space models (NORM_W × NORM_W/aspect) from a server config. */
/** Build render-space models (NORM_W × NORM_W) from a server config. */
export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
if (!cfg || !Array.isArray(cfg.spaces)) return [];
return cfg.spaces.map((s: any) => {
const H = NORM_W / s.aspect;
const H = NORM_W; // square canvas
const scale = (r: any): RoomCfg => ({
id: r.id,
name: r.name,
area: r.area ?? null,
// carried, not dropped: the static card renders from this model too, and
// without them it ignored the room-level fill override and drew a room the
// full card leaves transparent (HP-1454-07)
open_to: r.open_to || undefined,
settings: r.settings || undefined,
x: r.x != null ? r.x * NORM_W : undefined,
y: r.y != null ? r.y * H : undefined,
w: r.w != null ? r.w * NORM_W : undefined,
@@ -30,12 +51,50 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
id: s.id,
title: s.title,
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
bg: s.plan_url ? { href: contentUrl(s.plan_url), x: 0, y: 0, w: NORM_W, h: H } : null,
bg: s.plan_url ? { href: contentUrl(s.plan_url), ...fitInSquare(s.plan_aspect, NORM_W) } : null,
rooms: (s.rooms || []).map(scale),
} as SpaceModel;
});
}
/**
* What the plan actually occupies, padded by `pad` of the larger side.
*
* The canvas is a square big enough for any house, so a small hand-drawn plan
* used to open as a speck in the middle of it. Zooming to the CONTENT instead
* of the canvas is what people expect — but only when there is no background
* image: with one, the image is the plan, and cropping to the rooms would hide
* the parts of it nobody has outlined yet.
*
* Returns null when there is nothing drawn, so the caller keeps the full canvas.
*/
export function contentBounds(
space: SpaceModel, pad = 0.05, extra?: ReadonlyArray<readonly [number, number]>,
): { x: number; y: number; w: number; h: number } | null {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const add = (x: number, y: number) => {
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
};
for (const r of space.rooms || []) {
if (r.poly) for (const p of r.poly) add(p[0], p[1]);
else if (r.x != null && r.y != null) {
add(r.x, r.y);
add(r.x + (r.w || 0), r.y + (r.h || 0));
}
}
// things that live outside any room still count as content — a gate sensor
// by the fence, a camera on a pole (the card passes device positions here)
for (const p of extra || []) add(p[0], p[1]);
if (minX > maxX || minY > maxY) return null;
const m = Math.max(maxX - minX, maxY - minY) * pad;
const x = minX - m, y = minY - m;
return { x, y, w: (maxX - minX) + m * 2, h: (maxY - minY) + m * 2 };
}
/** Bounding rectangle of a room (rect or polygon) in render units. */
export function roomBounds(r: RoomCfg): { x: number; y: number; w: number; h: number } {
if (r.poly && r.poly.length) {
@@ -86,8 +145,7 @@ export function defaultPositions(devs: DevItem[], model: SpaceModel, iconPct: nu
export function markerPos(d: DevItem, layout: Layout, cfg: ServerConfig, defPos: Record<string, Pt>, model: SpaceModel): Pt {
const saved = layout[d.id];
if (saved && saved.s === d.space) {
const aspect = cfg.spaces.find((x: any) => x.id === d.space)?.aspect || 1;
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
return { x: saved.x * NORM_W, y: saved.y * NORM_W };
}
if (defPos[d.id]) return defPos[d.id];
const vb = model.vb;
@@ -98,8 +156,7 @@ export function markerPos(d: DevItem, layout: Layout, cfg: ServerConfig, defPos:
export function labelPos(r: RoomCfg, spaceId: string, layout: Layout, cfg: ServerConfig): Pt {
const saved = layout['rl_' + (r.id || '')];
if (saved && saved.s === spaceId) {
const aspect = cfg.spaces.find((x: any) => x.id === spaceId)?.aspect || 1;
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
return { x: saved.x * NORM_W, y: saved.y * NORM_W };
}
const c = roomCenter(r);
return { x: c[0], y: c[1] };
+8 -6
View File
@@ -8,7 +8,7 @@
*/
import { html, svg, nothing, type TemplateResult } from 'lit';
import { buildDevices, areaLqi, areaLights, areaTemp } from './devices';
import { spaceDisplayOf, roomFillStyle, fillColorsOf } from './logic';
import { spaceDisplayOf, roomFillStyle, fillColorsOf, roomFillModeOf } from './logic';
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
import { t, type Lang } from './i18n';
import type { ServerConfig } from './types';
@@ -73,16 +73,18 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
.map((r) => {
let cls = 'room ' + (space.bg ? 'overlay' : 'yard');
let style = '';
if (disp.showBorders || disp.fill !== 'none') {
// tier 3 wins over the space, exactly as on the full card (HP-1454-07)
const fill = roomFillModeOf(disp.fill, r);
if (disp.showBorders || fill !== 'none') {
cls += ' styled';
const parts = [`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`];
// fill rendered exactly as configured on the full card (snapshot of current states)
const fillC = r.area
? roomFillStyle(
disp.fill,
disp.fill === 'lqi' ? areaLqi(o.hass, devs, r.area) : null,
disp.fill === 'light' ? areaLights(o.hass, devs, r.area) : 'none',
disp.fill === 'temp' ? areaTemp(o.hass, devs, r.area) : null,
fill,
fill === 'lqi' ? areaLqi(o.hass, devs, r.area) : null,
fill === 'light' ? areaLights(o.hass, devs, r.area) : 'none',
fill === 'temp' ? areaTemp(o.hass, devs, r.area) : null,
disp.tempMin,
disp.tempMax,
fillColorsOf(o.cfg?.settings),
+51 -1
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;
+4 -2
View File
@@ -798,8 +798,10 @@ test('migratePdfUrls: only confirmed copies are rewritten (review CR-3)', () =>
{ name: 'b.pdf', url: '/houseplan_files/files/v_old1/b.pdf?v=2' },
];
// сервер скопировал только a.pdf, причём переименовал из-за коллизии
const out = migratePdfUrls(pdfs, 'v_old1', 'dev99', { 'a.pdf': 'a (2).pdf' });
assert.equal(out[0].url, '/houseplan_files/files/dev99/a%20(2).pdf?v=1');
// collision names use only characters the content view accepts back in a
// request: ' (2)' was sanitised to '_2_' server-side and 404'd (v1.46.0)
const out = migratePdfUrls(pdfs, 'v_old1', 'dev99', { 'a.pdf': 'a-2.pdf' });
assert.equal(out[0].url, '/houseplan_files/files/dev99/a-2.pdf?v=1');
assert.equal(out[1].url, pdfs[1].url, 'нескопированный файл ссылается на старую папку');
// пустой маппинг = ничего не переносим
assert.deepEqual(migratePdfUrls(pdfs, 'v_old1', 'dev99', {}).map((p) => p.url),
+60
View File
@@ -183,3 +183,63 @@ test('dispose(): a late answer neither renders nor throws, start() revives it',
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=NEW');
s.dispose();
});
test('R5-1: an empty but successful answer still backs off', async () => {
let t = 1_000_000;
const { hass, calls } = makeHass();
const { s, updates } = signer(() => t);
s.display(hass, URL_A);
await tick();
calls[0].res({ urls: {} }); // the backend skipped the path it could not sign
await tick();
assert.equal(updates(), 0, 'nothing was signed, so nothing to re-render for');
for (let i = 0; i < 5; i++) { s.display(hass, URL_A); await tick(); }
assert.equal(calls.length, 1, 'five renders, still one request');
t += SIGN_BACKOFF_MIN_MS + 1;
s.display(hass, URL_A);
await tick();
assert.equal(calls.length, 2, 'retried after the backoff');
});
test('R5-1: a partial answer backs off only the path that is missing', async () => {
let t = 1_000_000;
const { hass, calls } = makeHass();
const { s } = signer(() => t);
s.display(hass, URL_A);
s.display(hass, URL_B);
await tick();
assert.deepEqual(calls[0].paths.sort(), [URL_B, URL_A].sort());
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=OK' } }); // B was skipped
await tick();
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=OK');
assert.equal(s.display(hass, URL_B), '');
await tick();
assert.equal(calls.length, 1, 'the missing path is in backoff, not re-asked');
t += SIGN_BACKOFF_MIN_MS + 1;
s.display(hass, URL_A);
s.display(hass, URL_B);
await tick();
assert.deepEqual(calls[1].paths, [URL_B], 'only the missing path is retried');
calls[1].res({ urls: { [URL_B]: URL_B + '?authSig=OK' } });
await tick();
assert.equal(s.display(hass, URL_B), URL_B + '?authSig=OK');
t += SIGN_BACKOFF_MIN_MS * 8;
s.display(hass, URL_B);
await tick();
assert.equal(calls.length, 2, 'a success clears the backoff state, no stray retry');
});
test('R5-1: a key we never asked for is ignored', async () => {
const { hass, calls } = makeHass();
const { s } = signer();
s.display(hass, URL_A);
await tick();
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=OK', '/api/houseplan/content/files/x/evil.pdf': 'nope' } });
await tick();
assert.deepEqual(Object.keys(s.entries), [URL_A]);
});
+65 -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,43 @@ test('defaultPositions: several devices in one room are spread (declumped, disti
});
test('NORM_W is 1000', () => assert.equal(NORM_W, 1000));
test('contentBounds: fits what is drawn, with a 5% margin', () => {
const one = spaceModels({ spaces: [{
id: 's', view_box: [0, 0, 1, 1],
rooms: [{ id: 'r', poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }],
}], markers: [] })[0];
// 200x200 render units in the middle of a 1000x1000 canvas, +5% of 200 each side
assert.deepEqual(contentBounds(one), { x: 390, y: 390, w: 220, h: 220 });
// rectangles count too, and the margin follows the LARGER side
const rect = spaceModels({ spaces: [{
id: 's', view_box: [0, 0, 1, 1],
rooms: [{ id: 'r', x: 0.1, y: 0.4, w: 0.6, h: 0.1 }],
}], markers: [] })[0];
const b = contentBounds(rect);
assert.equal(Math.round(b.w), 660); // 600 + 2 * 5% of 600
assert.equal(Math.round(b.h), 160); // 100 + the same absolute margin
assert.equal(Math.round(b.x), 70);
// nothing drawn → the caller keeps the whole canvas
const empty = spaceModels({ spaces: [{ id: 's', view_box: [0, 0, 1, 1], rooms: [] }], markers: [] })[0];
assert.equal(contentBounds(empty), null);
});
test('contentBounds: devices outside every room stretch the frame', () => {
const one = spaceModels({ spaces: [{
id: 's', view_box: [0, 0, 1, 1],
rooms: [{ id: 'r', poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }],
}], markers: [] })[0];
// a gate sensor far to the right of the room
const b = contentBounds(one, 0.05, [[900, 500]]);
// span 400..900 wide, 400..600 tall; margin 5% of the larger side (500)
assert.deepEqual(b, { x: 375, y: 375, w: 550, h: 250 });
// devices alone are content enough — an empty yard with two cameras
const empty = spaceModels({ spaces: [{ id: 's', view_box: [0, 0, 1, 1], rooms: [] }], markers: [] })[0];
const only = contentBounds(empty, 0.05, [[100, 100], [300, 200]]);
assert.equal(Math.round(only.w), 220);
// and junk coordinates are ignored, not spread across the canvas
assert.deepEqual(contentBounds(one, 0.05, [[NaN, 5]]), contentBounds(one));
});
+70
View File
@@ -34,3 +34,73 @@ async def test_unload(hass: HomeAssistant) -> None:
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert entry.state.value == "not_loaded"
async def test_square_migration_finishes_after_a_crash_between_the_writes(
hass: HomeAssistant, hass_storage, monkeypatch
) -> None:
"""HP-1490-01, end to end on the real stores.
The layout write is made to fail once, AFTER the config write succeeded —
the exact boundary that used to strand the layout in the old coordinates
forever, because the config write had already deleted the `aspect` fields
the layout half needed. The durable intent must finish the job on the next
setup.
"""
from custom_components.houseplan.store import HouseplanStore
hass_storage["houseplan.config"] = {
"version": 1, "data": {
"config": {"spaces": [{"id": "f1", "aspect": 2.0, "rooms": []}],
"markers": [], "settings": {}},
"rev": 3,
},
}
hass_storage["houseplan.layout"] = {
"version": 1, "data": {"layout": {"m": {"s": "f1", "x": 0.1, "y": 0.1}}, "rev": 7},
}
real_save = HouseplanStore.async_save
state = {"layout_saves": 0}
async def failing_save(self, data):
if self.key == "houseplan.layout" and "geom_pending" not in data:
state["layout_saves"] += 1
if state["layout_saves"] == 1:
raise OSError("disk full at the worst possible moment")
await real_save(self, data)
monkeypatch.setattr(HouseplanStore, "async_save", failing_save)
entry = MockConfigEntry(domain=DOMAIN, title="House Plan", data={}, options={})
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
# the crash boundary: config migrated, layout not, intent saved
cfg = hass_storage["houseplan.config"]["data"]["config"]
assert "aspect" not in cfg["spaces"][0], "the config half committed"
lay = hass_storage["houseplan.layout"]["data"]
assert lay["layout"]["m"]["y"] == 0.1, "the layout half did NOT commit"
assert lay.get("geom_pending") == {"f1": 2.0}, "but the intent is durable"
# next start: the store write works again
monkeypatch.setattr(HouseplanStore, "async_save", real_save)
if entry.state.value == "loaded":
await hass.config_entries.async_unload(entry.entry_id)
await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
lay = hass_storage["houseplan.layout"]["data"]
assert lay["layout"]["m"] == {"s": "f1", "x": 0.1, "y": 0.3}, (
"the saved intent finished the layout half"
)
assert "geom_pending" not in lay, "and left with the layout write"
cfg = hass_storage["houseplan.config"]["data"]["config"]
assert cfg["spaces"][0]["view_box"] == [0.0, 0.0, 1.0, 1.0]
# a third start changes nothing — both triggers are gone
before = repr(hass_storage["houseplan.layout"]) + repr(hass_storage["houseplan.config"])
await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
assert repr(hass_storage["houseplan.layout"]) + repr(hass_storage["houseplan.config"]) == before
+4 -1
View File
@@ -32,7 +32,10 @@ async def test_upload_ok(hass: HomeAssistant, hass_client: ClientSessionGenerato
assert resp.status == 200
body = await resp.json()
# audit B1: uploads now return the AUTHENTICATED content URL
assert body["ok"] and body["url"].startswith("/api/houseplan/content/files/m1/manual.pdf?v=")
# HP-1454-02: uploads take a FREE name and never overwrite, so the url is
# the name that was actually used — no cache-busting query needed any more
assert body["ok"] and body["url"].startswith("/api/houseplan/content/files/m1/manual")
assert body["url"].endswith(".pdf") and "?" not in body["url"]
async def test_upload_bad_ext(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> None:
+821 -17
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(
@@ -396,6 +405,31 @@ async def test_collection_ignores_files_that_are_not_plans(
assert (plans / "readme").is_file()
async def test_a_marker_showing_its_value_can_be_saved(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""issue #3: display='value' was rejected, and one bad marker fails the lot.
A user could not save the configuration at all after setting any sensor to
"value instead of an icon" — the editor offered the option, the schema had
never heard of it.
"""
await _setup(hass)
client = await hass_ws_client(hass)
cfg = await _cfg([{"id": "f1", "plan_url": None}])
cfg["markers"] = [
{"id": "sensor.t", "binding": "entity:sensor.t", "display": "value"},
{"id": "sensor.h", "binding": "entity:sensor.h", "display": "badge"},
]
ok = await _save(client, cfg, 0)
assert ok["success"], ok.get("error")
await client.send_json_auto_id({"type": "houseplan/config/get"})
got = await client.receive_json()
assert [m["display"] for m in got["result"]["config"]["markers"]] == ["value", "badge"]
async def test_a_failing_collector_does_not_undo_an_accepted_save(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch
) -> None:
@@ -479,3 +513,773 @@ async def test_content_signed_path_opens_without_a_bearer_header(
)
resp2 = await client.receive_json()
assert resp2["success"] and resp2["result"]["urls"] == {}
async def test_signing_one_path_may_fail_without_failing_the_request(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch
) -> None:
"""review R5-1: pin the contract the card now codes against.
One unsignable path must NOT fail the whole call — a single bad url would
otherwise block the signatures of every other file in the batch. The answer
is a partial map, and the card treats a path missing from it as a failure
for that path (backing off) rather than as success.
"""
from custom_components.houseplan import websocket_api as wsapi
from custom_components.houseplan.const import CONTENT_URL
await _setup(hass)
good = f"{CONTENT_URL}/plans/_/good.png"
bad = f"{CONTENT_URL}/plans/_/bad.png"
real = wsapi.async_sign_path if hasattr(wsapi, "async_sign_path") else None
assert real is None # imported inside the handler, so patch the source module
import homeassistant.components.http.auth as ha_auth
original = ha_auth.async_sign_path
def _sign(hass_, *args, **kwargs):
path = next((a for a in args if isinstance(a, str) and a.startswith("/")), "")
if path == bad:
raise ValueError("cannot sign this one")
return original(hass_, *args, **kwargs)
monkeypatch.setattr(ha_auth, "async_sign_path", _sign)
client = await hass_ws_client(hass)
await client.send_json_auto_id({"type": "houseplan/content/sign", "paths": [good, bad]})
resp = await client.receive_json()
assert resp["success"], "one bad path must not fail the batch"
urls = resp["result"]["urls"]
assert good in urls and "authSig=" in urls[good]
assert bad not in urls, "an unsignable path is absent, never an unsigned url"
async def test_config_write_is_capped_by_total_size(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1454-05: per-field limits bound each list, this bounds their product."""
from custom_components.houseplan.validation import MAX_CONFIG_BYTES, MAX_TEXT
await _setup(hass)
client = await hass_ws_client(hass)
cfg = await _cfg([{"id": "f1", "plan_url": None}])
# every field inside the caps, the whole thing far past them
blob = "d" * MAX_TEXT
cfg["settings"] = {"known_devices": [blob] * (MAX_CONFIG_BYTES // MAX_TEXT + 100)}
resp = await _save(client, cfg, 0)
assert not resp["success"] and resp["error"]["code"] == "too_large"
cfg["settings"] = {"known_devices": ["ok"]}
assert (await _save(client, cfg, 0))["success"]
async def test_layout_keeps_its_revision_and_announces_changes(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1454-08: point-wise writes used to drop the revision and say nothing.
layout/set offered optimistic locking, but every drag wrote {"layout": …}
and reset the counter to 0, so the lock protected nothing; and a static card
on the same dashboard never learned that a marker had moved.
"""
await _setup(hass)
client = await hass_ws_client(hass)
events: list[dict] = []
hass.bus.async_listen("houseplan_layout_updated", lambda ev: events.append(ev.data))
await client.send_json_auto_id({"type": "houseplan/layout/get"})
assert (await client.receive_json())["result"]["rev"] == 0
await client.send_json_auto_id(
{"type": "houseplan/layout/set", "layout": {"a": {"x": 1, "y": 2}}, "expected_rev": 0}
)
rev = (await client.receive_json())["result"]["rev"]
assert rev == 1
await client.send_json_auto_id(
{"type": "houseplan/layout/update", "device_id": "b", "pos": {"x": 3, "y": 4}}
)
assert (await client.receive_json())["result"]["rev"] == 2
await client.send_json_auto_id({"type": "houseplan/layout/delete", "device_id": "b"})
assert (await client.receive_json())["result"]["rev"] == 3
await client.send_json_auto_id({"type": "houseplan/layout/get"})
got = await client.receive_json()
assert got["result"]["rev"] == 3 and got["result"]["layout"] == {"a": {"x": 1, "y": 2}}
# a stale wholesale write is refused, which it could not be before
await client.send_json_auto_id(
{"type": "houseplan/layout/set", "layout": {}, "expected_rev": 1}
)
bad = await client.receive_json()
assert not bad["success"] and bad["error"]["code"] == "conflict"
await hass.async_block_till_done()
# the bus does not promise ordering between separately fired events
assert sorted(e["rev"] for e in events) == [1, 2, 3]
async def test_uploaded_svg_is_sandboxed_and_a_pdf_is_not(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_client
) -> None:
"""HP-1454-01: user SVG served from HA's origin must not be a live document.
Only SVG gets the header: a CSP on a PDF response can break the browser's
built-in viewer, and a raster image cannot execute anything anyway.
"""
import os
from custom_components.houseplan.const import CONTENT_URL, FILES_DIR, PLANS_DIR
await _setup(hass)
plans = hass.config.path(PLANS_DIR)
files = os.path.join(hass.config.path(FILES_DIR), "m1")
def _write() -> None:
os.makedirs(plans, exist_ok=True)
os.makedirs(files, exist_ok=True)
with open(os.path.join(plans, "x.svg"), "wb") as fh:
fh.write(b"<svg xmlns='http://www.w3.org/2000/svg'/>")
with open(os.path.join(plans, "x.png"), "wb") as fh:
fh.write(b"PNG")
with open(os.path.join(files, "m.pdf"), "wb") as fh:
fh.write(b"%PDF-1.4")
await hass.async_add_executor_job(_write)
http = await hass_client()
svg = await http.get(f"{CONTENT_URL}/plans/_/x.svg")
assert svg.status == 200
csp = svg.headers.get("Content-Security-Policy", "")
assert "sandbox" in csp and "script-src 'none'" in csp
assert svg.headers["Content-Type"].startswith("image/svg+xml")
png = await http.get(f"{CONTENT_URL}/plans/_/x.png")
assert png.status == 200 and "Content-Security-Policy" not in png.headers
pdf = await http.get(f"{CONTENT_URL}/files/m1/m.pdf")
assert pdf.status == 200 and "Content-Security-Policy" not in pdf.headers
assert await pdf.read() == b"%PDF-1.4"
async def test_upload_never_overwrites_an_existing_attachment(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_client
) -> None:
"""HP-1454-02: an upload is not part of the config transaction.
Writing straight to `<marker>/<filename>` meant a cancelled dialog — or a
rejected save — left the stored url serving the new bytes. And two new
markers both uploading `manual.pdf` shared one physical file.
"""
import os
from custom_components.houseplan.const import CONTENT_URL, FILES_DIR
await _setup(hass)
http = await hass_client()
async def upload(marker_id: str, name: str, data: bytes) -> str:
import aiohttp
writer = aiohttp.FormData()
writer.add_field("marker_id", marker_id)
writer.add_field("file", data, filename=name)
resp = await http.post("/api/houseplan/upload", data=writer)
assert resp.status == 200, await resp.text()
return (await resp.json())["url"]
first = await upload("m9", "manual.pdf", b"ONE")
second = await upload("m9", "manual.pdf", b"TWO")
assert first != second, "the second upload must not take the first name"
folder = os.path.join(hass.config.path(FILES_DIR), "m9")
# the HA test config dir is shared across the module — hence our own marker id
names = sorted(
n for n in await hass.async_add_executor_job(os.listdir, folder) if n.startswith("manual")
)
assert names == ["manual-2.pdf", "manual.pdf"]
got = await http.get(first.replace(CONTENT_URL, CONTENT_URL))
assert await got.read() == b"ONE", "the first file is untouched"
got2 = await http.get(second)
assert await got2.read() == b"TWO"
async def test_upload_leaves_no_temporary_behind(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_client, monkeypatch
) -> None:
"""HP-1460-02: every exit path must take its temporary file with it.
The streaming rewrite kept one `tmp_path` and cleaned it in an
`except Exception`, which cancellation (a BaseException) walks straight
past — and the attachment collector only ever looks inside marker folders,
so a stranded `.upload-*` was never seen again.
"""
import os
from custom_components.houseplan import http_api
from custom_components.houseplan.const import FILES_DIR
from custom_components.houseplan.plans import TMP_PREFIX
await _setup(hass)
http = await hass_client()
root = hass.config.path(FILES_DIR)
def temps() -> list[str]:
return [n for n in os.listdir(root) if n.startswith(TMP_PREFIX)]
import aiohttp
def form(*files, marker="m8"):
w = aiohttp.FormData()
w.add_field("marker_id", marker)
for name, data in files:
w.add_field("file", data, filename=name)
return w
# two file parts: refused, and nothing left over
resp = await http.post("/api/houseplan/upload", data=form(("a.pdf", b"A"), ("b.pdf", b"B")))
assert resp.status == 400 and (await resp.json())["error"] == "one_file_only"
assert temps() == []
# a rejected extension after the temporary already exists
resp = await http.post("/api/houseplan/upload", data=form(("evil.exe", b"X")))
assert resp.status == 400
assert temps() == []
# promotion itself blows up
real = http_api.reserve_filename
def _boom(*_a, **_k):
raise OSError("disk on fire")
monkeypatch.setattr(http_api, "reserve_filename", _boom)
resp = await http.post("/api/houseplan/upload", data=form(("c.pdf", b"C")))
assert resp.status == 500
assert temps() == [], "a failed promotion must not strand the upload"
monkeypatch.setattr(http_api, "reserve_filename", real)
# and the happy path leaves nothing either
resp = await http.post("/api/houseplan/upload", data=form(("d.pdf", b"D")))
assert resp.status == 200
assert temps() == []
async def test_repair_issue_goes_when_its_space_does(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1454-09: the cleanup used to walk only spaces that still exist.
So deleting or renaming a space with a missing plan left its warning in
Repairs with nothing able to clear it.
"""
from homeassistant.helpers import issue_registry as ir
from custom_components.houseplan.const import DOMAIN as HP_DOMAIN
await _setup(hass)
client = await hass_ws_client(hass)
registry = ir.async_get(hass)
# A reference can only go bad AFTER it is stored — config/set refuses a new
# one that is already broken (HP-1470-02). So: attach a real plan, then let
# the file disappear the way it does in life, from outside Home Assistant.
import os
from custom_components.houseplan.const import PLANS_DIR
url, name = await _upload(client, "r7", b"PLAN")
rev = (await _save(client, await _cfg([{"id": "r7", "plan_url": url}]), 0))["result"]["rev"]
await hass.async_block_till_done()
assert registry.async_get_issue(HP_DOMAIN, "broken_plan_r7") is None, "the file is there"
await hass.async_add_executor_job(
os.remove, os.path.join(hass.config.path(PLANS_DIR), name)
)
rev = (await _save(client, await _cfg([{"id": "r7", "plan_url": url}]), rev))["result"]["rev"]
await hass.async_block_till_done()
assert registry.async_get_issue(HP_DOMAIN, "broken_plan_r7") is not None
# the space is deleted entirely — the warning must not outlive it
await _save(client, await _cfg([{"id": "other", "plan_url": None}]), rev)
await hass.async_block_till_done()
assert registry.async_get_issue(HP_DOMAIN, "broken_plan_r7") is None
async def test_cancelling_an_upload_takes_its_temporary_with_it(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1460-02, properly this time: cancellation, not an ordinary error.
`asyncio.CancelledError` is a BaseException, so the old `except Exception`
never saw it and an aborted transfer stranded its `.upload-*`. The previous
test claimed to cover this and did not — it only exercised error paths.
"""
import asyncio
import os
from custom_components.houseplan.const import FILES_DIR
from custom_components.houseplan.http_api import HouseplanUploadView
from custom_components.houseplan.plans import TMP_PREFIX
entry = await _setup(hass)
root = hass.config.path(FILES_DIR)
os.makedirs(root, exist_ok=True)
started = asyncio.Event()
class _Part:
name = "file"
filename = "big.pdf"
async def read_chunk(self, _size):
started.set()
await asyncio.sleep(3600) # the client stopped sending; we wait
class _Reader:
def __aiter__(self):
return self
async def __anext__(self):
if getattr(self, "_done", False):
raise StopAsyncIteration
self._done = True
return _Part()
from custom_components.houseplan import http_api as hp_http
class _User:
is_admin = True
class _Request:
app = {hp_http.KEY_HASS: hass}
def get(self, _key, default=None):
return _User()
async def multipart(self):
return _Reader()
task = hass.async_create_task(HouseplanUploadView().post(_Request()))
await started.wait()
await asyncio.sleep(0)
assert [n for n in os.listdir(root) if n.startswith(TMP_PREFIX)], "temp exists mid-upload"
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
await hass.async_block_till_done()
assert [n for n in os.listdir(root) if n.startswith(TMP_PREFIX)] == [], (
"a cancelled upload must not leave its temporary behind"
)
assert entry
async def _seed_aged(hass, names) -> None:
"""Create the given files and backdate them past the orphan TTL."""
import os
import time
from custom_components.houseplan.const import SCHEDULED_GRACE_S
old = time.time() - SCHEDULED_GRACE_S - 60 # past every grace
def _do() -> None:
for path in names:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as fh:
fh.write(b"x")
os.utime(path, (old, old))
await hass.async_add_executor_job(_do)
def _paths(hass):
import os
from custom_components.houseplan.const import FILES_DIR, PLANS_DIR
files = hass.config.path(FILES_DIR)
plans = hass.config.path(PLANS_DIR)
return files, plans, {
"kept_file": os.path.join(files, "m5", "kept.pdf"),
"kept_plan": os.path.join(plans, "s5.tok.png"),
"orphan_file": os.path.join(files, "up_cancelled", "manual.pdf"),
# a plan file is never collected by age any more; keep one around and
# assert exactly that
"kept_reject": os.path.join(plans, "s5.reject.png"),
}
async def _referenced_config() -> dict:
cfg = await _cfg([{"id": "s5", "plan_url": "/api/houseplan/content/plans/_/s5.tok.png"}])
cfg["markers"] = [
{"id": "m5", "binding": "virtual",
"pdfs": [{"name": "k", "url": "/api/houseplan/content/files/m5/kept.pdf"}]}
]
return cfg
async def _assert_swept(hass, p) -> None:
import os
assert await hass.async_add_executor_job(os.path.isfile, p["kept_file"]), "referenced file kept"
assert await hass.async_add_executor_job(os.path.isfile, p["kept_plan"]), "referenced plan kept"
assert not await hass.async_add_executor_job(os.path.isfile, p["orphan_file"]), (
"a staging folder from a dialog nobody saved is the one thing age collects"
)
assert await hass.async_add_executor_job(os.path.isfile, p["kept_reject"]), (
"a plan file is never removed for being old"
)
async def test_startup_sweep_collects_what_no_commit_will(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1461-01 / HP-1462-01: collection must not depend on a future save.
The files are seeded AFTER the configuration is stored. The previous version
of this test seeded them before, and `config/set` collects too — so it
passed without the startup pass doing anything, hiding HP-1462-01: during
setup the entry is not "loaded" yet, so looking its runtime data up by
domain returned None and the pass degraded to removing streaming
temporaries only.
"""
import os
await _setup(hass)
client = await hass_ws_client(hass)
files, _plans, p = _paths(hass)
# the plan it names has to exist: config/set refuses a NEW broken
# reference (HP-1470-02). The orphans still arrive after the save.
await _seed_aged(hass, [p["kept_plan"]])
assert (await _save(client, await _referenced_config(), 0))["success"]
await _seed_aged(hass, list(p.values()))
entry = hass.config_entries.async_entries(DOMAIN)[0]
assert await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
await _assert_swept(hass, p)
assert not await hass.async_add_executor_job(
os.path.isdir, os.path.join(files, "up_cancelled")
), "the emptied staging folder goes with its last file"
async def test_periodic_sweep_collects_too(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""The scheduled pass, invoked directly rather than by faking a 24 h jump.
Firing a time change proves the timer fires; awaiting the callback proves it
does the work. This asserts the second, which is the part that regressed.
"""
from custom_components.houseplan.store import get_data
await _setup(hass)
client = await hass_ws_client(hass)
_files, _plans, p = _paths(hass)
# the plan it names has to exist: config/set refuses a NEW broken
# reference (HP-1470-02). The orphans still arrive after the save.
await _seed_aged(hass, [p["kept_plan"]])
assert (await _save(client, await _referenced_config(), 0))["success"]
await _seed_aged(hass, list(p.values()))
data = get_data(hass)
assert data is not None and data.sweep is not None, "setup must publish the sweep"
await data.sweep()
await hass.async_block_till_done()
await _assert_swept(hass, p)
async def test_sweep_and_a_config_write_do_not_race(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Both take the same write lock, so an accepted config cannot lose a file.
Without it the sweep could decide a file is unreferenced, a commit could
start referencing it, and the file would go — leaving the accepted revision
pointing at nothing.
"""
import asyncio
import os
await _setup(hass)
client = await hass_ws_client(hass)
_files, plans, p = _paths(hass)
# the plan it names has to exist: config/set refuses a NEW broken
# reference (HP-1470-02). The orphans still arrive after the save.
await _seed_aged(hass, [p["kept_plan"]])
rev = (await _save(client, await _referenced_config(), 0))["result"]["rev"]
# an aged, currently unreferenced plan that the commit below adopts
newcomer = os.path.join(plans, "s5.newcomer.png")
await _seed_aged(hass, [p["kept_file"], p["kept_plan"], newcomer])
cfg2 = await _referenced_config()
cfg2["spaces"][0]["plan_url"] = "/api/houseplan/content/plans/_/s5.newcomer.png"
# Drive the sweep directly rather than through a reload: an entry reload has
# an unload window in which any WS call legitimately answers `not_ready`, so
# a save racing THAT proves nothing about the lock and fails at random.
from custom_components.houseplan.store import get_data
data = get_data(hass)
assert data is not None and data.sweep is not None
_swept, saved = await asyncio.gather(data.sweep(), _save(client, cfg2, rev))
await hass.async_block_till_done()
# assert the CONCRETE outcome: a save that came back `not_ready` would leave
# the old config pointing at the old file and satisfy a vaguer check
assert saved["success"], saved.get("error")
await client.send_json_auto_id({"type": "houseplan/config/get"})
stored = (await client.receive_json())["result"]["config"]
assert stored["spaces"][0]["plan_url"].endswith("s5.newcomer.png")
assert await hass.async_add_executor_job(
os.path.isfile, os.path.join(plans, "s5.newcomer.png")
), "the file the accepted config points at must exist"
async def test_files_cleanup_keeps_referenced_files(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""v1.46.5: the client may say what it no longer needs, never what may go.
`files/cleanup` used to rmtree the folder it was handed. A partial migration
leaves some urls pointing into that folder — the copy deliberately does not
rewrite the ones it could not confirm — so those were live links to files
being deleted. A wrong id from any client had the same effect on a device's
manuals.
"""
import os
from custom_components.houseplan.const import FILES_DIR
await _setup(hass)
client = await hass_ws_client(hass)
folder = os.path.join(hass.config.path(FILES_DIR), "m7")
def _seed() -> None:
os.makedirs(folder, exist_ok=True)
for n in ("kept.pdf", "orphan.pdf"):
with open(os.path.join(folder, n), "wb") as fh:
fh.write(b"x")
await hass.async_add_executor_job(_seed)
cfg = await _cfg([{"id": "s7", "plan_url": None}])
cfg["markers"] = [
{"id": "other", "binding": "virtual",
"pdfs": [{"name": "k", "url": "/api/houseplan/content/files/m7/kept.pdf"}]}
]
assert (await _save(client, cfg, 0))["success"]
await client.send_json_auto_id({"type": "houseplan/files/cleanup", "marker_id": "m7"})
resp = await client.receive_json()
assert resp["success"] and resp["result"] == {"ok": True, "removed": 1, "kept": 1}
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(folder, "kept.pdf")), (
"a file the configuration still references survives a cleanup of its folder"
)
assert not await hass.async_add_executor_job(os.path.isfile, os.path.join(folder, "orphan.pdf"))
async def test_detaching_a_plan_keeps_the_file(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1465-01, through the real save — where the earlier tests never looked.
Every check for this lived in the pure collector with old and new config
equal, i.e. the scheduled pass. The transition that matters is a save, and
there the file was deleted the moment the reference was cleared.
"""
import os
from custom_components.houseplan.const import PLANS_DIR
await _setup(hass)
client = await hass_ws_client(hass)
plans = hass.config.path(PLANS_DIR)
url, name = await _upload(client, "d1", b"PLAN", ext="png")
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": url}]), 0))["result"]["rev"]
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name))
# detach: the space stays, its plan does not
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": None}]), rev))["result"]["rev"]
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name)), (
"the editor says the image stays on disk — it has to actually stay"
)
# a restart does not change its mind either
entry = hass.config_entries.async_entries(DOMAIN)[0]
assert await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name))
# re-attach, then replace: THAT removes the one it replaced
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": url}]), rev))["result"]["rev"]
url2, name2 = await _upload(client, "d1", b"NEWPLAN", ext="png")
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": url2}]), rev))["result"]["rev"]
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name2))
assert not await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name))
# and deleting the space keeps its plan
await _save(client, await _cfg([]), rev)
await hass.async_block_till_done()
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name2))
async def test_stored_plans_can_be_listed_and_deleted_on_request(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1466-01/-02: "we never delete" needs the files to be findable.
A detached plan stays on disk. Without a way to see it, that is neither a
recovery path nor a disk policy — it is just accumulation. Listing gives
both: the user attaches it again, or deletes it on purpose, which is the
only way a plan file is ever removed.
"""
await _setup(hass)
client = await hass_ws_client(hass)
used_url, used = await _upload(client, "p1", b"USED", ext="png")
_free_url, free = await _upload(client, "p2", b"FREE", ext="png")
rev = (await _save(client, await _cfg([{"id": "p1", "plan_url": used_url}]), 0))["result"]["rev"]
await client.send_json_auto_id({"type": "houseplan/plans/list"})
# the HA test config dir is shared across the module: look at ours, not all
plans = {p["name"]: p for p in (await client.receive_json())["result"]["plans"]}
assert {used, free} <= set(plans)
assert plans[used]["used_by"] and not plans[free]["used_by"]
assert plans[used]["size"] == 4 and plans[free]["url"].endswith(free)
# a plan a space still uses is refused — the config decides, not the client
await client.send_json_auto_id({"type": "houseplan/plans/delete", "name": used})
resp = await client.receive_json()
assert not resp["success"] and resp["error"]["code"] == "in_use"
# an unused one goes on request
await client.send_json_auto_id({"type": "houseplan/plans/delete", "name": free})
assert (await client.receive_json())["result"]["removed"] is True
# detach the other, and now it is deletable — and listed as free until then
await _save(client, await _cfg([{"id": "p1", "plan_url": None}]), rev)
await client.send_json_auto_id({"type": "houseplan/plans/list"})
plans = {p["name"]: p for p in (await client.receive_json())["result"]["plans"]}
assert used in plans, "the detached plan is still there, ready to re-attach"
assert not plans[used]["used_by"]
assert free not in plans, "and the one we deleted is gone"
# nothing outside the plans folder can be reached through the name
await client.send_json_auto_id(
{"type": "houseplan/plans/delete", "name": "../../configuration.yaml"}
)
bad = await client.receive_json()
assert not bad["success"] and bad["error"]["code"] == "invalid_name"
async def test_config_set_refuses_a_plan_that_no_longer_exists(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1470-02: a stored internal plan url must name a file that exists.
The card can pick a plan and delete it from the same dialog, and two clients
can do the same in either order. The lock serialises them; it says nothing
about whether the file survived, so the check has to be here.
"""
await _setup(hass)
client = await hass_ws_client(hass)
url, name = await _upload(client, "x1", b"PLAN", ext="png")
await client.send_json_auto_id({"type": "houseplan/plans/delete", "name": name})
assert (await client.receive_json())["result"]["removed"] is True
resp = await _save(client, await _cfg([{"id": "x1", "plan_url": url}]), 0)
assert not resp["success"] and resp["error"]["code"] == "missing_plan"
# an external or legacy url is the user's business, not ours to verify
assert (await _save(client, await _cfg([{"id": "x1", "plan_url": "/local/mine.png"}]), 0))["success"]
async def test_uploads_are_bounded_by_a_store_quota(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch
) -> None:
"""HP-1470-01: nothing is deleted for being old, so growth stops at the door."""
from custom_components.houseplan import websocket_api as wsapi
from pathlib import Path
from custom_components.houseplan.const import PLANS_DIR
from custom_components.houseplan.plans import dir_usage
await _setup(hass)
client = await hass_ws_client(hass)
# every test in this module shares one config directory, so the plans folder
# is not empty here — budget two more from whatever is already stored
stored, _b = await hass.async_add_executor_job(
lambda: dir_usage(Path(hass.config.path(PLANS_DIR)))[::-1]
)
monkeypatch.setattr(wsapi, "MAX_PLANS_FILES", stored + 2)
await _upload(client, "q1", b"one")
await _upload(client, "q1", b"two")
import base64
await client.send_json_auto_id({
"type": "houseplan/plan/set", "space_id": "q1", "ext": "png",
"data": base64.b64encode(b"three").decode(),
})
resp = await client.receive_json()
assert not resp["success"] and resp["error"]["code"] == "too_many_files"
async def test_parallel_uploads_cannot_slip_past_the_quota_together(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch
) -> None:
"""HP-1490-02: N uploads used to measure the store before any of them
wrote, so all N passed a quota only one of them fits under. The
check→write pair is one job under upload_lock now, so whatever the
interleaving, at most ONE of two competing uploads can take the last slot.
"""
import asyncio
import base64
from custom_components.houseplan import websocket_api as wsapi
from custom_components.houseplan.const import PLANS_DIR
from custom_components.houseplan.plans import dir_usage
from pathlib import Path
await _setup(hass)
c1 = await hass_ws_client(hass)
c2 = await hass_ws_client(hass)
_bytes, stored = await hass.async_add_executor_job(
dir_usage, Path(hass.config.path(PLANS_DIR))
)
monkeypatch.setattr(wsapi, "MAX_PLANS_FILES", stored + 1) # room for ONE
payload = base64.b64encode(b"PLAN").decode()
async def upload(client, sid):
await client.send_json_auto_id({
"type": "houseplan/plan/set", "space_id": sid, "ext": "png", "data": payload,
})
return await client.receive_json()
r1, r2 = await asyncio.gather(upload(c1, "pa"), upload(c2, "pb"))
oks = [r for r in (r1, r2) if r["success"]]
errs = [r for r in (r1, r2) if not r["success"]]
assert len(oks) == 1, "exactly one takes the last slot"
assert errs and errs[0]["error"]["code"] == "too_many_files"
_bytes2, after = await hass.async_add_executor_job(
dir_usage, Path(hass.config.path(PLANS_DIR))
)
assert after == stored + 1, "the store holds what the quota promised, not more"
+577 -15
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
@@ -305,3 +302,568 @@ def test_collect_plans_never_raises_when_the_directory_disappears(tmp_path, monk
monkeypatch.setattr(type(d), "iterdir", _boom, raising=False)
assert collect_plans(d, _cfg("/p/a.png"), _cfg("/p/b.png")) == 0
# ---------- the editor's options must be storable (issue #3) ----------
def _ts_list(name):
"""Read one `export const NAME = [...] as const;` list out of src/logic.ts.
Deliberately reads the TypeScript source rather than duplicating the values:
a list that lives in two places drifts, which is exactly what happened here.
"""
import re
src = os.path.join(os.path.dirname(os.path.dirname(__file__)), "src", "logic.ts")
with open(src, encoding="utf-8") as fh:
text = fh.read()
m = re.search(rf"export const {name} = \[(.*?)\] as const;", text, re.S)
assert m, f"{name} not found in src/logic.ts"
return re.findall(r"'([^']+)'", m.group(1))
def _marker(**extra):
return {"id": "m1", "binding": "entity:sensor.x", **extra}
def test_every_display_mode_the_editor_offers_is_accepted():
"""issue #3: 'value' was added to the card in v1.26.0 and never to the schema.
Saving a sensor set to "value instead of an icon" failed with
"not a valid value for dictionary value @ data['config']['markers'][n]['display']",
and because one bad marker rejects the whole config, the user could not save
at all. Reported 2026-07-27.
"""
modes = _ts_list("DISPLAY_MODES")
assert "value" in modes, "the regression this test exists for"
for mode in modes:
v.MARKER_SCHEMA(_marker(display=mode))
v.MARKER_SCHEMA(_marker(display=None))
with pytest.raises(vol.Invalid):
v.MARKER_SCHEMA(_marker(display="wat"))
def test_every_tap_action_the_editor_offers_is_accepted():
for action in _ts_list("TAP_ACTIONS"):
v.MARKER_SCHEMA(_marker(tap_action=action))
with pytest.raises(vol.Invalid):
v.MARKER_SCHEMA(_marker(tap_action="launch-missiles"))
def _space(**settings):
return {
"id": "f1", "title": "F1", "aspect": 1.4, "view_box": [0, 0, 1, 1],
"rooms": [], "settings": settings,
}
def test_every_fill_mode_the_editor_offers_is_accepted():
for mode in _ts_list("SPACE_FILL_MODES"):
v.SPACE_SCHEMA(_space(fill_mode=mode))
with pytest.raises(vol.Invalid):
v.SPACE_SCHEMA(_space(fill_mode="rainbow"))
def test_every_room_fill_mode_the_editor_offers_is_accepted():
def room(mode):
return {
"id": "f1", "title": "F1", "aspect": 1.4, "view_box": [0, 0, 1, 1],
"rooms": [{"id": "r1", "name": "R", "x": 0.1, "y": 0.1, "w": 0.2, "h": 0.2,
"settings": {"fill_mode": mode}}],
}
for mode in _ts_list("ROOM_FILL_MODES"):
v.SPACE_SCHEMA(room(mode))
v.SPACE_SCHEMA(room(None)) # inherit from the space
with pytest.raises(vol.Invalid):
v.SPACE_SCHEMA(room("rainbow"))
# ---------- attachments & inner limits (HP-1454-02, -05) ----------
def test_reserve_filename_claims_the_name_atomically(tmp_path):
"""HP-1460-01: choosing a name and taking it must be one operation.
The old helper asked `exists()` and returned a string; two uploads racing
between the check and the write agreed on the same name and one overwrote
the other, both reporting success.
"""
reserve = plans.reserve_filename
d = tmp_path / "m1"
first = reserve(d, "manual.pdf")
assert first == "manual.pdf"
assert (d / first).is_file(), "the name is taken, not merely picked"
second = reserve(d, "manual.pdf")
assert second == "manual-2.pdf" and (d / second).is_file()
assert reserve(d, "manual.pdf") == "manual-3.pdf"
assert reserve(d, "readme") == "readme"
assert reserve(d, "readme") == "readme-2"
assert reserve(d, "../../etc/passwd") == "passwd"
def test_reserve_filename_result_survives_the_content_sanitizer(tmp_path):
"""A name the view would rewrite is a file written and then never served."""
reserve = plans.reserve_filename
d = tmp_path / "m2"
long_stem = "x" * 200 # far past the limit
names = [reserve(d, f"{long_stem}.pdf") for _ in range(12)]
assert len(set(names)) == 12, "each call takes its own name"
for n in names:
assert len(n) <= v.MAX_FILENAME
assert v.sanitize_filename(n) == n, n
assert n.endswith(".pdf")
# a name already exactly at the limit still leaves room for the tag
exact = "y" * (v.MAX_FILENAME - 4) + ".pdf"
assert len(exact) == v.MAX_FILENAME
a = reserve(d, exact)
b = reserve(d, exact)
assert a != b and len(b) <= v.MAX_FILENAME and v.sanitize_filename(b) == b
def test_reserve_filename_is_safe_under_concurrency(tmp_path):
"""Twenty threads, one filename: twenty distinct files, nothing overwritten."""
from concurrent.futures import ThreadPoolExecutor
reserve = plans.reserve_filename
d = tmp_path / "m3"
d.mkdir(parents=True)
with ThreadPoolExecutor(max_workers=20) as pool:
names = list(pool.map(lambda _: reserve(d, "manual.pdf"), range(20)))
assert len(set(names)) == 20
assert sorted(p.name for p in d.iterdir()) == sorted(names)
def test_sweep_upload_temps(tmp_path):
"""HP-1460-02: a crashed transfer leaves a temp no other collector sees."""
import os
import time
files = tmp_path / "files"
files.mkdir()
fresh = files / f"{plans.TMP_PREFIX}fresh"
old = files / f"{plans.TMP_PREFIX}old"
keep = files / "notes.txt"
for f in (fresh, old, keep):
f.write_bytes(b"x")
t = time.time() - const.PLAN_ORPHAN_TTL_S - 60
os.utime(old, (t, t))
assert plans.sweep_upload_temps(files) == 1
assert not old.exists(), "an aged temporary goes"
assert fresh.is_file(), "a fresh one may belong to a request in flight"
assert keep.is_file(), "nothing else is touched"
assert plans.sweep_upload_temps(tmp_path / "nope") == 0
def _acfg(*pairs):
return {"markers": [{"id": f"m{i}", "pdfs": [{"url": f"/api/houseplan/content/files/{p}"}]}
for i, p in enumerate(pairs)]}
def test_attachment_refs_reads_marker_urls():
attachment_refs = plans.attachment_refs
assert attachment_refs(None) == set()
assert attachment_refs(_acfg("m1/a.pdf", "m2/b.pdf")) == {"m1/a.pdf", "m2/b.pdf"}
# legacy and foreign urls are not ours to collect against
cfg = {"markers": [{"id": "m", "pdfs": [{"url": "/local/x.pdf"}, {"url": "/api/houseplan/content/files/deep/a/b.pdf"}]}]}
assert plans.attachment_refs(cfg) == set()
def test_collect_attachments_removes_the_empty_folder_and_never_raises(tmp_path):
import os
import time
files = tmp_path / "files"
(files / "up_x").mkdir(parents=True)
f = files / "up_x" / "orphan.pdf"
f.write_bytes(b"x")
old = time.time() - const.PLAN_ORPHAN_TTL_S - 60
os.utime(f, (old, old))
assert plans.collect_attachments(files, {}, {}) == 1
assert not (files / "up_x").exists(), "the staging folder goes with its last file"
assert plans.collect_attachments(tmp_path / "nope", {}, {}) == 0
def test_inner_collection_limits():
room = {"id": "r", "name": "R", "poly": [[0.1, 0.1]] * v.MAX_POLY_POINTS}
v.ROOM_SCHEMA(room)
with pytest.raises(vol.Invalid):
v.ROOM_SCHEMA({**room, "poly": [[0.1, 0.1]] * (v.MAX_POLY_POINTS + 1)})
rect = {"id": "r", "name": "R", "x": 0.1, "y": 0.1, "w": 0.2, "h": 0.2}
v.ROOM_SCHEMA({**rect, "open_to": ["x"] * v.MAX_OPEN_TO})
with pytest.raises(vol.Invalid):
v.ROOM_SCHEMA({**rect, "open_to": ["x"] * (v.MAX_OPEN_TO + 1)})
m = {"id": "m", "binding": "virtual"}
v.MARKER_SCHEMA({**m, "controls": ["light.x"] * v.MAX_CONTROLS})
with pytest.raises(vol.Invalid):
v.MARKER_SCHEMA({**m, "controls": ["light.x"] * (v.MAX_CONTROLS + 1)})
pdf = {"name": "n", "url": "/api/houseplan/content/files/m/a.pdf"}
v.MARKER_SCHEMA({**m, "pdfs": [pdf] * v.MAX_PDFS})
with pytest.raises(vol.Invalid):
v.MARKER_SCHEMA({**m, "pdfs": [pdf] * (v.MAX_PDFS + 1)})
v.MARKER_SCHEMA({**m, "name": "n" * v.MAX_TEXT})
with pytest.raises(vol.Invalid):
v.MARKER_SCHEMA({**m, "name": "n" * (v.MAX_TEXT + 1)})
with pytest.raises(vol.Invalid):
v.MARKER_SCHEMA({**m, "link": "u" * (v.MAX_URL + 1)})
def test_legacy_segments_are_dropped_by_the_server():
"""A limit that depends on the client stripping the field is not a limit."""
out = v.SPACE_SCHEMA({
"id": "f1", "title": "F", "aspect": 1.4, "view_box": [0, 0, 1, 1], "rooms": [],
"segments": [[1, 2, 3, 4]] * 100000,
})
assert "segments" not in out
def _aged(path, seconds):
import os
import time
t = time.time() - seconds
os.utime(path, (t, t))
def _sp(sid, url):
return {"id": sid, "plan_url": f"/api/houseplan/content/plans/_/{url}" if url else None}
def test_plan_collection_matrix(tmp_path):
"""Which config transition means "the user asked for this file to go"?
`old_refs - new_refs` cannot tell replace, detach and delete-space apart —
they look identical. v1.46.4/v1.46.5 added guards for detach and only ever
reached them on the scheduled pass, so the commit itself still deleted a
detached plan the moment it was detached (HP-1465-01). One case is a
deletion the user asked for; the rest are kept.
"""
collect = plans.collect_plans
d = tmp_path / "plans"
d.mkdir()
def seed(*names):
for n in names:
(d / n).write_bytes(b"x")
_aged(d / n, const.SCHEDULED_GRACE_S * 2) # old enough for any rule
# 1. replace: the user picked a different image for the same space
seed("f1.old.png", "f1.new.png")
assert collect(d, {"spaces": [_sp("f1", "f1.old.png")]},
{"spaces": [_sp("f1", "f1.new.png")]}) == 1
assert not (d / "f1.old.png").exists() and (d / "f1.new.png").is_file()
# 2. detach: same space, switched to "draw"
seed("f2.png")
assert collect(d, {"spaces": [_sp("f2", "f2.png")]},
{"spaces": [_sp("f2", None)]}) == 0
assert (d / "f2.png").is_file(), "the editor says the file stays — it stays"
# 3. the space is deleted outright
seed("f3.png")
assert collect(d, {"spaces": [_sp("f3", "f3.png")]}, {"spaces": []}) == 0
assert (d / "f3.png").is_file()
# 4. the scheduled pass, later, still keeps both
cfg = {"spaces": [_sp("f2", None)]}
assert collect(d, cfg, cfg) == 0
assert (d / "f2.png").is_file() and (d / "f3.png").is_file()
# 5. an upload whose save was rejected is kept too, at any age. Ageing those
# out raced the retry: the sweep deleted a file the save was committing a
# reference to (caught by test_sweep_and_a_config_write_do_not_race).
seed("f4.current.png", "f4.reject.png")
live = {"spaces": [_sp("f4", "f4.current.png")]}
assert collect(d, live, live) == 0
assert (d / "f4.current.png").is_file() and (d / "f4.reject.png").is_file()
# 6. the same file still referenced by another space is never touched
seed("shared.png")
assert collect(d, {"spaces": [_sp("a", "shared.png"), _sp("b", "shared.png")]},
{"spaces": [_sp("a", None), _sp("b", "shared.png")]}) == 0
assert (d / "shared.png").is_file()
def test_attachment_collection_matrix(tmp_path):
"""Removing an attachment is a trash button; deleting the device is not."""
collect = plans.collect_attachments
def case(name):
d = tmp_path / name
d.mkdir()
return d
def seed(root, folder, fname, age=None):
(root / folder).mkdir(parents=True, exist_ok=True)
p = root / folder / fname
p.write_bytes(b"x")
if age:
_aged(p, age)
return p
def cfg(*markers):
return {"markers": [
{"id": mid, "pdfs": [{"url": f"/api/houseplan/content/files/{mid}/{n}"} for n in names]}
for mid, names in markers
]}
# 1. the user removed one attachment from a device that still exists
d = case("dropped")
seed(d, "m1", "dropped.pdf")
seed(d, "m1", "kept.pdf")
assert collect(d, cfg(("m1", ["dropped.pdf", "kept.pdf"])), cfg(("m1", ["kept.pdf"]))) == 1
assert not (d / "m1" / "dropped.pdf").exists()
assert (d / "m1" / "kept.pdf").is_file()
# 2. the device itself is gone: its manuals are not ours to throw away
d = case("device_gone")
seed(d, "m2", "manual.pdf", age=const.SCHEDULED_GRACE_S * 2)
assert collect(d, cfg(("m2", ["manual.pdf"])), cfg()) == 0
assert (d / "m2" / "manual.pdf").is_file()
# and the scheduled pass, later, agrees
assert collect(d, cfg(), cfg()) == 0
assert (d / "m2" / "manual.pdf").is_file()
# 3. a dialog that was never saved, in its own staging folder
d = case("staging")
seed(d, "up_x", "manual.pdf", age=const.PLAN_ORPHAN_TTL_S + 60)
assert collect(d, cfg(), cfg()) == 1
assert not (d / "up_x").exists()
# 4. an upload into a live device's folder whose save was rejected: kept,
# for the same reason as a plan's — a retry may be about to reference it
d = case("reject")
seed(d, "m3", "current.pdf")
seed(d, "m3", "rejected.pdf", age=const.SCHEDULED_GRACE_S + 60)
live = cfg(("m3", ["current.pdf"]))
assert collect(d, live, live) == 0
assert (d / "m3" / "current.pdf").is_file()
assert (d / "m3" / "rejected.pdf").is_file()
def test_only_a_staging_folder_ages_out(tmp_path):
"""The one age rule left. Everything else waits for the user to say so."""
import os
import time
files = tmp_path / "files"
(files / "m1").mkdir(parents=True)
(files / "up_abandoned").mkdir(parents=True)
ancient = time.time() - const.SCHEDULED_GRACE_S * 12
hour_ago = time.time() - const.PLAN_ORPHAN_TTL_S - 60
for path, when in (
((files / "m1" / "ancient.pdf"), ancient),
((files / "up_abandoned" / "manual.pdf"), hour_ago),
):
path.write_bytes(b"x")
os.utime(path, (when, when))
cfg = {"markers": [{"id": "m1", "pdfs": []}]}
assert plans.collect_attachments(files, cfg, cfg) == 1
assert (files / "m1" / "ancient.pdf").is_file(), "age alone is never a reason"
assert not (files / "up_abandoned").exists(), "a cancelled dialog goes after an hour"
# ---------- square canvas migration (v1.48.0) ----------
gm = _load_pure("geometry_migration")
def _sq(space, layout=None):
cfg = {"spaces": [space]}
gm.migrate_config(cfg, layout if layout is not None else {})
return cfg["spaces"][0]
def test_the_viewport_becomes_the_whole_square():
"""The grid is drawn over the view box, and the fit fits it.
Transforming the old rectangle instead would leave the new margins outside
the canvas — no grid there and nothing to draw on — which is the room the
square canvas was meant to add.
"""
sp = _sq({"id": "f1", "aspect": 0.5, "view_box": [0.1, 0.2, 0.5, 0.5], "rooms": []})
assert sp["view_box"] == [0.0, 0.0, 1.0, 1.0]
def test_a_wide_plan_gains_margins_above_and_below():
sp = _sq({
"id": "f1", "aspect": 2.0, "cell_cm": 5, "view_box": [0, 0, 1, 1],
"rooms": [{"id": "r", "x": 0.0, "y": 0.0, "w": 1.0, "h": 1.0}],
})
r = sp["rooms"][0]
assert (r["x"], r["w"]) == (0.0, 1.0), "the width is untouched"
assert r["y"] == 0.25 and r["h"] == 0.5, "half the height, centred"
assert sp["cell_cm"] == 5, "the grid is tied to the width, which did not change"
assert "aspect" not in sp
def test_a_tall_plan_gains_margins_on_the_sides_and_rescales_the_grid():
sp = _sq({
"id": "f1", "aspect": 0.5, "cell_cm": 5, "view_box": [0, 0, 1, 1],
"rooms": [{"id": "r", "poly": [[0, 0], [1, 0], [1, 1], [0, 1]]}],
})
poly = sp["rooms"][0]["poly"]
assert [round(c, 6) for c in poly[0]] == [0.25, 0.0]
assert [round(c, 6) for c in poly[2]] == [0.75, 1.0], "half the width, centred"
assert sp["cell_cm"] == 10, "the canvas got twice as wide, so a cell is twice the cm"
def test_a_square_plan_is_left_alone():
before = {
"id": "f1", "aspect": 1.0, "cell_cm": 5, "view_box": [0, 0, 1, 1],
"rooms": [{"id": "r", "x": 0.1, "y": 0.2, "w": 0.3, "h": 0.4}],
}
sp = _sq({**before, "rooms": [dict(before["rooms"][0])]})
assert sp["rooms"][0] == before["rooms"][0]
assert sp["cell_cm"] == 5 and sp["view_box"] == [0.0, 0.0, 1.0, 1.0]
def test_migration_preserves_real_lengths_and_shapes():
"""A wall keeps its length in centimetres, and a square stays square."""
GRID = 1000.0
def wall_cm(space, p, q):
# render units per normalised unit is the canvas width, always 1000
dx = (q[0] - p[0]) * GRID
dy = (q[1] - p[1]) * GRID
pitch = GRID / 40 # whatever the grid is, the same constant both sides
return ((dx * dx + dy * dy) ** 0.5 / pitch) * float(space["cell_cm"])
for aspect in (2.0, 0.5, 0.8155784250916674, 1.4142):
# a square room, 0.2 x 0.2 of the OLD box, i.e. 200 x 200/aspect render
old = {"id": "f", "aspect": aspect, "cell_cm": 5,
"rooms": [{"id": "r", "poly": [[0.2, 0.2], [0.4, 0.2], [0.4, 0.4], [0.2, 0.4]]}]}
before_w = 0.2 * GRID
before_h = 0.2 * GRID / aspect
before_cm_w = (before_w / (GRID / 40)) * 5
sp = _sq(old)
poly = sp["rooms"][0]["poly"]
after_w = (poly[1][0] - poly[0][0]) * GRID
after_h = (poly[2][1] - poly[1][1]) * GRID
assert abs(after_w / after_h - before_w / before_h) < 1e-9, "shape preserved"
# cell_cm is stored rounded — a user reads it — so allow 0.01 cm on a
# 40 cm wall rather than pretending the scale is infinitely precise
assert abs(wall_cm(sp, poly[0], poly[1]) - before_cm_w) < 1e-2, "length in cm preserved"
def test_migration_moves_marker_positions_of_that_space_only():
layout = {
"a": {"s": "f1", "x": 0.5, "y": 0.5},
"b": {"s": "other", "x": 0.5, "y": 0.5},
"c": "not a dict",
}
cfg = {"spaces": [{"id": "f1", "aspect": 2.0, "rooms": []},
{"id": "other", "rooms": []}]}
assert gm.migrate_config(cfg, layout) is True
assert layout["a"] == {"s": "f1", "x": 0.5, "y": 0.5}, "x untouched for a wide plan"
assert layout["a"]["y"] == 0.5
assert layout["b"] == {"s": "other", "x": 0.5, "y": 0.5}, "another space is not touched"
def test_migration_runs_once_and_only_when_needed():
cfg = {"spaces": [{"id": "f1", "aspect": 2.0, "rooms": [], "cell_cm": 5}]}
assert gm.migrate_config(cfg, {}) is True
snapshot = repr(cfg)
assert gm.migrate_config(cfg, {}) is False, "already square: nothing to do"
assert repr(cfg) == snapshot
def test_migration_survives_a_crash_between_the_two_store_writes():
"""HP-1490-01: the two stores are written independently and either write
can fail. The intent (space -> old aspect) is saved BEFORE anything moves
and cleared with the layout write, so whichever half is missing after a
crash, the next start finishes exactly it — once.
"""
cfg = {"spaces": [{"id": "f1", "aspect": 2.0, "rooms": []}]}
layout = {"m": {"s": "f1", "x": 0.1, "y": 0.1}}
# start of the migration: the intent is computed from the config
pending = gm.pending_from_config(cfg)
assert pending == {"f1": 2.0}
# the config half commits; the process dies before the layout half
assert gm.migrate_config(cfg) is True
assert gm.pending_from_config(cfg) == {}, "the trigger left with the config write"
# next start: the config offers nothing, the SAVED intent still knows
assert gm.migrate_layout(layout, pending) is True
assert layout["m"] == {"s": "f1", "x": 0.1, "y": 0.3}, "y is re-centred for a wide plan"
# and the layout half never runs twice, because the intent is cleared by
# the same write that stores the migrated layout — with no intent there is
# nothing to apply
assert gm.migrate_layout(layout, {}) is False
assert layout["m"] == {"s": "f1", "x": 0.1, "y": 0.3}
def test_migration_intent_is_the_union_of_saved_and_current():
"""A crash BEFORE the config write leaves both the intent and the aspects;
merging them must not double anything, and a space added to the config
since (there cannot be one mid-crash, but the code should not care) still
migrates."""
cfg = {"spaces": [{"id": "f1", "aspect": 2.0, "rooms": []}]}
saved = {"f1": 2.0}
merged = {**saved, **gm.pending_from_config(cfg)}
assert merged == {"f1": 2.0}
layout = {"m": {"s": "f1", "x": 0.1, "y": 0.1}}
gm.migrate_config(cfg)
gm.migrate_layout(layout, merged)
assert layout["m"]["y"] == 0.3
assert cfg["spaces"][0]["view_box"] == [0.0, 0.0, 1.0, 1.0]
# ---------- store-wide limits (HP-1470-01) ----------
def test_check_quota_counts_the_whole_store_not_one_request(tmp_path):
"""Per-request caps say nothing about how many requests there are."""
d = tmp_path / "plans"
d.mkdir()
for i in range(3):
(d / f"p{i}.png").write_bytes(b"x" * 1000)
plans.check_quota(d, 1000, max_bytes=10_000, max_files=10) # fits
with pytest.raises(plans.QuotaError) as e:
plans.check_quota(d, 8000, max_bytes=10_000, max_files=10)
assert e.value.reason == "quota_exceeded" and "MB" in e.value.detail
with pytest.raises(plans.QuotaError) as e:
plans.check_quota(d, 1, max_bytes=10_000, max_files=3)
assert e.value.reason == "too_many_files"
def test_dir_usage_walks_subfolders_and_ignores_the_unreadable(tmp_path):
d = tmp_path / "files"
(d / "m1").mkdir(parents=True)
(d / "m1" / "a.pdf").write_bytes(b"x" * 10)
(d / "b.pdf").write_bytes(b"x" * 5)
assert plans.dir_usage(d) == (15, 2)
assert plans.dir_usage(tmp_path / "nope") == (0, 0)
def test_check_quota_refuses_when_the_disk_is_nearly_full(tmp_path, monkeypatch):
import shutil
d = tmp_path / "plans"
d.mkdir()
monkeypatch.setattr(
shutil, "disk_usage", lambda _p: type("U", (), {"free": const.MIN_FREE_BYTES // 2})()
)
with pytest.raises(plans.QuotaError) as e:
plans.check_quota(d, 1, max_bytes=10 ** 12, max_files=10 ** 6)
assert e.value.reason == "low_disk_space"