Compare commits

...
64 Commits
Author SHA1 Message Date
Matysh 764e023996 v1.53.0 2026-07-30 01:37:00 +03:00
Matysh ce6a11f53f v1.53.0: a tap can run your automation
The owner's spec shipped in dev yesterday, released as one:
- tap action 'Run automation/script/scene' with a searchable picker,
  per-domain services, save/runtime guards for the target
- 'Ask for confirmation' checkbox guarding toggle and run alike
- covers/valves in the card-wide toggle, garage/door/gate excluded

Inventory: 148 / 52 / 43 / 72.
2026-07-30 01:34:05 +03:00
Matysh 3e976562ff tap action: run an automation, a script or a scene — with a confirm guard
Owner's spec (2026-07-29), agreed points: one 'Run' action covering the
three runnable domains of HA (a script is the idiomatic 'action' — with
automations alone people would build trigger-less dummies); the confirm
checkbox guards BOTH toggle and run; covers and valves join the card-wide
toggle so curtains work natively.

- marker.tap_action gains 'run'; marker.tap_target (schema-bounded to
  automation./script./scene. ids); marker.tap_confirm.
- the dialog: a searchable picker over the three domains (friendly name +
  kind), save refuses a run action without a target, a vanished target gets
  a warning hint; the checkbox shows for any actionable tap (explicit or
  effective-default toggle).
- the tap: automation.trigger / script.turn_on / scene.turn_on, started/
  error toasts; with confirm on — our own dialog (not window.confirm, it
  must work on a wall tablet), Esc/backdrop/Cancel = no call. The guard
  covers the controls-toggle path too.
- 'run' is explicit-only by construction: it needs a per-marker target, so
  it can never arrive as a card-wide default.
- covers: the old test pinned 'garage stays shut' — that intent survives as
  COVER_GUARDED_CLASSES (garage/door/gate stay out of the CARD-WIDE toggle;
  an explicit per-device toggle remains the owner's conscious choice).
  Locks/alarms stay forbidden everywhere, run included is not affected —
  we do not inspect automation contents, same trust as HA's own Run button.

Tests: unit resolveTapAction/runServiceFor + cover guard, backend schema
parity picks 'run' automatically + tap_target bounds, smoke_tap_run with 11
assertions (picker, search, save guard, confirm cancel/ok, per-domain
services, missing target). smoke_tap_ctx: 4 options now.
Inventory: 148 / 52 / 43 / 72.
2026-07-30 01:26:30 +03:00
Matysh f56bceef27 STATUS: fresh HACS queue numbers (2026-07-29) — 835 ahead, merge rate stalled 2026-07-30 00:49:51 +03:00
Matysh 96a01e1380 v1.52.2 2026-07-29 22:19:22 +03:00
Matysh 08a9cc7d27 v1.52.2: the v1.52.1 review (HP-1521-01, HP-1521-02)
- HP-1521-01: the plan-mode assertion looked for ANY .dev.on and the lit
  kettle satisfied it — a false positive hiding the very regression it
  guards. It targets d_lamp now (kettle asserted separately), and the
  mutation check proves it: reverting the v1.52.1 gate fails the smoke.
- HP-1521-02: the checklist entry and the _stateClass comment still said
  'yellow in every fill mode'. Both now state the two-part contract: the
  state predicate is the glow-pool condition; the renderer keeps the badge
  only where the spot is not drawn.
2026-07-29 22:16:26 +03:00
Matysh 1be79a1427 v1.52.1 2026-07-29 21:58:05 +03:00
Matysh d7c20ff6a5 v1.52.1: the v1.52.0 review (HP-1520-01/-02, HP-1513-01)
- HP-1520-01: the glow layer is hidden in the plan editor, but the yellow
  suppression still fired there — a lit lamp had NEITHER indicator. The
  gate now equals the layer's visibility (disp.fill === 'glow' &&
  !this._markup), so the badge returns exactly where the spot is absent.
- HP-1513-01: the static card ignored marker.size and marker.angle — the
  same stored marker looked different on the two cards. It mirrors
  --dev-scale and the icon rotation now; geometry only, no live dressing.
- HP-1520-02: TESTING/UX-MODES still demanded the removed RGB icon tint,
  and the lightC comment described the old use. All three brought to the
  v1.52.0 contract.

smoke_light_badges grew the editor-mode vectors; new
smoke_size_angle_parity asserts the x3 ratio inside each card (absolute px
are incomparable across containers) and rotation on both. Inventory:
147 / 51 / 43 / 71.
2026-07-29 21:55:13 +03:00
Matysh fa59767c69 v1.52.0 2026-07-29 21:34:23 +03:00
Matysh 60d6167ecd v1.52.0: one look for light sources, whatever flipped them
Owner's rule, agreed 2026-07-29 after a field report (a lamp turned off by
tap looked different from one turned off by the wall switch):

- a lamp's colour lives ONLY in its glow. The v1.27 RGB tint of the icon,
  border and shadow is deleted — that tint was the fork: with colour data
  the lamp rendered dark-with-coloured-icon, without it plain yellow, and
  the same lamp crossed the fork depending on how it was switched.
- in glow fill the indicator IS the spot: a source's badge stays standard,
  lit or not (litLightEntity — the exact condition that casts the spot —
  gates the suppression, so a lit socket keeps its yellow even in glow).
- in every other fill a lit source is plain yellow, like a heating TRV.
- icon morphing stays everywhere; the ripple colour still falls back to the
  light colour (both explicitly confirmed by the owner).

smoke_light_badges covers the whole table (8 assertions); smoke_rgb_alarm
re-asserted: no rgb class, lit lamp yellow, ripple fallback keeps the
colour. README colour language updated. Inventory: 147 / 51 / 43 / 70.
2026-07-29 21:31:30 +03:00
Matysh 110fabd038 v1.51.3 2026-07-29 21:08:58 +03:00
Matysh 3456706ef6 v1.51.3: the size multiplier reaches the glyph (user report) 2026-07-29 21:06:10 +03:00
Matysh 1310c84a32 device icon: the size multiplier scales the glyph, not just the badge
User report via the owner: change a marker's size and the icon stays at its
default size — a big empty box around a small glyph. The badge, ripple and
value badges all derive from --dev-size (base size x per-device multiplier),
but --mdc-icon-size was pinned to the BASE --icon-size, so the multiplier
never reached the glyph. One calc argument: --dev-size.

New smoke_icon_scale: at size 3 the glyph grows with the badge and keeps
the 0.62 proportion. Inventory: 147 / 51 / 43 / 69.
2026-07-29 21:03:12 +03:00
Matysh 94563d2a0c v1.51.2
Validate / hacs (push) Failing after 25s
Validate / hassfest (push) Failing after 18s
Validate / frontend (push) Successful in 1m38s
Validate / smoke (push) Failing after 8m4s
Validate / backend (push) Failing after 6m43s
2026-07-29 18:03:15 +03:00
Matysh 5615afa33b v1.51.2: the v1.51.1 review (HP-1511-01, HP-1511-02)
- HP-1511-01: defaultPositions ran over different rosters — the full card
  reserves grid cells for hidden devices, the static card compacted them
  away, so an undragged marker sat in different spots on the two cards. The
  static card feeds spaceDevs (hidden included) to the shared grid and
  renders devs (visible) — exactly the split HP-1510-01 introduced for LQI.
- HP-1511-02: a hidden ripple-display marker rendered as an icon-less
  inactive pulse. A ghost drops the display dressing entirely: ripple
  presentation off, noicon off, base icon on, whatever marker.display says.

smoke_hidden_flag: the weak 'has icon OR noicon' assertion is gone — every
ghost must carry a base icon; new autoGridParity vector (vb-coordinate
comparison, the cards render in different view systems) and a ripple-ghost
vector. The demo stub got a connection.subscribeEvents so the static card's
module-level config cache can be invalidated between in-test cards.
2026-07-29 18:00:22 +03:00
Matysh 4083e14247 docs: everything caught up with v1.51.1
A sweep of every document against the shipped behaviour:

- README en+ru: the space dialog no longer claims a background is mandatory
  (draw-by-hand and the saved-plans picker exist; the canvas is square);
  'Show all devices' sections rewritten for the hide-flag world — the
  checkbox, 'Show hidden' ghosts, LQI-yes/light-no; troubleshooting updated.
- ARCHITECTURE: the config schema block still described v1.3 —
  aspect/device_overrides/virtual_devices/1000x1000-per-aspect/legacy-bundle
  fallback, all long gone. Rewritten to the current shape (square canvas,
  markers with hidden, filter_seeded, quotas, signed urls). The WS table
  dropped houseplan/file/set (removed in v1.10.0) and gained
  geometry/repair, layout/delete, files/migrate, files/cleanup,
  content/sign, the plans/list cap.
- UX-MODES: the Devices-tab tool list names the checkbox and the local
  'Show hidden' instead of the retired shared show-all.
- ROADMAP: repair-issues, system_health, floors import, data icon rules,
  click actions, theming and JSON i18n were done releases ago — checked off
  with their versions; the HACS pointer is #9004 (bot closed #8995).
- STATUS: SSH port is 22222 and the HA config root is
  /mnt/data/supervisor/homeassistant (/config does not exist there); the
  key lives in houseplan/.secrets; the PAT note reflects the fine-grained
  token; the feature surface gained the v1.42-v1.51 era.
- DEVELOPMENT: deploy instructions with the real port, path and cache
  busting.
- The owner's product description (user folder) refreshed the same way:
  square canvas, hide flags, the yellow principle.
2026-07-29 15:51:07 +03:00
Matysh 518d72fb74 v1.51.1 2026-07-29 15:16:59 +03:00
Matysh fc95a1f09b v1.51.1: the v1.51.0 review (HP-1510-01, HP-1510-02)
- HP-1510-01: the static card's visibility filter had quietly become its
  aggregation filter — the same room showed different Zigbee health on the
  two cards. Two lists now: aggregation (room LQI, temp) sees every device
  of the space including hidden ones, rendering sees visible only. Light
  fill keeps excluding hidden through areaLights itself, so the contract
  stays exactly as agreed: hidden counts toward signal, casts no light.
- HP-1510-02: the ghost suppressed state colors but still painted value
  text, temperature, humidity, the LQI badge and the state-morphed icon.
  All live numbers are gated on d.hidden now — a ghost is the base icon and
  the name, nothing else.

smoke_hidden_flag grew both audit vectors: the 42 kW value-display ghost
renders no numbers, and a room whose only Zigbee devices are hidden paints
the identical lqi fill on the full and the static card.
2026-07-29 15:14:06 +03:00
Matysh 4e736d49a3 v1.51.0 2026-07-29 14:07:45 +03:00
Matysh aa3c379540 v1.51.0: explicit hide flags, the yellow principle, phone editor gestures, room button
The dev batch since v1.50.4, released as one:
- hiding is a per-device checkbox seeded once from the old filter
  (docs/FILTERING.md); blue ghosts under a local 'Show hidden' toggle
- yellow = doing its main job now; TRVs glow by hvac_action, service
  switches can no longer become a device's primary
- pinch/pan gestures in every editor on touch
- the room settings button: visual centre (inscribed circle + centroid
  pull), icon-derived size, zooms with the plan; metrics visible in the
  plan editor

Inventory: 147 frontend / 51 pure / 43 harness / 68 smokes.
2026-07-29 14:04:49 +03:00
Matysh fa15598e67 room settings button: centroid pull breaks the plateau tie
The inscribed-circle criterion is FLAT along the long axis of any elongated
room — every midline point fits the same circle — and a plain argmax took
the first plateau sample: left of centre on the owner's kitchen-living
room, above centre in the sauna. The score now subtracts a soft pull
toward the area centroid (shoelace-weighted): on the plateau the nearest-
to-centroid point wins, while real clearance differences still dominate,
so the point never wanders into a thinner limb of an L.

Verified on a replica of the owner's floor: kitchen slab centre within a
few units, sauna dead-centre both axes. Units: wide and tall rectangles
centre on both axes; the L keeps to its slab near the centroid x.
2026-07-29 14:00:52 +03:00
Matysh 9eec856d50 room settings button: the VISUAL centre for L-shaped rooms
The owner's kitchen-living room is L-shaped, and interiorPoint() only
promises 'somewhere inside' — the button sat near the seam, visibly
off-centre. poleOfInaccessibility() (largest inscribed circle, grid search
plus one refinement pass) puts it in the middle of the widest open space:
the slab of an L, the exact centre of a rectangle or square. Cached per
poly array in a WeakMap — the memoized model keeps the arrays stable, so
the search runs once per geometry, not per render.

Unit: square -> centre; thick-slab L -> mid-slab, always inside.
2026-07-29 13:55:03 +03:00
Matysh 0bb9282edd room settings button: half size, dead-centred on the room
Owner's follow-up: the button was too large — height is now 0.77 of the
icon-size unit (half the previous), width follows through the derived font
and padding. The below-centre offset is gone: the anchor is the room's
geometric centre on BOTH axes, verified against the polygon centroids in vb
coordinates (exact match on all four demo rooms). Still zooms with the plan.

smoke_feedback_v2's gear assertions pinned the 2026-07-27 feedback sizes
(font >= 10px, box >= 18x40) — superseded by the owner's half-size order;
the contract is now 'sized from the device icon, clickable, opens the
dialog'.
2026-07-29 13:47:20 +03:00
Matysh 09f4dc4115 room settings button: room-centred, icon-sized, zooms with the plan
Owner's spec: the button is no longer glued to the room NAME (which the user
can drag anywhere) — it anchors to the geometric centre of the ROOM
(interiorPoint for polygons, so an L-shaped room gets a point actually
inside it), one button-height below centre so it never covers the name,
whose default position is that same centre. Height is 70% of a device icon
box, and since --icon-size already rescales with the view, the button zooms
with the plan instead of keeping a constant screen size (verified: x2.2 zoom
-> x2.20 button). The small metric rows under the room name (temperature,
humidity, signal, lights) now render in the plan editor too — they used to
be view-mode only.

smoke_room_cards updated: plainInPlan now asserts metrics ARE present in the
editor (the old assertion pinned the old behaviour), plus gearDetached.
2026-07-29 13:39:46 +03:00
Matysh 2551b4ea0e hidden devices: blue ghosts, no live-state paint
A hidden device and an unavailable one both rendered as translucent dark —
indistinguishable at a glance, and a lit hidden lamp still glowed yellow
through the ghost (owner's report). A ghost is CONFIGURATION, not status:

- blue dashed ghost (accent-tinted, color-mix with an rgba fallback for old
  WebViews), clearly apart from the grey 'unavailable' icon;
- no state classes, no RGB tint, no alarm pulse, no active ripple on hidden
  devices — the only thing a ghost says is 'I am hidden, click to unhide'.

smoke_hidden_flag grew two assertions: the ghost carries no state classes
and is blue/dashed.
2026-07-29 11:49:37 +03:00
Matysh 694e1e9a3b filtering: hiding is an explicit per-device flag (docs/FILTERING.md)
Agreed with the owner: whether a device is on the plan is a CHECKBOX
('Hide device from plan', every kind incl. virtual), not a runtime
algorithm. The old filter survives only as the SEEDER of those flags.

- marker.hidden is the flag; hidden devices are BUILT (room LQI counts
  them — owner's decision) but rendered only in the device editor with
  'Show hidden' on, ghosted. They cast no glow and no light fill: an
  invisible device casts no visible light (owner's decision).
- seedHiddenBindings(): non-physical devices (excluded domains, Group,
  scene, bridge, myheat children, grouped lamps) in bound areas WITHOUT a
  marker. The editing client materialises them into hidden:true stub
  markers, sets settings.filter_seeded, retires settings.show_all, and
  strips fresh-hidden ids from the red-dot list. Unticking the checkbox
  keeps a hidden:false marker — the seeder never revisits a marked device,
  so the user's decision is final. New non-physical devices hide silently;
  physical ones keep the red-dot flow.
- legacy configs (no filter_seeded) keep the OLD behaviour verbatim —
  runtime filter, shared show_all, hidden-means-gone — until an editing
  client materialises them, so a read-only tablet never sees a half-state.
- 'Show all' is renamed 'Show hidden' and is LOCAL to the tab; the shared
  settings.show_all retires with the runtime filter.
- 'Remove from plan' disappears for auto/entity devices (the checkbox is
  the way); a virtual device's Delete remains a real deletion.
- docs/FILTERING.md is the source of truth for the mechanism.

Tests: seeder/seeded/legacy/lights units (146), smoke_hidden_flag with 12
assertions (68 smokes). Inventory: 146 / 51 / 43 / 68.
2026-07-29 11:35:35 +03:00
Matysh 996a7442ec yellow means working: one principle for the glowing icon
Validate / hacs (push) Failing after 6s
Validate / hassfest (push) Failing after 7s
Validate / frontend (push) Failing after 1m33s
Validate / smoke (push) Skipped
Validate / backend (push) Failing after 6m17s
Research on the owner's install (verified live): the radiator heads that
glowed yellow were the ones with SCALE PROTECTION on, and the ones actually
heating stayed dark. Cause: the primary-entity search ran domains outside
tiers, and switch outranks climate — so a vendor's config switch (anti
scaling, child lock) became the device's primary, driving the color, the
icon morphing and tap-toggle alike.

The principle now: yellow = the device is doing its main job RIGHT NOW.

- primaryEntity: tiers outside, domains inside — a service entity never
  beats the visible main function; a hidden lamp still beats a visible
  config switch (grouped lights), and a plug's switch stays primary.
- climate joins the state table: yellow by hvac_action (heating/cooling/
  drying/fan) — 'which radiators are heating', not 'enabled for winter';
  the coarser state is only a fallback when the integration reports no
  action.
- one truth for light: litLightEntity() is asked by BOTH the glow pool and
  the icon color, in every fill mode — the pool and the icon can no longer
  disagree. The 'is a light source' flag keeps counting controls first.
- README (en+ru): the color table, in words.

Tests: TRV + plug primary units, litLightEntity unit, smoke_yellow_principle
(heating yellow / idle dark / off dark / fallback / lit-light wins / forced
source). Inventory: 142 / 51 / 43 / 67.
2026-07-29 11:07:34 +03:00
Matysh 098a147f87 editor: gestures work on touch — pinch zooms, a moving finger pans
The stage pointerdown bailed out whenever _markup was set, so in the plan
editor no pointer was ever tracked: no pinch, no pan — on a phone the plan
could not be zoomed or moved at all (owner's report). But drawing is
CLICK-based, so the two coexist: a finger that moves pans (and suppresses
the synthesized click so the release feeds no tool), two fingers pinch, a
clean tap still draws. Pointers that start on labels, handles, markers or
buttons stay out — those run their own drags. The tool preview keeps
following the tracked finger.

New smoke: smoke_editor_gestures (pinch in plan mode, pan without drawing,
tap still draws). Inventory: 140 / 51 / 43 / 66.
2026-07-29 10:00:44 +03:00
Matysh 14f7c06bf4 v1.50.4
Validate / hacs (push) Failing after 8s
Validate / hassfest (push) Failing after 10s
Validate / frontend (push) Successful in 1m42s
Validate / backend (push) Failing after 6m26s
Validate / smoke (push) Failing after 11m53s
2026-07-29 08:35:54 +03:00
Matysh 9f36b39379 v1.50.4: one model builder for both cards (HP-1503-01)
The full card's _buildModel() was a hand-copied twin of spaceModels(), and
the twin missed the legacy-store fallbacks v1.50.3 gave the shared builder —
the same broken store rendered recovered in the static card and as
viewBox='0 0 0 0' with negative-width rects in the main one. The divergence
of the duplicates IS the bug, so the duplicate is gone: the full card calls
spaceModels() and only swaps the raw plan url back in (its signing flow must
not bake a signed url into a memoized model — 2026-07-27).

New smoke_legacy_geometry runs the audit's exact vector (zero viewport +
negative rect) through both models and both DOM trees and asserts parity:
full-canvas fallback, normalised rectangle, no negative SVG attributes.
Inventory: 140 / 51 / 43 / 65.
2026-07-29 08:33:10 +03:00
Matysh 9da96abb05 v1.50.3 2026-07-29 08:18:47 +03:00
Matysh df65d25348 v1.50.3: sizes are not coordinates (HP-1502-01)
The ±4 bound from v1.50.2 measured view_box[2:4] and room w/h with the same
ruler as coordinates, so zero and negative sizes still passed the schema —
and viewBox='0 0 0 0' draws nothing on every client, with the static card
computing aspect-ratio: 0 / 0 on top. _EXTENT now requires strictly positive
sizes with a floor of one thousandth of the canvas (1 render unit — far
below any real room, keeps the maths finite); coordinates stay allowed to be
negative, a crop origin legitimately sits past the edge.

Defensive layer for stores that already hold a broken viewport: spaceModels
falls back to the whole canvas — both cards render from that model, so both
get the fallback — and a legacy rectangle with a negative size reads as the
same rectangle drawn from the other corner.

Also: the room settings button is the bottom row of the room card, and the
room name renders in the same spot in view and plan modes (owner's request,
committed earlier on dev).
2026-07-29 08:16:12 +03:00
Matysh 8db6b2673f room card: the name never moves, the settings button sits at the bottom
The label box is centred on the room point, so anything taking part in its
layout SHIFTS THE NAME: entering the plan editor pushed it down by the gear
button's height (owner's report). The name is the anchor now — the metrics
and the gear button hang below it as absolutes, outside the centring math —
so the name renders in exactly the same spot in view mode and in the editor,
and the settings button is the bottom row of the card. Verified by measuring
the name's vb-coordinates in both modes: identical to the pixel.
2026-07-29 08:12:51 +03:00
Matysh c9030af900 v1.50.2 2026-07-29 07:33:01 +03:00
Matysh 5392dadeaa v1.50.2: the v1.50.1 review (HP-1501-01, HP-1501-02)
- HP-1501-01: v1.50.1 bounded layout positions and left room rectangles,
  polygon vertices, view_box and opening coordinates on bare _finite — the
  same absurd-magnitude failure, one schema over. _GEOM (±4) covers them all
  now, opening angles get ±360. And because a store may already hold such a
  vertex from before the door existed, contentBounds applies its canvas
  envelope to room geometry exactly as it does to device positions: the
  point renders where it is, the frame ignores it, a space of nothing but
  absurd points falls back to the whole canvas.
- HP-1501-02: a repair matching zero positions answered ok/moved:0 and
  replaced the one-deep backup with an empty one — a typo right after
  repairing the wrong space destroyed the promised way back. Empty match is
  nothing_to_repair now: no write, no revision bump, backup intact.

Old test fixtures carried view_box [0,0,100,100] from the render-unit days;
they now use the normalised box the product actually stores.
2026-07-29 07:30:22 +03:00
Matysh aa53b33dd6 v1.50.1
Validate / smoke (push) Failing after 13m53s
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 1m34s
Validate / backend (push) Failing after 6m46s
2026-07-29 01:41:45 +03:00
Matysh a8ce6020f4 v1.50.1: the v1.50.0 review (HP-1500-01..03)
- HP-1500-02: the stage budget was the absolute document coordinate, so any
  tall dashboard content before the card was billed as header and the stage
  collapsed to 0px. Measure our own chrome relative to the card plus a
  bounded (<=120px) allowance for what the viewport keeps above us; re-measure
  on window resize, remove the listener in disconnectedCallback.
- HP-1500-03, both layers: contentBounds opens a near-zero axis (< ~an icon)
  up to a 200-unit floor and ignores extra points outside a canvas envelope
  (-25%..125%) for FRAMING purposes only; the server bounds layout coordinates
  to +-4 — any finite float used to pass, and one 1e100 hid the plan from
  every viewer. A thin real room keeps its tight frame; the gate sensor past
  the edge still stretches it.
- HP-1500-01: no automatic double-transform — a correct layout and a stranded
  one are indistinguishable, and guessing wrong corrupts good data. Explicit
  admin command houseplan/geometry/repair: dry_run previews, the backup rides
  the same store write, undo restores, and routine layout writes now preserve
  unrelated store keys instead of eating the backup.

Tests: contentBounds guards (unit), layout coordinate bounds + repair
lifecycle (harness), card-below-content smoke. Inventory: 139 / 49 / 42 / 64.
2026-07-29 01:39:10 +03:00
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
73 changed files with 6178 additions and 778 deletions
+9 -3
View File
@@ -75,6 +75,12 @@ Key advantages in short:
- **Automatic device placement.** Outline a room and bind it to a Home Assistant area — the devices of that area appear on the plan by themselves.
- **Manual additions of your own.** Any device, group or even a "virtual" point can be placed on the plan manually, with a name, icon, model, link and an attached PDF manual.
- **Live states.** Temperature, Zigbee signal strength, on/off, open/closed — everything updates in real time.
Icon colors follow one principle — **yellow means the device is doing its main job right now**:
a light is shining, a socket is powering, a fan is spinning, media is playing, a vacuum is
cleaning, a radiator valve is actually heating (not merely enabled). Orange = open / unlocked.
A pulsing red ring = an emergency (leak, smoke, gas). An RGB bulb's colour lives in its glow
spot (glow fill), where the spot itself is the on/off indicator and the badge stays standard.
A translucent icon = unavailable. Dark = idle.
- **Crisp zoom.** Zooming in does not "blur" the picture: the plan, labels and icons remain vector-sharp at any scale.
---
@@ -156,7 +162,7 @@ for each floor (names prefilled, a plan image is asked for one by one; any floor
![Empty plan — prompt to add a space](docs/images/02-onboarding-empty.png)
In the dialog, set a **name** (for example, "1st floor") and **upload a background** — a floor-plan image in SVG, PNG or JPG format. Both fields are required: without a plan the "Save" button stays disabled.
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is always square; an image keeps its own proportions and is centred inside it.
![Space creation dialog](docs/images/03-space-dialog.png)
@@ -199,7 +205,7 @@ walls** (it slides around corners too), and a **double click opens its propertie
As soon as you save a room bound to an area, **the devices of that area are automatically laid out inside the outline**. These are the same devices shown on the **Settings → Devices → (filtered by the room)** page — only the meaningful ones, without service records, bridges and duplicates.
By default only meaningful devices make it onto the plan — service records, bridges and duplicates are filtered out. If you need to see **absolutely all** devices of the area, enable the **👁 "Show all devices"** button in the header.
By default only meaningful devices make it onto the plan: non-physical ones (service records, bridges, scenes, individual lamps folded into a light group) arrive with the **"Hide device from plan"** checkbox already ticked. The checkbox is yours from then on — every device dialog has it, virtual devices included. To see and un-hide them, open the device editor and press **"Show hidden"**: hidden devices appear as translucent blue ghosts, a click opens the dialog. Hidden devices still count toward the room's Zigbee signal, but cast no light.
From here on you can just use the plan: clicking an icon opens the device card with the model, link and a button to jump into Home Assistant.
@@ -281,7 +287,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 (the "Hide device from plan" checkbox — set automatically for bridges, scenes and other non-physical records) — open the device editor, press **"Show hidden"** and untick the box in its dialog.
**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.
+9 -3
View File
@@ -74,6 +74,12 @@ House Plan показывает ваш умный дом так, как он в
- **Автоматическое добавление устройств.** Обвели комнату и привязали её к зоне Home Assistant — устройства этой зоны сами появляются на плане.
- **Ручное добавление своих.** Любое устройство, группу или даже «виртуальную» точку можно поставить на план вручную, задать имя, иконку, модель, ссылку и приложить PDF-инструкцию.
- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени.
Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**:
лампа светит, розетка подаёт, вентилятор крутится, медиа играет, пылесос убирает, термоголовка
реально греет (а не просто включена). Оранжевый = открыто / не заперто. Пульсирующее красное
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
Полупрозрачный значок = недоступно. Тёмный = покой.
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
---
@@ -157,7 +163,7 @@ title: План дома
![Пустой план — предложение добавить пространство](docs/images/02-onboarding-empty.png)
В диалоге задайте **название** (например, «1 этаж») и **загрузите подложку** — картинку плана этажа в формате SVG, PNG или JPG. Оба поля обязательны: без плана кнопка «Сохранить» неактивна.
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст всегда квадратный; картинка сохраняет свои пропорции и центрируется внутри него.
![Диалог создания пространства](docs/images/03-space-dialog.png)
@@ -200,7 +206,7 @@ title: План дома
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
По умолчанию на план попадают только осмысленные устройства — служебные записи, мосты и дубликаты отфильтрованы. Если нужно видеть **вообще все** устройства зоны, включите в шапке кнопку **👁 «Показать все устройства»**.
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) приходят с уже установленной галкой **«Скрыть устройство с плана»**. Дальше галка принадлежит вам — она есть в диалоге каждого устройства, включая виртуальные. Чтобы увидеть и вернуть скрытые, откройте редактор устройств и нажмите **«Показать скрытые»**: скрытые отобразятся полупрозрачными синими призраками, клик открывает диалог. Скрытые устройства учитываются в Zigbee-сигнале комнаты, но света не дают.
Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant.
@@ -283,7 +289,7 @@ title: План дома
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто курированием (мосты, служебные, дубликаты) — включите в шапке кнопку **👁 «Показать все устройства»**.
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто (галка «Скрыть устройство с плана» — для мостов, сцен и прочих нефизических записей она ставится автоматически) — откройте редактор устройств, нажмите **«Показать скрытые»** и снимите галку в его диалоге.
**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок.
+53 -5
View File
@@ -20,9 +20,10 @@ 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, get_data
from .store import HouseplanConfigEntry, create_data
_LOGGER = logging.getLogger(__name__)
@@ -99,6 +100,46 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
module_url, module_url,
)
# One-time move to the square canvas (v1.48.0). Coordinates used to be
# normalised against a per-space aspect ratio; the canvas is now always
# square and a plan is centred inside it. Nothing about the drawing changes
# — the box is padded and the numbers re-expressed against it.
# The two stores are written independently, and the lock is no transaction:
# a crash between the writes used to leave the config in square coordinates
# with the layout still in the old ones — permanently, because the config
# write had already deleted the `aspect` fields the layout half needed
# (HP-1490-01). So the intent is made durable FIRST, in the layout store,
# and each half carries its own trigger with its own write: the config half
# removes `aspect`, the layout half removes the saved intent. Whatever
# half is missing after a crash, the next start finishes exactly it.
async with data.write_lock:
stored = await data.config_store.async_load() or {}
cfg = stored.get("config")
lay_stored = await data.store.async_load() or {}
layout = lay_stored.get("layout") or {}
pending = {
str(k): v for k, v in (lay_stored.get("geom_pending") or {}).items()
}
merged = {**pending, **pending_from_config(cfg)}
if merged:
lay_rev = int(lay_stored.get("rev", 0))
if merged != pending: # 1. the durable intent, before anything moves
await data.store.async_save(
{"layout": layout, "rev": lay_rev, "geom_pending": merged}
)
rev = int(stored.get("rev", 0))
if cfg and migrate_config(cfg): # 2. the config half
rev += 1
await data.config_store.async_save({"config": cfg, "rev": rev})
migrate_layout(layout, merged) # 3. the layout half + intent cleared
await data.store.async_save({"layout": layout, "rev": lay_rev + 1})
_LOGGER.info(
"House Plan: migrated %s space(s) to the square canvas", len(merged)
)
# only once both halves are durable — a client refetching on this
# event must never see one migrated half and one old one
hass.bus.async_fire("houseplan_config_updated", {"rev": rev})
await async_check_plan_files(hass, entry)
# Scheduled collection of everything nobody ended up referencing.
@@ -118,16 +159,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
files_dir = Path(hass.config.path(FILES_DIR))
plans_dir = Path(hass.config.path(PLANS_DIR))
try:
data = get_data(hass)
if data is None: # entry unloaded — nothing authoritative to compare against
await hass.async_add_executor_job(sweep_upload_temps, files_dir)
return
# `data` from the closure, NOT get_data(hass): during
# async_setup_entry the entry is still SETUP_IN_PROGRESS, so
# async_loaded_entries() does not list it and the lookup returned
# None. The startup pass then silently degraded to removing
# streaming temporaries only, and the real collection waited a full
# day — restarting more often than that meant it never ran at all
# (HP-1462-01). The callback is unregistered with the entry, so
# closing over its runtime data matches the lifecycle exactly.
async with data.write_lock:
stored = await data.config_store.async_load() or {}
cfg = stored.get("config") or {}
def _collect() -> int:
n = sweep_upload_temps(files_dir)
# same config on both sides: nothing is superseded, so this
# only ever collects what the shared rules call abandoned
n += collect_attachments(files_dir, cfg, cfg)
n += collect_plans(plans_dir, cfg, cfg)
return n
@@ -138,6 +185,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
except Exception: # noqa: BLE001 — housekeeping must never fail a setup
_LOGGER.exception("House Plan: sweeping unreferenced files failed")
data.sweep = _sweep
await _sweep()
entry.async_on_unload(
async_track_time_interval(hass, _sweep, timedelta(hours=24))
+22 -1
View File
@@ -17,14 +17,35 @@ CONTENT_URL = "/api/houseplan/content"
# way to tell which paths were dropped (review R2-2).
MAX_SIGN_PATHS = 200
# Nothing is ever deleted for being old (docs/SCOPE.md), so growth has to be
# stopped at the door instead. These bound the whole store, not one request: by
# default any authenticated user may upload, and a per-request cap of 8/50 MB
# says nothing about how many requests there are (HP-1470-01).
MAX_PLANS_BYTES = 256 * 1024 * 1024
MAX_PLANS_FILES = 200
# How many the picker asks for at once — newest first.
MAX_PLANS_LISTED = 60
MAX_FILES_BYTES = 1024 * 1024 * 1024
MAX_FILES_COUNT = 1000
# Refuse to write when the disk is nearly full: filling the config partition
# breaks .storage, the recorder and backups, not just this card.
MIN_FREE_BYTES = 512 * 1024 * 1024
# An uploaded plan that no accepted configuration references is collected only
# once it is this old. Age is a race guard, not a policy: a plan uploaded
# seconds ago may belong to another client's transaction that has not written
# its configuration yet (review R3-1).
PLAN_ORPHAN_TTL_S = 3600
# Kept for compatibility with anything reading it; the collectors no longer use
# a long grace at all. Every attempt to age files out ended badly — first by
# deleting detached plans, then by racing the save that was about to reference a
# retried upload. What is left is deliberately simple: files go when the user's
# action says so, plus staging folders after PLAN_ORPHAN_TTL_S.
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.46.2"
VERSION = "1.53.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
+15 -2
View File
@@ -20,9 +20,12 @@ except ImportError: # older HA versions
KEY_HASS = "hass" # type: ignore[assignment]
from homeassistant.core import HomeAssistant
from .const import CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, PLANS_DIR
from .const import (
CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, MAX_FILES_BYTES,
MAX_FILES_COUNT, PLANS_DIR,
)
from .auth import may_write
from .plans import TMP_PREFIX, reserve_filename
from .plans import TMP_PREFIX, QuotaError, check_quota, reserve_filename
from .validation import (
FILE_EXTENSIONS,
MAX_FILE_BYTES,
@@ -204,6 +207,16 @@ class HouseplanUploadView(HomeAssistantView):
return web.json_response({"error": "no_file"}, status=400)
tmp_path = temps[0]
try:
await hass.async_add_executor_job(
check_quota, files_root, tmp_path.stat().st_size,
MAX_FILES_BYTES, MAX_FILES_COUNT,
)
except QuotaError as err:
_LOGGER.warning("House Plan upload refused: %s", err.detail)
return web.json_response({"error": err.reason, "detail": err.detail}, status=507)
except OSError:
pass
target_dir = files_root / marker_id
safe_name = filename
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.46.2"
"version": "1.53.0"
}
+130 -21
View File
@@ -14,7 +14,7 @@ import time
from pathlib import Path
from typing import Any
from .const import PLAN_ORPHAN_TTL_S
from .const import MIN_FREE_BYTES, PLAN_ORPHAN_TTL_S
from .validation import MAX_FILENAME, PLAN_EXTENSIONS, sanitize_filename
_LOGGER = logging.getLogger(__name__)
@@ -121,15 +121,25 @@ def collect_attachments(
) -> int:
"""The same commit-scoped rule as `collect_plans`, for marker attachments.
A file the old revision referenced and the new one does not was superseded
by this commit and goes. Anything else unreferenced is an upload that was
never saved — a cancelled dialog, a rejected write — and waits out
PLAN_ORPHAN_TTL_S first, because a fresh one may belong to a dialog the user
still has open. Never raises: it runs behind a durable write.
A file the old revision referenced and the new one does not, whose marker
still exists, was removed on purpose — the dialog has a trash button and
promises nothing. It goes. Everything else is kept, except a staging folder
(`up_*`), which by construction only ever holds an upload from a dialog that
was never saved: those go after PLAN_ORPHAN_TTL_S. Never raises: it runs
behind a durable write.
"""
new_refs = attachment_refs(new_cfg)
old_refs = attachment_refs(old_cfg)
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
# Removing an attachment from a device that still exists is the user saying
# "drop this one" — a trash button, no promise that anything is kept. A
# device that is GONE is a different transition, and its files follow the
# same rule as a deleted space's plan: kept.
live_markers = {str(m.get("id")) for m in (new_cfg or {}).get("markers") or []}
# Same distinction as for plans. A staging folder (`up_*`) is different: it
# only ever holds an upload from a dialog that was never saved, so the short
# rule is exactly right there even on the timer.
now_s = time.time() if now is None else now
staging_cutoff = now_s - PLAN_ORPHAN_TTL_S
removed = 0
try:
folders = sorted(p for p in files_dir.iterdir() if p.is_dir()) if files_dir.is_dir() else []
@@ -138,6 +148,9 @@ def collect_attachments(
return 0
removed += sweep_upload_temps(files_dir, now)
for folder in folders:
# A staging folder only ever holds an upload from a dialog that was never
# saved — unambiguous, so an hour is right, and no device owns it.
staging = folder.name.startswith("up_")
try:
items = sorted(p for p in folder.iterdir() if p.is_file())
except OSError:
@@ -146,12 +159,19 @@ def collect_attachments(
rel = f"{folder.name}/{item.name}"
if rel in new_refs:
continue
try:
stale = item.stat().st_mtime < cutoff
except OSError:
stale = False
if rel not in old_refs and not stale:
continue
dropped = rel in old_refs and folder.name in live_markers
if not dropped:
if not staging:
# Same rule as for plans: not asked for, so kept. A file in
# a device's folder that the device does not list is an
# upload whose save was rejected — and ageing those out
# raced the retry that was about to reference them.
continue
try:
if item.stat().st_mtime >= staging_cutoff:
continue
except OSError:
continue
try:
item.unlink()
removed += 1
@@ -169,6 +189,56 @@ def collect_attachments(
return removed
class QuotaError(Exception):
"""A store limit would be exceeded. Carries what to tell the user."""
def __init__(self, reason: str, detail: str) -> None:
super().__init__(detail)
self.reason = reason
self.detail = detail
def dir_usage(path: Path) -> tuple[int, int]:
"""(bytes, files) below `path`, ignoring what we cannot read."""
total = count = 0
if not path.is_dir():
return 0, 0
for item in path.rglob("*"):
try:
if item.is_file():
total += item.stat().st_size
count += 1
except OSError:
continue
return total, count
def check_quota(path: Path, incoming: int, max_bytes: int, max_files: int) -> None:
"""Raise QuotaError unless `incoming` more bytes fit.
Deliberately not an age rule. Files are never removed for getting old — that
cost real plans twice — so the limit sits where a decision is being made
anyway: at the moment somebody asks to store something new.
"""
import shutil
used, count = dir_usage(path)
if count + 1 > max_files:
raise QuotaError("too_many_files", f"{count} files already stored, the limit is {max_files}")
if used + incoming > max_bytes:
raise QuotaError(
"quota_exceeded",
f"{(used + incoming) // 1024 // 1024} MB would be stored, the limit is "
f"{max_bytes // 1024 // 1024} MB",
)
try:
free = shutil.disk_usage(str(path if path.is_dir() else path.parent)).free
except OSError:
return
if free - incoming < MIN_FREE_BYTES:
raise QuotaError("low_disk_space", f"only {free // 1024 // 1024} MB free on the disk")
def plan_basename(url: Any) -> str:
"""File name a stored plan_url points at ('' when there is none)."""
if not isinstance(url, str) or not url:
@@ -186,6 +256,14 @@ def plan_refs(cfg: dict[str, Any] | None) -> set[str]:
return out
def plan_by_space(cfg: dict[str, Any] | None) -> dict[str, str]:
"""space id -> the plan file it references ('' when it has none)."""
return {
str(sp.get("id")): plan_basename(sp.get("plan_url"))
for sp in (cfg or {}).get("spaces") or []
}
def is_plan_file(name: str) -> bool:
"""Does this look like a plan we wrote: <space>.<ext> or <space>.<token>.<ext>?"""
parts = name.split(".")
@@ -212,7 +290,7 @@ def collect_plans(
* a file the OLD configuration referenced and the new one does not was
authoritative and has been superseded — remove it;
* any other unreferenced plan file is a rejected or abandoned upload, and
is removed only once PLAN_ORPHAN_TTL_S has passed: a fresh one may
is KEPT — see the rule above; only a staging folder ages out: a fresh one may
belong to a transaction that has not committed yet.
Never raises: the configuration is already stored by the time this runs, so
@@ -220,7 +298,29 @@ def collect_plans(
"""
new_refs = plan_refs(new_cfg)
old_refs = plan_refs(old_cfg)
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
# A commit knows what it superseded. The timer only knows what nothing
# points at *right now*, and for a plan that is a reversible state: the
# editor detaches the image when a space switches to "draw" and says the
# file stays on disk. So the scheduled pass keeps anything belonging to a
# space that still exists, and waits a month for the rest.
# A space with NO plan_url has had its image detached — reversible, and the
# editor promises the file stays. A space that HAS one is different: any
# other file of its own is a superseded or rejected upload, so the short
# rule is right for those. Getting this distinction wrong (protecting
# nothing) destroyed two detached plans on 2026-07-28.
# The short rule fits exactly one case: a space that HAS a plan, where any
# other file of its own can only be a superseded or rejected upload.
old_by_space = plan_by_space(old_cfg)
new_by_space = plan_by_space(new_cfg)
# A file that left the configuration tells us nothing on its own: replacing a
# plan, detaching one and deleting a space all look identical from
# `old_refs - new_refs`. Only the first is a deletion the user asked for
# (HP-1465-01 — the guards below were written and then never reached,
# because the code decided "superseded" before asking why).
replaced = {
name for space, name in old_by_space.items()
if new_by_space.get(space) and new_by_space[space] != name
}
removed = 0
try:
items = sorted(plans_dir.iterdir()) if plans_dir.is_dir() else []
@@ -233,12 +333,21 @@ def collect_plans(
for item in items:
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
continue
superseded = item.name in old_refs
try:
stale = item.stat().st_mtime < cutoff
except OSError:
stale = False
if not superseded and not stale:
if item.name not in replaced:
# PRODUCT RULE (owner's decision, 2026-07-28): a plan file we were
# not told to delete is kept, however long it sits there. Detaching
# is one click to undo and the editor says the image stays; deleting
# a space is deliberate but the image was imported and may be
# nowhere else. The errors are not symmetrical — unnecessary
# megabytes can be removed by hand, a deleted file cannot be
# brought back.
#
# There is deliberately no age rule here. An earlier version aged
# out "rejected uploads" — a file of a space that has a plan, which
# was never the plan — and that raced a save: the sweep deleted the
# upload from the failed attempt while a retry was committing a
# reference to it. A rule that can delete a file somebody is about
# to point at is not worth the disk it reclaims.
continue
try:
item.unlink()
+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]
+53 -12
View File
@@ -95,13 +95,41 @@ _TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
_TEXT_OR_NONE = vol.Any(None, _TEXT)
_URL = vol.All(str, vol.Length(max=MAX_URL))
# Positions are normalised to the canvas (0..1). Allow generous slack for an
# icon dragged past an edge, but not arbitrary magnitudes: any finite float
# used to pass, and a single stored 1e100 stretched every client's view of the
# space until the plan was invisible (HP-1500-03).
_COORD = vol.All(_finite, vol.Range(min=-4.0, max=4.0))
POS_SCHEMA = vol.Schema(
{vol.Required("x"): _finite, vol.Required("y"): _finite},
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
)
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
POINT = vol.All([_finite], vol.Length(min=2, max=2))
# Geometry is normalised to the canvas (0..1). ±4 is generous slack for a
# vertex nudged past an edge, not an envelope for arbitrary magnitudes: any
# finite float used to pass here, and a single 1e100 room vertex stretched the
# frame until the whole space was unviewable for every client (HP-1501-01) —
# the exact failure HP-1500-03 closed for layout positions, one schema over.
_GEOM = vol.All(_finite, vol.Range(min=-4.0, max=4.0))
# A SIZE is not a coordinate (HP-1502-01): SVG requires positive width/height,
# and the clients divide by these. `view_box: [0,0,0,0]` passed the shared
# validator and serialised into viewBox="0 0 0 0" — a blank plan on every
# client. The floor is one thousandth of the canvas (1 render unit): far below
# any real room, but keeps the maths finite.
_EXTENT = vol.All(_finite, vol.Range(min=0.001, max=4.0))
def _view_box(value):
"""[x, y, w, h]: the first two are coordinates, the last two are sizes."""
if not isinstance(value, (list, tuple)) or len(value) != 4:
raise vol.Invalid("view_box must be [x, y, w, h]")
return [_GEOM(value[0]), _GEOM(value[1]), _EXTENT(value[2]), _EXTENT(value[3])]
POINT = vol.All([_GEOM], vol.Length(min=2, max=2))
def _require_geometry(room: dict) -> dict:
@@ -130,10 +158,10 @@ ROOM_SCHEMA = vol.All(
extra=vol.ALLOW_EXTRA,
),
),
vol.Optional("x"): _finite,
vol.Optional("y"): _finite,
vol.Optional("w"): _finite,
vol.Optional("h"): _finite,
vol.Optional("x"): _GEOM,
vol.Optional("y"): _GEOM,
vol.Optional("w"): _EXTENT,
vol.Optional("h"): _EXTENT,
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3, max=MAX_POLY_POINTS)),
},
extra=vol.ALLOW_EXTRA,
@@ -188,8 +216,16 @@ SPACE_SCHEMA = vol.Schema(
vol.Required("title"): str,
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
vol.Optional("plan_url"): vol.Any(str, None),
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
vol.Required("view_box"): vol.All([_finite], vol.Length(min=4, max=4)),
# The canvas is square since v1.48.0. What used to be the space's own
# `aspect` is gone; the background image keeps its own proportions and
# is centred, so only the IMAGE's ratio is stored. A stale tab may still
# send the old field — it is dropped rather than trusted, because the
# coordinates it comes with were normalised against a different box.
vol.Remove("aspect"): object,
vol.Optional("plan_aspect"): vol.Any(
None, vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20))
),
vol.Required("view_box"): _view_box,
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
vol.Optional("openings"): vol.All([
@@ -197,9 +233,9 @@ SPACE_SCHEMA = vol.Schema(
{
vol.Required("id"): str,
vol.Required("type"): vol.Any("door", "window"),
vol.Required("x"): _finite,
vol.Required("y"): _finite,
vol.Required("angle"): _finite,
vol.Required("x"): _GEOM,
vol.Required("y"): _GEOM,
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
vol.Required("length"): vol.All(vol.Coerce(float), vol.Range(min=0.001, max=1)),
vol.Optional("contact"): vol.Any(str, None),
vol.Optional("lock"): vol.Any(str, None),
@@ -233,7 +269,12 @@ MARKER_SCHEMA = vol.Schema(
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("tap_action"): vol.Any("info", "more-info", "toggle", "run", None),
# the 'run' target: only the runnable domains, nothing else is callable
vol.Optional("tap_target"): vol.Any(
None, vol.All(str, vol.Length(max=MAX_TEXT), vol.Match(r"^(automation|script|scene)\.[A-Za-z0-9_]+$"))
),
vol.Optional("tap_confirm"): vol.Any(bool, 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),
+332 -29
View File
@@ -17,14 +17,19 @@ from homeassistant.core import HomeAssistant, callback
from .const import (
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
CONTENT_URL, FILES_DIR, MAX_SIGN_PATHS, PLANS_DIR, PLANS_URL,
CONTENT_URL, FILES_DIR, MAX_PLANS_BYTES, MAX_PLANS_FILES, MAX_PLANS_LISTED,
MAX_SIGN_PATHS,
PLANS_DIR, PLANS_URL,
)
from .auth import may_write
from .plans import collect_attachments, collect_plans, reserve_filename
from .plans import (
QuotaError, check_quota, collect_attachments, collect_plans, is_plan_file,
plan_basename, plan_refs, reserve_filename,
)
from .store import HouseplanData, get_data, get_entry
from .validation import (
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
PLAN_EXTENSIONS, POS_SCHEMA, valid_space_id,
PLAN_EXTENSIONS, POS_SCHEMA, sanitize_filename, valid_space_id,
)
@@ -36,11 +41,14 @@ def async_register(hass: HomeAssistant) -> None:
"""Register the WS commands."""
websocket_api.async_register_command(hass, ws_layout_get)
websocket_api.async_register_command(hass, ws_layout_set)
websocket_api.async_register_command(hass, ws_geometry_repair)
websocket_api.async_register_command(hass, ws_layout_update)
websocket_api.async_register_command(hass, ws_layout_delete)
websocket_api.async_register_command(hass, ws_config_get)
websocket_api.async_register_command(hass, ws_config_set)
websocket_api.async_register_command(hass, ws_plan_set)
websocket_api.async_register_command(hass, ws_plans_list)
websocket_api.async_register_command(hass, ws_plans_delete)
websocket_api.async_register_command(hass, ws_files_migrate)
websocket_api.async_register_command(hass, ws_files_cleanup)
websocket_api.async_register_command(hass, ws_content_sign)
@@ -111,7 +119,8 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
)
return
new_rev = current_rev + 1
await rt.store.async_save({"layout": msg["layout"], "rev": new_rev})
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
"layout": msg["layout"], "rev": new_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@@ -140,11 +149,100 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
# optimistic locking on layout/set meaningless — every drag reset the
# counter to 0 (HP-1454-08)
new_rev = int(data.get("rev", 0)) + 1
await rt.store.async_save({"layout": layout, "rev": new_rev})
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
"layout": layout, "rev": new_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/geometry/repair",
vol.Required("space_id"): str,
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
vol.Optional("dry_run"): bool,
vol.Optional("undo"): bool,
}
)
@websocket_api.async_response
async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Re-apply the square-canvas transform to ONE space's layout, explicitly.
For installations that hit the v1.48/v1.49 crash window: the config write
of the migration landed, the layout write did not, and the trigger fields
were already gone — markers and labels of that space are stranded in the
old coordinates with nothing able to tell (HP-1500-01). Nothing can be
detected reliably after the fact, and re-running a transform on a layout
that is already correct would corrupt it, so this NEVER runs by itself:
an administrator names the space and its old aspect, may preview with
`dry_run`, and gets a one-deep backup written in the same store write —
`undo` restores it.
"""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may repair the layout")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
from .geometry_migration import migrate_layout
space_id = msg["space_id"]
if not valid_space_id(space_id):
connection.send_error(msg["id"], "invalid_space_id", "space_id: only [a-z0-9_-], up to 64 characters")
return
async with rt.write_lock:
data = await rt.store.async_load() or {}
layout = data.get("layout") or {}
current_rev = int(data.get("rev", 0))
if msg.get("undo"):
backup = data.get("repair_backup")
if not isinstance(backup, dict) or backup.get("space") != space_id:
connection.send_error(msg["id"], "no_backup", "No repair backup stored for this space")
return
restored = dict(layout)
for key, pos in (backup.get("positions") or {}).items():
restored[key] = pos
new_rev = current_rev + 1
await rt.store.async_save({"layout": restored, "rev": new_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev,
"restored": len(backup.get("positions") or {})})
return
touched = {
k: dict(v) for k, v in layout.items()
if isinstance(v, dict) and str(v.get("s")) == space_id
}
if not touched:
# A typo'd space id used to "succeed" with moved: 0 — and its
# empty result REPLACED the one-deep backup, destroying the very
# undo this endpoint promises (HP-1501-02). Nothing to move means
# nothing to save: no write, no revision bump, the backup stays.
connection.send_error(
msg["id"], "nothing_to_repair",
f"No stored positions belong to space '{space_id}'",
)
return
preview = {k: dict(v) for k, v in touched.items()}
migrate_layout(preview, {space_id: msg["aspect"]})
if msg.get("dry_run"):
connection.send_result(msg["id"], {
"ok": True, "dry_run": True, "moved": len(preview),
"before": touched, "after": preview,
})
return
new_layout = {**layout, **preview}
new_rev = current_rev + 1
# the backup rides the same store write: either both are durable or
# neither — the deletion-shy rules of this project apply to positions
# too
await rt.store.async_save({
"layout": new_layout, "rev": new_rev,
"repair_backup": {"space": space_id, "positions": touched},
})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev, "moved": len(preview)})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/files/migrate",
@@ -215,6 +313,105 @@ async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any])
connection.send_result(msg["id"], {"ok": True, "mapping": mapping, "copied": len(mapping)})
@websocket_api.websocket_command({vol.Required("type"): "houseplan/plans/list"})
@websocket_api.async_response
async def ws_plans_list(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Plan images on the server, with what still uses them.
Files are never removed for being unreferenced (docs/SCOPE.md), which only
works as a policy if the user can see them: detaching a plan keeps the
image, and this is how it gets picked up again — or deleted on purpose.
"""
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
stored = await rt.config_store.async_load() or {}
cfg = stored.get("config") or {}
used: dict[str, list[str]] = {}
for space in cfg.get("spaces") or []:
name = plan_basename(space.get("plan_url"))
if name:
used.setdefault(name, []).append(space.get("title") or space.get("id") or "?")
plans_dir = Path(hass.config.path(PLANS_DIR))
def _scan() -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
if not plans_dir.is_dir():
return out
for item in sorted(plans_dir.iterdir()):
if not item.is_file() or not is_plan_file(item.name):
continue
try:
st = item.stat()
except OSError:
continue
out.append({
"name": item.name,
"url": f"{CONTENT_URL}/plans/_/{item.name}",
"size": st.st_size,
"modified": int(st.st_mtime),
"used_by": used.get(item.name, []),
})
out.sort(key=lambda x: -x["modified"])
return out
plans = await hass.async_add_executor_job(_scan)
# newest first and capped: a folder with thousands of files would otherwise
# become one huge message, one huge list and a signing request per row
connection.send_result(
msg["id"], {"plans": plans[:MAX_PLANS_LISTED], "total": len(plans)}
)
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/plans/delete",
vol.Required("name"): str,
}
)
@websocket_api.async_response
async def ws_plans_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Delete a plan image because the user asked — the only way one goes.
Refuses while a space still references it: the answer to "can I delete this"
is the stored configuration's, not the client's.
"""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may delete plans")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
name = sanitize_filename(msg["name"])
if not is_plan_file(name):
connection.send_error(msg["id"], "invalid_name", "Not a plan file")
return
async with rt.write_lock:
stored = await rt.config_store.async_load() or {}
cfg = stored.get("config") or {}
if name in plan_refs(cfg):
connection.send_error(
msg["id"], "in_use", "A space still uses this plan — detach it first"
)
return
path = Path(hass.config.path(PLANS_DIR)) / name
def _rm() -> bool:
try:
path.unlink()
return True
except FileNotFoundError:
return False
except OSError as err:
_LOGGER.warning("House Plan: could not delete %s: %s", path, err)
return False
removed = await hass.async_add_executor_job(_rm)
connection.send_result(msg["id"], {"ok": True, "removed": removed})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/content/sign",
@@ -263,34 +460,68 @@ async def ws_content_sign(hass: HomeAssistant, connection, msg: dict[str, Any])
)
@websocket_api.async_response
async def ws_files_cleanup(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Delete a marker's file folder — called only AFTER the config is committed."""
"""Drop a marker folder's leftovers after its files moved elsewhere.
Called after a rebind: the files were copied to the new marker id and the
config that references them is committed, so the source folder is spent.
It used to `rmtree` the folder on the client's word alone. Two ways that
ends badly: a partial copy leaves some urls still pointing INTO this folder
(the migration deliberately does not rewrite those), and a wrong or stale
id from any client deletes a live marker's attachments outright. So the
server checks for itself — under the config lock — and removes only files
the stored configuration does not reference. Same principle as the
collector: a client may say what it no longer needs, never what may go.
"""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit files")
return
import shutil
from pathlib import Path
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
from .const import FILES_DIR
from .plans import attachment_refs
from .validation import sanitize_marker_id
mid = sanitize_marker_id(msg["marker_id"])
if not mid:
connection.send_result(msg["id"], {"ok": True, "removed": False})
return
base = Path(hass.config.path(FILES_DIR)).resolve()
target = (base / mid).resolve()
if not str(target).startswith(str(base)) or target == base:
connection.send_result(msg["id"], {"ok": True, "removed": False})
target = (base / mid).resolve() if mid else base
if not mid or not str(target).startswith(str(base)) or target == base:
connection.send_result(msg["id"], {"ok": True, "removed": 0, "kept": 0})
return
def _rm() -> bool:
if not target.is_dir():
return False
shutil.rmtree(target, ignore_errors=True)
return True
async with rt.write_lock:
stored = await rt.config_store.async_load() or {}
refs = attachment_refs(stored.get("config") or {})
removed = await hass.async_add_executor_job(_rm)
connection.send_result(msg["id"], {"ok": True, "removed": removed})
def _rm() -> tuple[int, int]:
if not target.is_dir():
return 0, 0
removed = kept = 0
for item in sorted(target.iterdir()):
if not item.is_file():
continue
if f"{mid}/{item.name}" in refs:
kept += 1
continue
try:
item.unlink()
removed += 1
except OSError as err:
_LOGGER.warning("House Plan: could not remove %s: %s", item, err)
if not kept:
try:
target.rmdir()
except OSError:
pass
return removed, kept
removed, kept = await hass.async_add_executor_job(_rm)
if kept:
_LOGGER.info(
"House Plan: kept %s file(s) in %s — the configuration still references them", kept, mid
)
connection.send_result(msg["id"], {"ok": True, "removed": removed, "kept": kept})
@websocket_api.websocket_command(
@@ -315,7 +546,8 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
if msg["device_id"] in layout:
del layout[msg["device_id"]]
new_rev = int(data.get("rev", 0)) + 1
await rt.store.async_save({"layout": layout, "rev": new_rev})
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
"layout": layout, "rev": new_rev})
if new_rev is not None:
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@@ -337,6 +569,46 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
def _internal_plan_names(config: dict[str, Any]) -> set[str]:
"""Plan file names a configuration names through OUR urls.
Only `/api/houseplan/content/plans/_/<name>` and the legacy static path
count. Anything else belongs to the user and may point wherever they like.
"""
out: set[str] = set()
for space in (config or {}).get("spaces") or []:
url = space.get("plan_url")
if not isinstance(url, str) or not url:
continue
if not (url.startswith(CONTENT_URL + "/plans/") or url.startswith(PLANS_URL + "/")):
continue
name = plan_basename(url)
if name:
out.add(name)
return out
def _missing_internal_plans(
plans_dir: Path, config: dict[str, Any], previous: dict[str, Any] | None = None
) -> set[str]:
"""Newly named plan files that are not on disk.
Guards the pick-then-save window: another client may delete a plan between
the moment this one chose it and the moment it saves, which would otherwise
store a url with nothing behind it (HP-1470-02).
A name the stored configuration already carries is deliberately let through.
It is already broken — repairs says so — and refusing the write would lock
the owner out of every other edit, including the one that detaches it.
"""
known = _internal_plan_names(previous or {})
return {
name
for name in _internal_plan_names(config)
if name not in known and not (plans_dir / name).is_file()
}
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/config/set",
@@ -386,6 +658,23 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
f"Configuration was changed in another window (rev {current_rev} != {msg['expected_rev']})",
)
return
# An internal plan url must name a file that exists. The card can pick a
# plan and then delete it from the same dialog, and two clients can do
# the same thing in either order — the lock serialises them but says
# nothing about whether the file survived (HP-1470-02). External and
# legacy urls are not ours to check and are left alone.
missing = await hass.async_add_executor_job(
_missing_internal_plans,
Path(hass.config.path(PLANS_DIR)),
msg["config"],
data.get("config"),
)
if missing:
connection.send_error(
msg["id"], "missing_plan",
"Plan file no longer exists: " + ", ".join(sorted(missing)),
)
return
new_rev = current_rev + 1
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
# Still holding the lock: the file system is not part of the store's
@@ -446,10 +735,11 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
# deleted here (review R2-1). The old name stays readable, so a config write
# that is later rejected — revision conflict, validation, lost connection —
# leaves the stored plan exactly as it was. The card calls
# nothing here; the superseded file is collected by `config/set` itself,
# inside the write lock, once a revision that no longer references it has
# been accepted (review R3-1). A crash in between leaves an orphan, which
# the same collector removes on a later commit once it is old enough.
# nothing here; the file a commit REPLACES is collected by `config/set`
# itself, inside the write lock (review R3-1). An upload that never gets
# committed is not collected at all — it is offered back in the space
# dialog's "already uploaded" list, where the user can attach or delete it.
# Every attempt to age these out ended in data loss or a race (v1.46.4-6).
#
# `.` separates the id from the token because a space id cannot contain one
# (SPACE_ID_RE), so "<space>.<token>.<ext>" can never be confused with the
@@ -458,9 +748,22 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
name = f"{space_id}.{secrets.token_hex(4)}.{msg['ext']}"
path = plans_dir / name
def _write() -> None:
def _check_and_write() -> None:
# one executor job for the pair, under upload_lock: the measurement
# is only a bound if nothing else writes between it and our write
# (HP-1490-02). A failed write reserves nothing — the file either
# exists and is counted by the next scan, or does not and is not.
check_quota(plans_dir, len(raw), MAX_PLANS_BYTES, MAX_PLANS_FILES)
plans_dir.mkdir(parents=True, exist_ok=True)
path.write_bytes(raw)
await hass.async_add_executor_job(_write)
data = _runtime(hass, connection, msg["id"])
if data is None:
return
async with data.upload_lock:
try:
await hass.async_add_executor_job(_check_and_write)
except QuotaError as err:
connection.send_error(msg["id"], err.reason, err.detail)
return
connection.send_result(msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{name}"})
+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));
+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()=>()=>{} } };
+65
View File
@@ -0,0 +1,65 @@
// На телефоне в редакторах не работали зум и навигация жестами: pointerdown
// сцены выходил сразу при _markup, так что пинч и пан не начинались вовсе.
// Рисование кликается, жесты двигаются — они совместимы; палец с движением
// панорамирует, два пальца зумируют, отпускание после жеста не рисует точку.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = {};
const pd = (c, id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
const pm = (c, id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
const pu = (c, id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
// --- пинч-зум в редакторе плана -----------------------------------------
out.pinchZoomsInPlanEditor = await page.evaluate(() => {
const c = window.__card;
c._setMode('plan');
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
const pm = (id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
c._resetZoom();
const z0 = c._zoom;
pd(1, 300, 300); pd(2, 400, 300); // два пальца
pm(1, 250, 300); pm(2, 450, 300); // разводим
const zoomed = c._zoom > z0 * 1.5;
pu(1, 250, 300); pu(2, 450, 300);
return zoomed && c._path.length === 0;
});
// --- пан одним пальцем в редакторе, точка не рисуется --------------------
out.panWorksAndDoesNotDraw = await page.evaluate(async () => {
const c = window.__card;
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
const pm = (id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
const st = c._stageEl;
c._zoomAt(st.clientWidth / 2, st.clientHeight / 2, 3); // есть куда панорамировать
const v0 = { ...c._view };
pd(3, 300, 300);
pm(3, 380, 340); pm(3, 420, 360);
const panned = Math.abs(c._view.x - v0.x) > 1 || Math.abs(c._view.y - v0.y) > 1;
const suppressed = c._suppressClick === true;
c._markupClick({ composedPath: () => [], clientX: 420, clientY: 360 }); // синтезированный click после пана
const noDot = c._path.length === 0;
pu(3, 420, 360);
await new Promise((r) => setTimeout(r, 10));
return panned && suppressed && noDot;
});
// --- обычный клик без движения по-прежнему рисует ------------------------
out.tapStillDraws = await page.evaluate(() => {
const c = window.__card;
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
c._resetZoom();
c._tool = 'draw';
const before = c._path.length;
pd(4, 300, 300); pu(4, 300, 300);
const st = c._stageEl.getBoundingClientRect();
c._markupClick({ composedPath: () => [], clientX: st.left + 200, clientY: st.top + 200 });
const drew = c._path.length > before;
c._path = []; c._setMode('view');
return drew;
});
await finish(browser, checkAll(out));
+5 -2
View File
@@ -9,10 +9,13 @@ const res = await page.evaluate(async () => {
const btn = sr().querySelector('.rlgearbtn');
out.gearButtonShown = !!btn;
const cs = btn ? getComputedStyle(btn) : null;
out.gearReadable = cs ? parseFloat(cs.fontSize) >= 10 && cs.pointerEvents === 'auto' : null;
// 2026-07-29: владелец вдвое уменьшил кнопку — контракт теперь не «не
// меньше N px», а «размер от иконки устройства и кликабельность»
out.gearReadable = cs ? parseFloat(cs.fontSize) > 0 && cs.pointerEvents === 'auto' : null;
out.gearHasLabel = btn ? btn.textContent.trim().length > 0 : null;
const box = btn?.getBoundingClientRect();
out.gearTapTarget = box ? box.height >= 18 && box.width >= 40 : null;
// (в редакторе плана .dev скрыты display:none — сравнивать не с чем)
out.gearTapTarget = box ? box.height > 6 && box.height < 40 : null;
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await c.updateComplete;
out.gearOpensDialog = c._roomDialog === true && !!c._roomEditId;
+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);
+237
View File
@@ -0,0 +1,237 @@
// docs/FILTERING.md: скрытие — явная галка. Конфиг материализуется сеятелем
// (filter_seeded), галка в диалоге у всех устройств, «Показать скрытые» —
// локальный режим редактора, скрытые рисуются призраками и только там.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = {};
// --- сеятель материализует конфиг при загрузке ---------------------------
out.configSeeded = await page.evaluate(async () => {
const c = window.__card;
const t0 = Date.now();
while (!c._serverCfg?.settings?.filter_seeded && Date.now() - t0 < 3000) {
await new Promise((r) => setTimeout(r, 60));
}
return c._serverCfg?.settings?.filter_seeded === true;
});
// --- галка прячет, счётчик не считает, призрак только в редакторе --------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const visibleIds = () => [...sr().querySelectorAll('.dev')].length;
const before = visibleIds();
const d = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device');
c._serverCfg.markers = c._serverCfg.markers || [];
c._serverCfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, hidden: true });
c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
o.hiddenNotRendered = visibleIds() === before - 1;
const built = c._devices.find((x) => x.id === d.id);
o.stillBuilt = !!built && built.hidden === true;
o.countExcludesHidden = (sr().querySelector('.count')?.textContent || '').includes(String(before - 1));
// в просмотре тумблера нет эффекта — призраки только в редакторе устройств
c._showHidden = true; c.requestUpdate(); await c.updateComplete;
o.noGhostsInView = !sr().querySelector('.dev.ghost');
c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
o.ghostInEditor = !!sr().querySelector('.dev.ghost');
// призрак — конфигурация, не статус: ни жёлтого, ни unavail, ни тревоги
const g = sr().querySelector('.dev.ghost');
o.ghostHasNoState = !!g && !g.classList.contains('on') && !g.classList.contains('open')
&& !g.classList.contains('unavail') && !g.classList.contains('alarm');
// и он синий, а не тёмный — отличим от недоступного устройства
o.ghostIsBlue = !!g && getComputedStyle(g).borderStyle.includes('dashed')
&& getComputedStyle(g).borderColor !== 'rgb(255, 255, 255)';
// тумблер локальный: конфиг не трогается
o.toggleIsLocal = c._serverCfg.settings.show_all === undefined;
// --- галка в диалоге у авто-устройства, кнопки «Удалить» нет -----------
const ghost = c._devices.find((x) => x.id === d.id);
c._openMarkerDialog(ghost); await c.updateComplete;
o.checkboxOn = c._markerDialog?.hideFromPlan === true;
o.noDeleteForAuto = !sr().querySelector('.dialog .btn.danger');
// снимаем галку и сохраняем — маркер остаётся с hidden:false (анти-повтор)
c._markerDialog = { ...c._markerDialog, hideFromPlan: false };
await c._saveMarker(); await c.updateComplete;
const m = (c._serverCfg.markers || []).find((x) => x.binding === 'device:' + d.bindingRef);
o.untickKeepsMarker = !!m && m.hidden === false;
const back = c._devices.find((x) => x.id === d.id) || c._devices.find((x) => x.bindingRef === d.bindingRef);
o.deviceVisibleAgain = !!back && !back.hidden;
// --- у виртуального кнопка «Удалить» есть -------------------------------
c._openMarkerDialog(); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, name: 'Тест', binding: 'virtual' };
await c._saveMarker(); await c.updateComplete;
const virt = c._devices.find((x) => x.virtual);
c._openMarkerDialog(virt); await c.updateComplete;
o.deleteForVirtual = !!sr().querySelector('.dialog .btn.danger');
c._markerDialog = null; c._setMode('view');
return o;
}));
// --- HP-1510-02: призрак не показывает live-значения ----------------------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// датчик с числом + display:value, скрыт
c.hass = { ...c.hass, states: { ...c.hass.states,
'sensor.power_meter': { state: '42', attributes: { unit_of_measurement: 'kW' } } } };
c._serverCfg.markers = c._serverCfg.markers || [];
c._serverCfg.markers.push({ id: 'pm', binding: 'entity:sensor.power_meter',
display: 'value', hidden: true, space: 'f1' });
c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices();
c._setMode('devices'); c._showHidden = true;
c.requestUpdate(); await c.updateComplete;
const ghosts = [...sr().querySelectorAll('.dev.ghost')];
const pm = ghosts.find((g) => g.textContent.includes('42')) || null;
o.ghostHidesValue = pm === null; // «42 kW» не отрисован
o.ghostNoLiveBadges = ghosts.every((g) =>
!g.querySelector('.valtext') && !g.querySelector('.tval') && !g.querySelector('.hval') && !g.querySelector('.lqi'));
// у КАЖДОГО призрака есть базовая иконка — «noicon» не оправдание
// (HP-1511-02: ripple-призрак был безликим пульсом)
o.ghostKeepsIcon = ghosts.every((g) => !!g.querySelector('ha-icon') && !g.classList.contains('noicon'));
c._setMode('view'); c._showHidden = false;
return o;
}));
// --- HP-1511-02: ripple-призрак с базовой иконкой, без пульса -------------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const lamp = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device' && !x.hidden);
c._serverCfg.markers = c._serverCfg.markers || [];
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== lamp.id);
c._serverCfg.markers.push({ id: lamp.id, binding: 'device:' + lamp.bindingRef,
display: 'ripple', hidden: true });
c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices();
c._setMode('devices'); c._showHidden = true;
c.requestUpdate(); await c.updateComplete;
const g = [...sr().querySelectorAll('.dev.ghost')].find((x) => x.querySelector('.ripple') || !x.classList.contains('noicon'));
const ghosts = [...sr().querySelectorAll('.dev.ghost')];
o.rippleGhostHasIcon = ghosts.length > 0 && ghosts.every((x) => !!x.querySelector('ha-icon'));
o.rippleGhostNoNoicon = ghosts.every((x) => !x.classList.contains('noicon'));
o.rippleGhostNoRipple = ghosts.every((x) => !x.querySelector('.ripple'));
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== lamp.id);
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
c._setMode('view'); c._showHidden = false;
return o;
}));
// --- HP-1511-01: авто-сетка одинакова на обеих карточках ------------------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
await customElements.whenDefined('houseplan-space-card');
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
const f1 = cfg.spaces.find((s) => s.id === 'f1');
// один видимый, остальные зоны living_room скрыты; layout пуст
const vis = c._devices.filter((x) => x.area === 'living_room' && !x.virtual);
const keep = vis[0];
for (const d of vis.slice(1)) {
cfg.markers = (cfg.markers || []).filter((m) => m.id !== d.id);
cfg.markers.push({ id: d.id, binding: d.bindingKind + ':' + d.bindingRef, hidden: true });
}
cfg.markers = cfg.markers.filter((m) => m.id !== keep.id || !m.hidden);
c._serverCfg = cfg; c._cfgEpoch++; c._regSignature = '';
c._layout = {}; // пустой layout: работают только авто-позиции
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
const full = [...(c.shadowRoot || c.renderRoot).querySelectorAll('.dev')]
.find((el) => el.style.left && !el.classList.contains('ghost'));
// проценты полной карточки считаются от content-fit view — переводим в vb
const v = c._viewOr(c._baseVb());
const fullVb = full && [
v.x + (parseFloat(full.style.left) / 100) * v.w,
v.y + (parseFloat(full.style.top) / 100) * v.h,
];
const hass = { ...c.hass, callWS: async (m) => {
// rev уникален: у статичной карточки модульный кэш конфига по rev,
// и одинаковый rev в соседних тестах подсовывает чужой конфиг
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 31 };
if (m.type === 'houseplan/layout/get') return { layout: {}, rev: 31 };
return { ok: true };
},
// модульный кэш конфига инвалидируется только через это событие — стаб
// сохраняет колбэк, чтобы следующий тест мог сбросить кэш
connection: { subscribeEvents: async (cb) => { window.__hpInvalidate = cb; return () => {}; } } };
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('[style*="left"]') && Date.now() - t0 < 6000) {
await new Promise((r) => setTimeout(r, 60));
}
await card.updateComplete;
const st = [...card.renderRoot.querySelectorAll('[style*="left"]')]
.find((el) => /%$/.test(el.style.left || '') && el.style.top);
// статичная карточка рендерит от полного квадрата 0..1000
const stVb = st && [parseFloat(st.style.left) * 10, parseFloat(st.style.top) * 10];
o.autoGridParity = !!fullVb && !!stVb
&& Math.abs(fullVb[0] - stVb[0]) < 6 && Math.abs(fullVb[1] - stVb[1]) < 6;
if (!o.autoGridParity) console.log('full', fullVb, 'static', stVb);
host.remove();
return o;
}));
// --- HP-1510-01: LQI комнаты одинаков на полной и статичной карточке ------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
await customElements.whenDefined('houseplan-space-card');
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
const f1 = cfg.spaces.find((s) => s.id === 'f1');
f1.settings = { ...(f1.settings || {}), fill_mode: 'lqi', show_borders: true };
// прячем ВСЕ устройства зоны living_room: комнату красит только скрытое
for (const d of c._devices.filter((x) => x.area === 'living_room' && !x.virtual)) {
if (!cfg.markers.some((m) => m.id === d.id)) {
cfg.markers.push({ id: d.id, binding: d.bindingKind + ':' + d.bindingRef, hidden: true });
} else {
cfg.markers = cfg.markers.map((m) => (m.id === d.id ? { ...m, hidden: true } : m));
}
}
c._serverCfg = cfg; c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
const fullRoom = [...(c.shadowRoot || c.renderRoot).querySelectorAll('.room')]
.find((r) => (r.getAttribute('style') || '').includes('--room-fill'));
const fullFill = fullRoom ? (fullRoom.getAttribute('style').match(/--room-fill:([^;]+)/) || [])[1] : null;
window.__hpInvalidate?.(); // сбросить модульный кэш от предыдущей карточки
const hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 57 };
if (m.type === 'houseplan/layout/get') return { layout: {}, rev: 57 };
return { ok: true };
},
connection: { subscribeEvents: async () => () => {} } };
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('.room') && Date.now() - t0 < 6000) {
await new Promise((r) => setTimeout(r, 60));
}
await card.updateComplete;
const stRoom = [...card.renderRoot.querySelectorAll('.room')]
.find((r) => (r.getAttribute('style') || '').includes('--room-fill'));
const stFill = stRoom ? (stRoom.getAttribute('style').match(/--room-fill:([^;]+)/) || [])[1] : null;
o.fullPaintsHiddenLqi = !!fullFill;
o.staticPaintsHiddenLqi = !!stFill;
o.lqiParity = !!fullFill && fullFill === stFill;
host.remove();
return o;
}));
await finish(browser, checkAll(out));
+38
View File
@@ -0,0 +1,38 @@
// Множитель «размер значка» обязан масштабировать ГЛИФ вместе с подложкой:
// --mdc-icon-size считался от базового --icon-size, и «увеличенный» значок
// был пустой рамкой вокруг дефолтной иконки (репорт пользователя, 2026-07-29).
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const d = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device');
const glyphW = () => {
const el = [...sr().querySelectorAll('.dev')].find((e) => !e.classList.contains('ghost'));
const ic = el?.querySelector('ha-icon');
return ic ? ic.getBoundingClientRect().width : 0;
};
const badgeW = () => {
const el = [...sr().querySelectorAll('.dev')].find((e) => !e.classList.contains('ghost'));
return el ? el.getBoundingClientRect().width : 0;
};
// базовый размер
const g1 = glyphW(), b1 = badgeW();
// множитель ×3 на всех авто-устройствах через маркер первого
c._serverCfg.markers = (c._serverCfg.markers || []).filter((m) => m.id !== d.id);
c._serverCfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, size: 3 });
c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 100));
const scaled = [...sr().querySelectorAll('.dev')].find((e) => (e.getAttribute('style') || '').includes('--dev-scale:3'));
const g3 = scaled?.querySelector('ha-icon')?.getBoundingClientRect().width || 0;
const b3 = scaled?.getBoundingClientRect().width || 0;
o.badgeScales = b3 > b1 * 2.5;
o.glyphScalesWithBadge = g3 > g1 * 2.5; // раньше глиф оставался базовым
o.glyphFillsBadge = g3 / b3 > 0.45 && g3 / b3 < 0.8; // пропорция сохранена
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== d.id);
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
return o;
});
await finish(browser, checkAll(out));
+65
View File
@@ -0,0 +1,65 @@
// HP-1503-01: один и тот же повреждённый store обязан рендериться одинаково в
// ОБЕИХ карточках. Полная карточка строила модель рукописной копией
// spaceModels и прошла мимо safeViewBox/normRect: статическая показывала
// восстановленный план, полная — viewBox="0 0 0 0" и rect с отрицательной
// шириной. Вектор аудита исполняется через обе модели и оба DOM-дерева.
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;
// ровно вектор из отчёта: нулевой viewport + отрицательный legacy-rect
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
const f1 = cfg.spaces.find((s) => s.id === 'f1');
f1.plan_url = null; f1.plan_aspect = null;
f1.view_box = [0, 0, 0, 0];
f1.rooms = [{ id: 'r1', name: 'R', area: 'living_room', x: 0.6, y: 0.7, w: -0.2, h: -0.3 }];
f1.settings = { ...(f1.settings || {}), show_borders: true };
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._view = null;
main.requestUpdate(); await main.updateComplete;
await new Promise((r) => setTimeout(r, 150));
// модель полной карточки: fallback на весь холст + нормализованный rect
const m = main._spaceModel('f1');
out.fullVbFallsBack = JSON.stringify(m.vb) === JSON.stringify([0, 0, 1000, 1000]);
const r = m.rooms[0];
out.fullRectNormalised =
Math.round(r.x) === 400 && Math.round(r.y) === 400
&& Math.round(r.w) === 200 && Math.round(r.h) === 300;
// DOM полной карточки: конечный положительный viewBox, никаких минусов в rect
const svg = (main.shadowRoot || main.renderRoot).querySelector('.stage svg');
const vbAttr = (svg?.getAttribute('viewBox') || '').split(/\s+/).map(Number);
out.fullDomViewBoxSane = vbAttr.length === 4 && vbAttr[2] > 0 && vbAttr[3] > 0;
const rect = (main.shadowRoot || main.renderRoot).querySelector('.stage svg rect.room, .stage svg .room rect, .stage svg rect[width]');
out.fullDomRectSane = !rect || (Number(rect.getAttribute('width')) >= 0 && Number(rect.getAttribute('height')) >= 0);
// статическая карточка того же store — паритет
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('svg') && Date.now() - t0 < 6000) {
await new Promise((r2) => setTimeout(r2, 60));
}
await card.updateComplete;
const svb = (card.renderRoot.querySelector('svg')?.getAttribute('viewBox') || '').split(/\s+/).map(Number);
out.staticDomViewBoxSane = svb.length === 4 && svb[2] > 0 && svb[3] > 0;
out.parity = JSON.stringify(vbAttr.length === 4 && svb.length === 4
? [vbAttr[2] > 0, vbAttr[3] > 0] : null) === JSON.stringify([svb[2] > 0, svb[3] > 0]);
return out;
});
await finish(browser, checkAll(res));
+78
View File
@@ -0,0 +1,78 @@
// Правило владельца (2026-07-29): у источников света подложка в glow-режиме
// всегда стандартная — индикатор включения это ПЯТНО; в остальных режимах
// горящий источник жёлтый, как греющая термоголовка. RGB лампы красит только
// пятно (и фолбэк цвета пульсации); окраска иконки/рамки убрана. Морф иконки
// остаётся везде. Розетка в glow-режиме остаётся жёлтой — она не источник.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const setFill = async (mode) => {
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
const f1 = cfg.spaces.find((s) => s.id === 'f1');
f1.settings = { ...(f1.settings || {}), fill_mode: mode };
c._serverCfg = cfg; c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 80));
};
const lamp = c._devices.find((x) => x.id === 'd_lamp');
const kettle = c._devices.find((x) => x.id === 'd_kettle'); // розетка/чайник
// зажигаем лампу с RGB и розетку
c.hass = { ...c.hass, states: { ...c.hass.states,
[lamp.primary]: { ...c.hass.states[lamp.primary], state: 'on',
attributes: { ...(c.hass.states[lamp.primary]?.attributes || {}), rgb_color: [255, 120, 40] } },
[kettle.primary]: { ...c.hass.states[kettle.primary], state: 'on' } } };
const devEl = (id) => {
// надёжнее по индексу в списке devs: ищем по позиции через _pos
const d = c._devices.find((x) => x.id === id);
const v = c._viewOr(c._baseVb());
const p = c._pos(d);
const left = (((p.x - v.x) / v.w) * 100).toFixed(0);
return [...sr().querySelectorAll('.dev')].find((e) => Math.abs(parseFloat(e.style.left) - left) < 1.5);
};
// --- режим light: горящая RGB-лампа ЖЁЛТАЯ, без rgb-класса --------------
await setFill('light');
let le = devEl('d_lamp');
o.litLampYellowInLightMode = !!le && le.classList.contains('on') && !le.classList.contains('rgb');
o.morphInLightMode = le?.querySelector('ha-icon')?.getAttribute('icon')?.includes('lightbulb') ?? false;
// --- режим glow: лампа ТЁМНАЯ (индикатор — пятно), розетка жёлтая -------
await setFill('glow');
le = devEl('d_lamp');
const ke = devEl('d_kettle');
o.litLampDarkInGlow = !!le && !le.classList.contains('on') && !le.classList.contains('rgb');
o.glowSpotPresent = !!sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
o.socketStaysYellowInGlow = !!ke && ke.classList.contains('on');
o.morphInGlow = le?.querySelector('ha-icon')?.getAttribute('icon')?.includes('lightbulb') ?? false;
// --- HP-1520-01: в редакторах, где glow-слой скрыт, индикатор возвращается
// (лампа горит; в devices-редакторе слой рисуется — там лампа тёмная,
// в plan-редакторе слоя нет — там класс on должен вернуться)
c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
le = devEl('d_lamp');
o.devicesEditorLampDark = !!le && !le.classList.contains('on');
o.devicesEditorHasGlow = !!sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
c._setMode('plan'); c.requestUpdate(); await c.updateComplete;
// ИМЕННО лампа (mutation-проверка аудита HP-1521-01: розетка тоже on и
// удовлетворяла бы поиску «любой .dev.on», пряча регресс самой лампы)
le = devEl('d_lamp');
o.planEditorLampYellow = !!le && le.classList.contains('on'); // слой скрыт — жёлтый вернулся
o.planEditorKettleStillOn = !!devEl('d_kettle')?.classList.contains('on');
o.planEditorNoGlow = !sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
// --- выключенная лампа тёмная в обоих режимах ---------------------------
c.hass = { ...c.hass, states: { ...c.hass.states,
[lamp.primary]: { ...c.hass.states[lamp.primary], state: 'off' } } };
c.requestUpdate(); await c.updateComplete;
le = devEl('d_lamp');
o.offLampDarkInGlow = !!le && !le.classList.contains('on');
await setFill('light');
le = devEl('d_lamp');
o.offLampDarkInLightMode = !!le && !le.classList.contains('on');
return o;
});
await finish(browser, checkAll(out));
+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);
+14 -3
View File
@@ -8,9 +8,20 @@ const res = await page.evaluate(async () => {
c.hass = { ...c.hass, states: { ...c.hass.states,
'light.ceiling': { entity_id: 'light.ceiling', state: 'on', attributes: { friendly_name: 'Ceiling light', rgb_color: [255, 0, 128] } } } };
await c.updateComplete;
const rgbDev = sr().querySelector('.dev.rgb');
out.rgbClass = !!rgbDev;
out.rgbVar = rgbDev?.getAttribute('style')?.includes('--light-color:rgb(255, 0, 128)');
// v1.52.0 (правило владельца): RGB больше НЕ красит значок — цвет лампы
// живёт только в glow-пятне и в фолбэке цвета пульсации
out.rgbClassGone = !sr().querySelector('.dev.rgb');
out.litLampIsYellow = [...sr().querySelectorAll('.dev.on')].length > 0;
// фолбэк пульсации сохраняет цвет свечения: маркер icon_ripple на лампе
const lampDev = c._devices.find((x) => x.entities.includes('light.ceiling'));
c._serverCfg.markers = (c._serverCfg.markers || []).filter((m) => m.id !== lampDev.id);
c._serverCfg.markers.push({ id: lampDev.id, binding: 'device:' + lampDev.bindingRef, display: 'icon_ripple' });
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
c.requestUpdate(); await c.updateComplete;
const rippleDev = [...sr().querySelectorAll('.dev')].find((e) => (e.getAttribute('style') || '').includes('--ripple-color'));
out.rippleKeepsLightColor = !!rippleDev && rippleDev.getAttribute('style').includes('rgb(255, 0, 128)');
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== lampDev.id);
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
// тревога: датчик протечки on
c.hass = { ...c.hass, states: { ...c.hass.states,
'binary_sensor.sink_leak': { entity_id: 'binary_sensor.sink_leak', state: 'on', attributes: { friendly_name: 'Leak', device_class: 'moisture' } } } };
+8 -2
View File
@@ -36,8 +36,14 @@ const res = await page.evaluate(async () => {
c._setMode('plan'); await c.updateComplete;
const planLbl = sr().querySelector('.roomlabel');
out.handlesInPlan = planLbl?.querySelectorAll('.rlhandle').length === 4;
// метрики скрыты в редакторе плана (карточка = имя, чтобы не мешать разметке)
out.plainInPlan = sr().querySelectorAll('.roomlabel.card').length === 0;
// метрики видны и в редакторе плана (решение владельца, 2026-07-29)
out.plainInPlan = sr().querySelectorAll('.roomlabel.card').length > 0;
// кнопка настроек — отдельно от подписи, в центре комнаты, с размером от иконки
out.gearDetached = (() => {
const g = sr().querySelector('.rlgearbtn');
if (!g || g.closest('.roomlabel')) return false;
return getComputedStyle(g).height !== 'auto';
})();
// масштаб: сымитируем resize через прямой вызов
const room = c._spaceModel().rooms.find((r) => r.name);
c._rlResize = { id: 'rl_' + room.id, space: spId, k0: 1, cx: 100, cy: 100, d0: 50 };
+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);
+61
View File
@@ -0,0 +1,61 @@
// HP-1513-01: один и тот же маркер (size 3, angle 37) обязан выглядеть
// одинаково на полной и статичной карточках — статичная игнорировала и
// множитель размера, и поворот.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
await customElements.whenDefined('houseplan-space-card');
const d = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device');
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
cfg.markers = (cfg.markers || []).filter((m) => m.id !== d.id);
cfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, size: 3, angle: 37 });
c._serverCfg = cfg; c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 100));
const pick = (root) => [...root.querySelectorAll('.dev')]
.find((e) => (e.getAttribute('style') || '').includes('--dev-scale:3'));
const full = pick(c.shadowRoot || c.renderRoot);
const fW = full?.getBoundingClientRect().width || 0;
const fT = full ? getComputedStyle(full.querySelector('ha-icon')).transform : 'none';
window.__hpInvalidate?.();
const hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 83 };
if (m.type === 'houseplan/layout/get') return { layout: c._layout, rev: 83 };
return { ok: true };
}, connection: { subscribeEvents: async (cb) => { window.__hpInvalidate = cb; return () => {}; } } };
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 (!pick(card.renderRoot || { querySelectorAll: () => [] }) && Date.now() - t0 < 6000) {
await new Promise((r) => setTimeout(r, 60));
}
const st = pick(card.renderRoot);
const sW = st?.getBoundingClientRect().width || 0;
const sT = st ? getComputedStyle(st.querySelector('ha-icon')).transform : 'none';
o.staticCarriesScale = sW > 0;
// абсолютные px несравнимы (разные контейнеры/letterbox) — контракт в том,
// что множитель РАБОТАЕТ на обеих карточках: базовый значок той же карточки
// втрое меньше масштабированного
const stBase = [...card.renderRoot.querySelectorAll('.dev')]
.find((e) => !(e.getAttribute('style') || '').includes('--dev-scale'));
const sBaseW = stBase?.getBoundingClientRect().width || 0;
o.staticRatioIs3 = sBaseW > 0 && Math.abs(sW / sBaseW - 3) < 0.35;
const fBase = [...(c.shadowRoot || c.renderRoot).querySelectorAll('.dev')]
.find((e) => !(e.getAttribute('style') || '').includes('--dev-scale') && !e.classList.contains('ghost'));
const fBaseW = fBase?.getBoundingClientRect().width || 0;
o.fullRatioIs3 = fBaseW > 0 && Math.abs(fW / fBaseW - 3) < 0.35;
o.bothRotated = fT !== 'none' && sT !== 'none' && fT.startsWith('matrix') && sT.startsWith('matrix');
if (!o.staticRatioIs3) console.log('static', sW, sBaseW);
host.remove();
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== d.id);
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
return o;
});
await finish(browser, checkAll(out));
+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);
+1 -1
View File
@@ -12,7 +12,7 @@ const res = await page.evaluate(async () => {
c._openMarkerDialog(dev); await c.updateComplete;
const sel = [...sr().querySelectorAll('.dialog select')].find((s) =>
[...s.options].some((o) => o.textContent === c._t('tap.toggle')));
out.threeOptions = sel && sel.options.length === 3;
out.threeOptions = sel && sel.options.length === 4; // + «Запустить…» (2026-07-29)
out.noAutoOption = sel && ![...sel.options].some((o) => o.value === '');
out.defaultInfo = sel && sel.value === 'info';
c._markerDialog = null; await c.updateComplete;
+76
View File
@@ -0,0 +1,76 @@
// ТЗ 2026-07-29: действие по нажатию «Запустить автоматизацию/скрипт/сцену»
// с пикером и поиском + чекбокс «Спрашивать подтверждение» для toggle/run.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const calls = [];
c.hass = { ...c.hass,
states: { ...c.hass.states,
'automation.evening': { state: 'on', attributes: { friendly_name: 'Вечерний свет' } },
'script.curtains': { state: 'off', attributes: { friendly_name: 'Шторы' } },
'scene.movie': { state: '', attributes: { friendly_name: 'Кино' } } },
callService: async (dom, svc, data) => { calls.push([dom, svc, data]); return {}; } };
// --- диалог: выбор run + пикер с поиском --------------------------------
const d = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device');
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog(d); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, tapAction: 'run' }; await c.updateComplete;
o.pickerShown = !!sr().querySelector('.dialog .candlist');
c._markerDialog = { ...c._markerDialog, runFilter: 'штор' }; await c.updateComplete;
const cands = [...sr().querySelectorAll('.dialog .cand')].map((x) => x.textContent);
o.searchWorks = cands.length >= 1 && cands.some((t) => t.includes('Шторы'));
// сохранение без цели блокируется
await c._saveMarker(); await c.updateComplete;
o.saveBlockedWithoutTarget = !!c._markerDialog;
// выбираем цель + подтверждение, сохраняем
c._markerDialog = { ...c._markerDialog, tapTarget: 'script.curtains', tapConfirm: true };
await c.updateComplete;
o.confirmCheckboxShown = [...sr().querySelectorAll('.dialog .srcrow')].length >= 2;
await c._saveMarker(); await c.updateComplete;
const saved = (c._serverCfg.markers || []).find((m) => m.binding === 'device:' + d.bindingRef);
o.markerSaved = saved?.tap_action === 'run' && saved?.tap_target === 'script.curtains' && saved?.tap_confirm === true;
// --- тап: подтверждение → отмена → вызова нет ---------------------------
c._setMode('view'); c._regSignature = ''; c._maybeRebuildDevices();
c.requestUpdate(); await c.updateComplete;
const dev = c._devices.find((x) => x.id === (saved?.id ?? d.id)) || c._devices.find((x) => x.bindingRef === d.bindingRef);
c._clickDevice({ stopPropagation() {} }, dev); await c.updateComplete;
o.confirmDialogShown = !!c._tapConfirm && c._tapConfirm.text.includes('Шторы');
c._tapConfirm = null; await c.updateComplete;
o.cancelNoCall = calls.length === 0;
// --- тап: подтвердили → script.turn_on ----------------------------------
c._clickDevice({ stopPropagation() {} }, dev); await c.updateComplete;
const conf = c._tapConfirm; c._tapConfirm = null; conf.exec();
await new Promise((r) => setTimeout(r, 10));
o.runCalled = calls.length === 1 && calls[0][0] === 'script' && calls[0][1] === 'turn_on'
&& calls[0][2].entity_id === 'script.curtains';
// --- без подтверждения: сразу вызов; автоматизация → trigger ------------
c._serverCfg.markers = c._serverCfg.markers.map((m) =>
m.id === (saved?.id ?? d.id) ? { ...m, tap_target: 'automation.evening', tap_confirm: null } : m);
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
const dev2 = c._devices.find((x) => x.bindingRef === d.bindingRef);
calls.length = 0;
c._clickDevice({ stopPropagation() {} }, dev2); await c.updateComplete;
await new Promise((r) => setTimeout(r, 10));
o.automationTriggered = calls.length === 1 && calls[0][0] === 'automation' && calls[0][1] === 'trigger';
o.noConfirmWhenOff = !c._tapConfirm;
// --- цель удалена → тост, вызова нет ------------------------------------
const states2 = { ...c.hass.states }; delete states2['automation.evening'];
c.hass = { ...c.hass, states: states2 };
calls.length = 0;
c._clickDevice({ stopPropagation() {} }, dev2); await c.updateComplete;
o.missingTargetSafe = calls.length === 0 && !!c._toast;
// очистка
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== (saved?.id ?? d.id));
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
return o;
});
await finish(browser, checkAll(out));
+60
View File
@@ -0,0 +1,60 @@
// Единый принцип жёлтого (2026-07-29): жёлтый = устройство прямо сейчас
// выполняет основную функцию. Термоголовка желтеет от реального нагрева
// (hvac_action), а не от служебного свитча защиты от накипи; горящая лампа
// желтит значок в любом режиме заливки тем же условием, что зажигает пятно.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(() => {
const o = {};
const c = window.__card;
const cls = (d, states) => {
const saved = c.hass;
c.hass = { ...c.hass, states: { ...c.hass.states, ...states } };
const r = c._stateClass(d);
c.hass = saved;
return r;
};
const trv = { id: 't', primary: 'climate.trv', entities: ['climate.trv', 'switch.trv_anti_scaling'], marker: null };
// реально греет → жёлтая
o.heatingIsYellow = cls(trv, {
'climate.trv': { state: 'heat', attributes: { hvac_action: 'heating' } },
'switch.trv_anti_scaling': { state: 'on' },
}) === 'on';
// включена, но не греет (idle) → чёрная, даже со включённой защитой от накипи
o.idleIsDark = cls(trv, {
'climate.trv': { state: 'heat', attributes: { hvac_action: 'idle' } },
'switch.trv_anti_scaling': { state: 'on' },
}) === '';
// выключена → чёрная
o.offIsDark = cls(trv, {
'climate.trv': { state: 'off', attributes: { hvac_action: 'off' } },
}) === '';
// без hvac_action фолбэк на state
o.stateFallback = cls(trv, { 'climate.trv': { state: 'heat', attributes: {} } }) === ''
? false : cls(trv, { 'climate.trv': { state: 'heat', attributes: {} } }) === 'on';
// горящая лампа устройства желтит значок, даже если primary — не она
const lamp = { id: 'l', primary: 'sensor.lamp_power', entities: ['sensor.lamp_power', 'light.lamp'], marker: null };
o.litLightWins = cls(lamp, {
'sensor.lamp_power': { state: '5' },
'light.lamp': { state: 'on' },
}) === 'on';
o.darkLampIdle = cls(lamp, {
'sensor.lamp_power': { state: '5' },
'light.lamp': { state: 'off' },
}) === '';
// «источник света»: умный выключатель с глупыми светильниками — жёлтый от
// того же условия, что и пятно glow
const wall = { id: 'w', primary: 'sensor.w', entities: ['sensor.w'],
marker: { is_light: true, controls: ['switch.fixtures'] } };
o.forcedSourceYellow = cls(wall, {
'sensor.w': { state: '1' }, 'switch.fixtures': { state: 'on' },
}) === 'on';
return o;
});
await finish(browser, checkAll(out));
+81
View File
@@ -0,0 +1,81 @@
// v1.49.x: zoom goes below the base fit, the editor does not shift the plan.
// - the stage height follows the MEASURED header, not a hard-coded 118px, so
// entering an editor keeps the plan inside the viewport;
// - zoom < 1 centres the content instead of pinning it to a corner;
// - the content frame (default zoom) includes devices standing outside rooms.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = {};
// -- editor entry keeps the stage inside the viewport --------------------
const stageBox = () => page.evaluate(() => {
const sr = window.__card.shadowRoot || window.__card.renderRoot;
const b = sr.querySelector('.stage').getBoundingClientRect();
return { top: Math.round(b.top), bottom: Math.round(b.bottom) };
});
const vh = await page.evaluate(() => window.innerHeight);
const inView = await stageBox();
await page.evaluate(() => window.__card._setMode('plan'));
await page.waitForTimeout(400);
const inPlan = await stageBox();
out.viewFitsViewport = inView.bottom <= vh + 2;
out.editorFitsViewport = inPlan.bottom <= vh + 2; // used to overflow by ~90px
out.editorStageShrinks = inPlan.top > inView.top && inPlan.bottom <= inView.bottom + 2;
await page.evaluate(() => window.__card._setMode('view'));
await page.waitForTimeout(300);
// -- zoom out below the base fit -----------------------------------------
out.zoomOut = await page.evaluate(() => {
const c = window.__card;
c._resetZoom();
const fit = { ...c._viewOr(c._baseVb()) };
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
c._zoomAt(stage.clientWidth / 2, stage.clientHeight / 2, 0.5);
const v = { ...c._view };
const base = c._baseVb();
const cx = v.x + v.w / 2, cy = v.y + v.h / 2;
return {
zoom: c._zoom,
wider: v.w > fit.w * 1.9, // actually zoomed out
centredX: Math.abs(cx - (base[0] + base[2] / 2)) < 1, // not pinned to a corner
centredY: Math.abs(cy - (base[1] + base[3] / 2)) < 1,
};
});
out.zoomOutWorks = out.zoomOut.zoom === 0.5 && out.zoomOut.wider
&& out.zoomOut.centredX && out.zoomOut.centredY;
delete out.zoomOut;
out.floorIsHalf = await page.evaluate(() => { window.__card._resetZoom(); const c = window.__card;
c._applyView(0.1); return c._zoom; }) === 0.4; // clamped at the floor
await page.evaluate(() => window.__card._resetZoom());
// -- devices outside rooms stretch the default frame ---------------------
out.devicesStretchFrame = await page.evaluate(() => {
const c = window.__card;
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
cfg.spaces[0].plan_url = null; cfg.spaces[0].plan_aspect = null;
c._serverCfg = cfg; c._model = null;
const before = c._baseVb();
// walk one lamp far outside every room
c._layout = { ...c._layout, d_lamp: { s: 'f1', x: 0.99, y: 0.5 } };
const after = c._baseVb();
return after[0] + after[2] > before[0] + before[2] + 20; // right edge follows the lamp
});
// -- a card BELOW other dashboard content still gets a stage (HP-1500-02) --
out.stageSurvivesContentAbove = await page.evaluate(async () => {
const spacer = document.createElement('div');
spacer.style.height = '900px';
document.body.insertBefore(spacer, document.body.firstChild);
window.dispatchEvent(new Event('resize'));
await new Promise((r) => setTimeout(r, 120));
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const h = sr.querySelector('.stage').getBoundingClientRect().height;
spacer.remove();
window.dispatchEvent(new Event('resize'));
await new Promise((r) => setTimeout(r, 120));
// the old code billed the 900px spacer as "header" and left a 0px stage
return h > 300;
});
await finish(browser, checkAll(out));
File diff suppressed because one or more lines are too long
+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: {}
+230 -92
View File
File diff suppressed because one or more lines are too long
+102 -20
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)
@@ -100,23 +100,36 @@ name?, icon?, model?, link?, description?, pdfs:[{name,url}]}`. A hybrid: auto-d
appear on their own; a marker with `binding=device:<id>` overrides them (metadata/rebinding/hiding),
`entity:<eid>` — for groups/helpers, `virtual` — a manual icon without HA. The marker id = device_id /
`lg_<eid>` / `v_<rand>` (preserves the position in the layout). The binding picker excludes already-placed
references and duplicates by name|area. Manual files: `houseplan/file/set` → `/config/houseplan/files/<id>/`,
served from `/houseplan_files/files/`.
references and duplicates by name|area. Manual files: transactional HTTP upload into `<config>/houseplan/files/<id>/`
(staging `up_*` folders promoted on save), served via signed
`/api/houseplan/content/files/…` urls.
## Server-side configuration (v1.3.0+)
## Server-side configuration (current shape, v1.51+)
`.storage/houseplan.config` (Store):
```json
{ "spaces": [{ "id","title","plan_url","aspect","view_box":[4],"rooms":[{"id","name","area","x","y","w","h"}] }],
"device_overrides": {"<device_id>": {"hidden","icon","name"}},
"virtual_devices": [{"id","space","name","icon","x","y","note?","entity_id?"}],
"settings": {"exclude_integrations":[],"group_lights":true} }
{ "spaces": [{ "id","title","plan_url","plan_aspect","view_box":[4],
"rooms":[{"id","name","area","poly|x/y/w/h","open_to","settings"}],
"openings":[…], "decor":[…], "settings":{…} }],
"markers": [{ "id","binding":"device:<id>|entity:<eid>|virtual","hidden",
"name","icon","display","controls","is_light","tap_action",
"room_id","pdfs",… }],
"settings": { "exclude_integrations":[], "group_lights":true,
"filter_seeded":true, "fill_colors":{…}, "icon_rules":[…],
"known_devices":[…], "new_device_ids":[…] } }
```
All coordinates are **normalized (0..1 of the space plan)**; the render space is
1000 × 1000/aspect. Layout v2: `{device_id: {"s": space, "x", "y"}}` (normalized).
Plan files: `<config>/houseplan/plans/<space>.<ext>` → URL `/houseplan_files/plans/…`.
If the server config is empty, the card falls back to the legacy bundle (the dacha) and shows a
"To server" migration button in edit mode. The dacha was migrated on 2026-07-04.
All coordinates are **normalized (0..1 of the canvas)**; the canvas is always
**square** (v1.48.0), render space `NORM_W × NORM_W` (1000×1000). A space has no
proportions of its own — `plan_aspect` is the IMAGE's ratio, used to letterbox
it centred on the square. The schema bounds geometry to ±4 with strictly
positive sizes (HP-1501/1502). `device_overrides`/`virtual_devices` are long
gone — markers carry everything, `marker.hidden` is the explicit
"hide from plan" flag seeded once by the old filter (docs/FILTERING.md).
Layout v2: `{device_id | rl_<roomId>: {"s": space, "x", "y"}}` (normalized,
bounded ±4). Plan files: `<config>/houseplan/plans/<space>.<token>.<ext>`
(copy-on-write, never overwritten), served via signed
`/api/houseplan/content/plans/_/<name>` urls; growth is bounded by store
quotas, nothing is ever deleted for being old (docs/SCOPE.md).
## Room geometry rules (v1.19–v1.21)
@@ -178,7 +191,38 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
| `houseplan/config/get` | — | `{config, rev}` |
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` — writes `<space>.<token>.<ext>`, deletes nothing |
| `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) |
| `houseplan/plans/list` | — | `{plans: [{name, url, size, modified, used_by}], total}` (newest 60) |
| `houseplan/plans/delete` | `name` | `{ok, removed}` / err `in_use` |
| `houseplan/layout/delete` | `device_id` | `{ok, rev}`; event `houseplan_layout_updated` |
| `houseplan/geometry/repair` | `space_id`, `aspect`, `dry_run?`, `undo?` | preview / `{ok, rev, moved}` / `{restored}`; errs `nothing_to_repair`, `no_backup` |
| `houseplan/files/migrate` | `from_id`, `to_id` | `{mapping}` — COPY, never move |
| `houseplan/files/cleanup` | `marker_id`, `keep?` | replacement-only collection |
| `houseplan/content/sign` | `paths[]` | `{urls}` — authSig for `<image>`/`<a>` fetches |
Manual attachments upload over HTTP (streaming, transactional staging), not WS —
the old `houseplan/file/set` was removed in v1.10.0.
**If the v1.48 migration crashed halfway** (HP-1500-01): the config write
landed, the layout write did not, and both triggers are gone — markers of that
space sit in the old coordinates and nothing in the data can prove it. The
`geom_pending` intent (v1.50.0) prevents this for any future migration, but
cannot help an install that was already stranded. There is no safe automatic
answer — re-transforming a layout that is actually correct would corrupt it —
so the fix is explicit: `houseplan/geometry/repair {space_id, aspect}`
re-applies the transform to that one space's positions. `dry_run: true`
previews, the previous positions ride the same store write as a one-deep
backup, and `undo: true` restores them. Admin-gated like every other write.
**The canvas is square, the image is not** (v1.48.0). A space used to carry an
`aspect`, and coordinates were normalised against it — x by the width, y by the
height. That made every geometric question depend on a per-space number for no
benefit. Now the render space is `NORM_W × NORM_W` and a plan image is fitted
inside it by its own ratio (`fitInSquare`, shared by both renderers), which is
stored as `plan_aspect` so the layout does not jump before the file loads.
Upgrading runs `geometry_migration.migrate_config` once: it pads the old box out
to a square and re-expresses every coordinate against it — a uniform scale plus
an offset in render units, so angles and proportions are exact — and scales
`cell_cm` for tall plans, since the grid pitch is a fraction of the width.
**User content is served inert** (HP-1454-01). An uploaded SVG is the only
thing here that a browser will happily treat as a *document* rather than an
@@ -207,8 +251,37 @@ happen when somebody saves, and a file uploaded into a dialog that was then
cancelled would wait for a write that may never come. A new icon has no id yet, so its
files go to a per-dialog staging folder and move to the real id once the config
write is accepted — the same copy → save → cleanup order as a rebind.
`config/set` collects what its commit superseded, and anything unreferenced and
older than `PLAN_ORPHAN_TTL_S`.
`config/set` collects what its commit superseded — that much a commit knows for
certain. *Unreferenced* is a far weaker signal, and the policy follows from one
asymmetry: **a few unnecessary megabytes can always be removed by hand; a file
we should not have removed cannot be brought back.** When the evidence is weak,
keep the file. Owner's decision, 2026-07-28, after the one-hour rule applied to
every unreferenced file destroyed two detached plans.
The classification is by **owner**, not by "is it referenced". A file leaving
the configuration looks identical whether the plan was replaced, detached, or
its space deleted — and only the first is a deletion the user asked for. Reading
`old_refs - new_refs` and calling it "superseded" deleted a plan the moment it
was detached, under documentation promising the opposite (HP-1465-01).
| Case | What it means | Rule |
|---|---|---|
| Space in both, plan A → plan B | the user picked another image | removed immediately |
| Space in both, plan → none | detached; one click undoes it | **kept** |
| Space gone | deliberate, but the image was imported and may be nowhere else | **kept** |
| Space has a plan, plus another file of its own | an upload whose save was rejected | **kept** — ageing these out raced the retry that referenced them |
| Marker in both, attachment dropped from its list | a trash button, promising nothing | removed immediately |
| Marker gone | same call as a deleted space's plan | **kept** |
| Attachment in `up_*` | a dialog that was never saved; no device owns it | `PLAN_ORPHAN_TTL_S` (1 h) |
| Marker there, file it never listed | a rejected upload | **kept**, same reason |
Nothing is deleted for being old, with one exception: a per-dialog staging
folder (`up_*`), which by construction can only hold an upload from a dialog
that was never saved. The disk therefore stays bounded by the user, not by a
timer — `houseplan/plans/list` shows every stored plan with its size and which
space uses it, and `houseplan/plans/delete` removes one on request, refusing
while a space still references it. That listing is what makes "we never delete"
livable: a detached plan is not lost, it is one click away in the space dialog.
**Config writes are serialized** (HP-1454-03). `_writeConfig()` chains onto a
single promise: one `config/set` in flight, each carrying the revision the
@@ -224,12 +297,21 @@ removes nothing. Deciding what may then go is *not* a client's call — a cleanu
request cannot be ordered against another client's commit, and a delayed one
deletes a plan that was just saved. So `config/set` collects itself, inside its
write lock, from the pair of configurations that bracket the commit
(`plans.collect_plans`): superseded files go immediately, other unreferenced
uploads only once `PLAN_ORPHAN_TTL_S` has passed, since a fresh one may belong
to a transaction still in flight. The `.` between id and token is load-bearing —
(`plans.collect_plans`): a file the commit REPLACED goes immediately, and
nothing else goes at all — see the table above; only a per-dialog staging folder
ages out. Growth is bounded at the door instead, by `plans.check_quota` on every
upload (store size, file count, free disk), because a limit that deletes is how
plans were lost twice. The `.` between id and token is load-bearing —
a space id cannot contain one, so `<space>.<token>.<ext>` can never be confused
with the files of a space whose name merely starts the same way.
**An internal plan url must exist when it is stored** (HP-1470-02). The picker
can attach a plan and then delete it, and two clients can do the same in either
order — the write lock orders the requests but says nothing about whether the
file survived. `config/set` therefore checks every `/api/houseplan/content/plans/`
url against the disk before saving, and refuses with `missing_plan`. External and
legacy urls are the user's own and are never second-guessed.
**Signed content urls are batched, aged and deduplicated** (reviews R2-2, R3-2, R4-2). `ContentSigner`
in `src/signing.ts` is the single implementation, used by both cards; the
duplicate inside houseplan-space-card signed correctly and never handed the
@@ -270,7 +352,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
+437
View File
@@ -1,5 +1,442 @@
# Changelog
## v1.53.0 — 2026-07-30
**A tap can run your automation** (owner's spec)
- **New tap action: "Run automation/script/scene".** We cannot know every
exotic device, but you know what a tap on it should do: pick any
automation, script or scene from a searchable list, and the tap runs it —
`automation.trigger`, `script.turn_on` or `scene.turn_on` per kind, with a
"Started" toast. A script is the idiomatic HA "action", so all three
runnable kinds are offered — no trigger-less dummy automations needed.
Saving refuses a run action without a target; a target deleted later warns
in the dialog and toasts safely on tap.
- **"Ask for confirmation" checkbox** — the accidental-tap guard, available
for any state-changing action: toggle and run alike, wall-switch markers
with bound targets included. The dialog is the card's own (Esc, backdrop
or Cancel = nothing happens), so it works on a wall tablet.
- **Curtains toggle natively:** covers and valves joined the card-wide
toggle domains — with one deliberate exception: garage doors, doors and
gates (cover device classes garage/door/gate) stay OUT of the default
toggle, an accidental tap must not open the driveway. An explicit
per-device toggle remains the owner's conscious choice, and locks/alarm
panels stay untouchable from the plan, as always.
## v1.52.2 — 2026-07-29
**From the v1.52.1 review** (no runtime changes — test and wording quality)
- The plan-editor regression check now targets THE lamp (HP-1521-01): the
old assertion accepted any yellow badge, and the lit socket in the same
fixture would have satisfied it with the lamp fix removed. Verified by
mutation: reverting the v1.52.1 gate makes the smoke fail.
- The one remaining "yellow in every fill mode" wording — the checklist
entry and the _stateClass comment — now states the actual contract: the
lit-source state is computed by the glow-pool condition, and the yellow
badge shows only where the glow spot is not drawn (HP-1521-02).
## v1.52.1 — 2026-07-29
**From the v1.52.0 review**
- **A lit lamp always has exactly one indicator (HP-1520-01).** The glow
layer is hidden in the plan editor, but the yellow suppression still
applied there — a lit lamp showed neither the spot nor the badge. The
suppression gate now equals the layer's actual visibility: wherever the
spot is not drawn, the yellow badge returns.
- **The static card honours a marker's size and rotation (HP-1513-01).** The
same stored marker rendered at base size, unrotated, on the read-only
card. It now mirrors the full card's --dev-scale and angle — geometry
only, still a schematic.
- Documentation caught up with the v1.52.0 colour contract (HP-1520-02):
the RGB-tint expectations in TESTING/UX-MODES and a stale inline comment.
## v1.52.0 — 2026-07-29
**One look for light sources, whatever flipped them** (owner's rule)
- **A lamp's colour lives only in its glow.** The RGB tint of the icon,
border and shadow is gone: depending on whether the state carried colour
data, the same lamp used to land in the "coloured icon on a dark badge" or
the "plain yellow badge" branch — turning one lamp off by tap and the rest
by the wall switch produced visibly different results. The branch is gone.
- **In glow fill the indicator IS the glow spot.** A light source's badge
stays standard, lit or not — the pool of light around it says everything.
A lit socket, fan or kettle keeps its yellow even in glow fill: they cast
no light, the rule is for sources only.
- **In every other fill a lit source is plain yellow**, like a heating
radiator valve — RGB and white lamps alike.
- Icon morphing (the shining-bulb outline) stays in every mode, and the
ripple colour still falls back to the lamp's light colour.
## v1.51.3 — 2026-07-29
- **The icon size multiplier scales the glyph, not just the badge.** Changing
a device's size grew the badge, the ripple and the value badges but left
the icon itself at its default size — a big empty box around a small glyph
(user report). The glyph now derives from the same per-device size as
everything else and keeps its proportion at any multiplier and zoom.
## v1.51.2 — 2026-07-29
**From the v1.51.1 review**
- **The auto grid is the same on both cards (HP-1511-01).** The full card
reserves grid cells for hidden devices (their ghosts keep a place in the
device editor); the static card compacted the grid over visible ones only,
so a freshly discovered marker with no saved position landed in different
spots on the two cards. The static card now feeds the full roster to the
same grid and still draws only the visible.
- **A ripple-display ghost keeps its base icon (HP-1511-02).** Hidden markers
with the "ripple" presentation rendered as an icon-less inactive pulse —
unrecognisable in the editor. A ghost now drops the display dressing
entirely: base icon and name, whatever the display mode.
## v1.51.1 — 2026-07-29
**From the v1.51.0 review**
- **The static card counts hidden devices in room LQI again (HP-1510-01).**
Its visibility filter had quietly become the aggregation filter: the same
room showed different Zigbee health on the two cards. Aggregation and
rendering use separate lists now — hidden devices count toward signal on
both cards, are drawn on neither, and still cast no light.
- **A ghost shows no live numbers (HP-1510-02).** A hidden device in "Show
hidden" suppressed the state colors but still painted its value text,
temperature, humidity, LQI badge and state-morphed icon. All of that is
gone: the ghost keeps only the base icon and name — enough to recognise it
and open the dialog.
## v1.51.0 — 2026-07-29
**Hiding is an explicit flag now** (docs/FILTERING.md)
- **Every device dialog — virtual ones included — has a "Hide device from
plan" checkbox.** The old on-the-fly filter survives only as the SEEDER of
those flags: on first load by an editing client the config is materialised
once — non-physical devices (bridges, scenes, service integrations, lamps
folded into a light group) get the flag, and from then on the flag belongs
to you. Unticking it is final: the seeder never revisits a device you have
decided about. New non-physical devices hide silently; physical ones keep
the red-dot flow.
- **"Show all" became "Show hidden"** — a local tool of the device editor
(nothing flips on the wall tablets), showing hidden devices as translucent
BLUE dashed ghosts: clearly apart from a grey unavailable icon, and with no
live-state paint at all — a ghost is configuration, not status. Click one
to untick the box. "Remove from plan" is gone for bound devices (the
checkbox is the way); a virtual device's Delete still deletes.
- Hidden devices still count toward the room's Zigbee signal, but cast no
glow and no light fill — an invisible device casts no visible light. Room
climate is unchanged. Old configs behave exactly as before until an
editing client materialises them.
**Yellow means working right now**
- One principle for the glowing icon: a light is shining, a socket is
powering, a fan is spinning, media is playing, a vacuum is cleaning — or a
radiator valve is ACTUALLY heating (hvac_action), not merely enabled for
the winter. Previously a TRV could glow yellow because its anti-scaling
service switch was on while the actually-heating one stayed dark: the
primary-entity search let a vendor's config switch outrank the visible
climate entity. Fixed — a service entity never beats the device's visible
main function (this also fixes tap-toggle and icon morphing on such
devices).
- The glow pool and the icon color now ask the same question: a lit light
yellows its icon in every fill mode, by exactly the condition that lights
its glow spot. The README (en+ru) documents the color language.
**The editors, on a phone**
- Pinch zoom and pan gestures now work in every editor: drawing is
click-based, so the two coexist — a moving finger pans, two fingers pinch,
releasing after a gesture never draws a point, a clean tap still does.
**The room settings button**
- Detached from the (movable) room name: it sits at the VISUAL centre of the
room — the centre of the largest inscribed circle with a pull toward the
area centroid, so an elongated room centres it on both axes and an L-shaped
one keeps it in the middle of its widest part, never down a thin limb.
- Half its former size, sized from the device icon (70% of the icon box) and
zooming WITH the plan instead of keeping a constant screen size.
- The small metric rows under the room name (temperature, humidity, signal,
lights) now show in the plan editor too, and the name renders in exactly
the same spot in view mode and in the editor.
## v1.50.4 — 2026-07-29
**From the v1.50.3 review**
- **Both cards build their model with the same code now (HP-1503-01).** The
full card carried a hand-copied twin of the shared model builder, and the
twin missed the legacy-store fallbacks v1.50.3 added — the same broken
store rendered fine in the static card and as a blank `viewBox="0 0 0 0"`
in the main one. The duplicate is gone: the full card calls the shared
builder and only swaps in the raw plan url its signing flow needs. A new
smoke runs the audit's exact legacy vector through both models and both
DOM trees and asserts parity.
## v1.50.3 — 2026-07-29
**From the v1.50.2 review**
- **A size is not a coordinate (HP-1502-01).** The ±4 bound from v1.50.2
treated all four view_box elements and room w/h alike, so `[0, 0, 0, 0]`
and negative sizes still passed — and a zero axis serialises into
`viewBox="0 0 0 0"`, a blank plan on every client, with the static card
computing `aspect-ratio: 0 / 0` on top. Sizes now get their own validator:
strictly positive, floored at one thousandth of the canvas; coordinates may
still be negative, because a crop origin legitimately sits past the edge.
And since a store may already hold a broken viewport from before, both
cards fall back to the whole canvas instead of a blank screen, and a legacy
rectangle with a negative size is read as the same rectangle drawn from the
other corner.
**Also in this release**
- The room settings button moved to the bottom of the room card, and the room
name renders in exactly the same spot in view mode and in the plan editor —
the button and the metrics no longer take part in the label's centring.
## v1.50.2 — 2026-07-29
**From the v1.50.1 review**
- **Geometry magnitudes are bounded on both layers (HP-1501-01).** v1.50.1
bounded layout positions, but room rectangles, polygon vertices, view_box
and opening coordinates still took any finite float — one schema-valid 1e100
vertex framed the space so wide the plan was a dot, for every client, and
the server stored it as a perfectly good configuration. The config schema
now bounds geometry to ±4 (angles to ±360°), and the content frame applies
the same canvas envelope to room vertices it already applied to device
positions — so a store that already holds an absurd coordinate from before
this door existed still renders: the point draws wherever it is, it just no
longer commands the frame. A vertex a bit past the canvas edge keeps
working.
- **A no-op repair no longer eats the undo backup (HP-1501-02).** A typo'd
space id "succeeded" with moved: 0 — and its empty result replaced the
one-deep backup, destroying the only way back exactly when it was needed
most: right after repairing the wrong space. Matching nothing is an error
now (`nothing_to_repair`); nothing is written, the revision does not move,
and the previous repair stays undoable.
## v1.50.1 — 2026-07-29
**From the v1.50.0 review**
- **A card below other dashboard content gets its stage back (HP-1500-02).**
The v1.50.0 height measurement used the absolute document coordinate, so a
tall card before this one was billed as "header" and the stage collapsed to
zero. The card now measures only its own chrome plus a bounded allowance for
what the dashboard keeps above it, and re-measures on window resize; the
listener is removed on teardown.
- **The content frame can no longer be degenerate or absurd (HP-1500-03).**
A lone marker in an empty space produced a zero-area viewBox — a blank
scene; a single stored coordinate like 1e100 (any finite float passed
validation) stretched the frame until the plan was a dot, for every viewer
of the space. A near-zero axis now opens up to a floor of canvas around the
marker, points far outside the canvas envelope no longer command the frame
(they still render where they are), and the server refuses layout
coordinates outside ±4 — generous slack for an icon dragged past an edge,
not an envelope for absurdity. A real thin room keeps its tight frame, and
the gate sensor slightly past the edge still counts.
- **A repair path for installs stranded by the v1.48 migration window
(HP-1500-01).** If the old migration crashed between its two writes, the
markers of a space are left in the old coordinates with nothing in the data
able to prove it — and re-transforming a correct layout would corrupt it, so
nothing automatic is safe. `houseplan/geometry/repair {space_id, aspect}` is
the explicit answer: `dry_run` previews the exact moves, the previous
positions ride the same store write as a one-deep backup, `undo` restores
them, and routine drags no longer erase that backup. The v1.50.0
`geom_pending` protocol already protects every future migration; this covers
the installs it was too late for.
## v1.50.0 — 2026-07-28
**Owner's batch**
- **The default zoom counts devices as content.** They are allowed to stand
outside every room — a gate sensor by the fence, a camera on a pole — and the
opening view now includes them, even on a space with no rooms at all.
- **Entering an editor no longer shifts the plan.** The stage height assumed a
fixed 118px of header, and the editor header is taller: the scene slid down
by the difference and its bottom went below the fold. The card measures where
the stage actually starts and gives it the rest of the viewport.
- **The zoom goes out as well as in.** Down to 0.4×, and zoomed out the plan
floats centred instead of being pinned to a corner.
**From the v1.49.0 review**
- **The square-canvas migration survives a crash between its two writes
(HP-1490-01).** Config and layout live in separate stores, written one after
the other, and the first write deleted the very fields the second needed — a
failure between them stranded markers in the old coordinates for good. The
migration intent is durable now, saved before anything moves and cleared by
the layout write itself; whichever half is missing after a crash, the next
start finishes exactly that half, once.
- **Parallel uploads cannot slip past the store quota together (HP-1490-02).**
N uploads all measured the store before any of them wrote, and all passed a
limit only one of them fit under. The measure-and-write pair is one atomic
step under its own lock — separate from the config lock, so a slow directory
scan does not stall saves.
- **The editors see the whole canvas again (HP-1490-03).** The content frame
also bounded pan, zoom and pointer maths, so after the first room there was
nowhere left to draw the second one. Edit modes now measure from the full
square; the view keeps its content fit, and switching modes refits instead of
carrying a view clamped against the wrong base.
- **Save waits for the proportions of a picked plan (HP-1490-04).** Saving
before the image had answered used to ship the PREVIOUS file's ratio, and the
new plan kept the old shape for good. Picking a plan clears the old ratio at
once, and Save awaits the bounded read; if it fails, "unknown" is stored —
a square fallback is honest, an inherited ratio is not.
- Release hygiene from §5: package-lock.json caught up with the package
version, and a duplicated comment in space-geometry.ts is gone.
## v1.49.0 — 2026-07-28
**The canvas is square** (see v1.48.0, released together with this one).
- **Zoom opens on what is drawn, not on the whole canvas.** A space without a
background image now fits its rooms with a 5% margin, so a small plan on a big
canvas fills the screen instead of sitting in the middle of it as a speck.
With a background image nothing changes: the image is the plan, and cropping
to the rooms would hide the parts nobody has outlined yet.
- **Switching spaces by swipe, or on the kiosk carousel, slides.** The plan
leaves the way the finger went and the next one arrives from the other side.
Respects "reduce motion".
- The room settings button says "Room settings" rather than just "Room", and
lightens slightly under the cursor.
- "Curation" is called filtering everywhere — the interface, the documentation
and the code.
**From the v1.47.0 review**
- **A plan you have just picked can no longer be deleted from the same dialog
(HP-1470-02).** It was not saved yet, so the server correctly considered it
free — and the save then stored a url with no file behind it. The button is
disabled now, and, because two clients can do the same in either order, the
server checks every internal plan url a configuration adds against the disk
and refuses a new reference that is already broken. A url the stored
configuration already carries is let through — a file can vanish from outside
Home Assistant, and refusing then would block the very edit that detaches it.
Urls that are not ours are left alone.
- **Uploads are bounded (HP-1470-01).** Nothing is deleted for being old — that
cost real plans twice — so the limit sits where a decision is being made
anyway: an upload is refused if the store would pass 256 MB or 200 plans
(1 GB / 1000 for attachments), or if the disk would drop below 512 MB free.
The plan list is capped at the 60 newest and its thumbnails load lazily.
- **Picking a saved plan reads its real proportions (HP-1470-03).** The card
waited for nothing and, when the signature for the protected url had not
arrived yet, saved a fallback ratio — a square plan came out stretched. It now
waits for the signature, ties the result to the dialog that asked, and the
preview in the dialog is signed like everything else.
## v1.48.0 — 2026-07-28 (the canvas is always square)
- **A space no longer has proportions of its own.** The drawing area is a square;
a plan image keeps its own shape and is centred inside it, so a wide plan gets
margins above and below and a tall one gets them at the sides. There is
nothing left to choose — the canvas orientation setting for hand-drawn spaces
is gone with it.
- **Existing plans are migrated once, on upgrade.** Nothing about a drawing
changes: the box is padded out to a square and every coordinate is
re-expressed against it — rooms, doors and windows, decor, marker positions
and the saved viewport. Angles, room proportions and relative positions are
preserved exactly. For a tall plan the scale in centimetres per grid cell is
adjusted along with it, because the grid is tied to the width; without that a
wall would silently measure less than it does.
## v1.47.0 — 2026-07-28 (pick a plan you already uploaded)
- **The space dialog can now show the plans stored on the server.** Detaching a
plan keeps the image on disk — that has been the rule since v1.46.4, but until
now the only way back was to find the original file on your computer and
upload it again. "Already uploaded" lists what is there, with a thumbnail, the
file size and which space uses it. One click attaches it; the aspect ratio is
read from the image, exactly as on upload.
- **And it is where you delete one.** A plan file is never removed automatically
— not for being detached, not for being old — which is only a sensible policy
if you can see what is being kept and get rid of it deliberately. The trash
button does that, and refuses while a space still uses the plan: the answer to
"may this go" comes from the stored configuration, not from the browser.
- Documentation caught up with the code: several comments still described the
age-based collection that v1.46.6 removed.
## v1.46.6 — 2026-07-28 (the detach promise, actually kept this time)
- **Switching a space to "draw" no longer deletes its image.** v1.46.4 and
v1.46.5 said it did not, and the scheduled cleanup indeed left detached plans
alone — but the save itself deleted the file the moment the reference was
cleared, before any of those guards were reached. The cause: a file that left
the configuration was called "superseded", and from that difference alone
replacing a plan, detaching one and deleting its space are indistinguishable.
Only the first is a deletion anybody asked for. The transition is now
classified by the space that owned the file, and the same distinction applies
to attachments: dropping one from a device that still exists removes it,
deleting the device keeps its manuals.
- **A plan whose space was deleted is kept**, rather than the thirty days
v1.46.5 promised — thirty days measured from the file's age is meaningless
anyway, since it was usually uploaded months earlier.
- **Nothing is deleted for being old any more**, except a per-dialog staging
folder. The rule that aged out "rejected uploads" turned out to race a retry:
the cleanup removed the file from a failed save while the next attempt was
committing a reference to it. A rule that can delete a file somebody is about
to point at is not worth the disk it reclaims. Files therefore go when an
action says so, and otherwise stay.
## v1.46.5 — 2026-07-28 (audit of every automatic deletion)
- **A detached plan is never deleted, at any age.** v1.46.4 gave it a month;
this makes it permanent and writes the reason down where the next change will
see it. The rule, now in docs/SCOPE.md: the component may delete a file only
when a user action says so — replacing a plan, removing an attachment,
deleting a device. "Nothing points at this any more" is not such an action.
The errors are not symmetrical: wasted disk is visible, cheap and reversible;
a deleted file is none of those.
- **`houseplan/files/cleanup` no longer takes a folder on the client's word.**
After a device is rebound its files are copied to the new id and the old
folder is dropped — with `rmtree`, on whatever id the card sent. Two ways that
ends badly: a partial copy leaves some urls still pointing into that folder
(the migration deliberately does not rewrite those, so they were live links to
files being deleted), and a wrong or stale id from any client would destroy a
live device's manuals. The server now checks the stored configuration itself,
under the config lock, and removes only files nothing references.
- **A plan of a space that was deleted waits thirty days instead of an hour.**
Deleting a space is deliberate, but an hour is a short window in which to
notice it was a misclick.
## v1.46.4 — 2026-07-28 (data loss: detached plans were collected as garbage)
- **A plan you detach is no longer deleted an hour later.** Switching a space to
"draw" clears the reference and, as the editor has always said, leaves the
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,
+441
View File
@@ -6,6 +6,447 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
## v1.53.0 — 2026-07-30
**Нажатие может запускать вашу автоматизацию** (ТЗ владельца)
- **Новое действие по нажатию: «Запустить автоматизацию/скрипт/сцену».** Мы
не можем знать каждое экзотическое устройство, но вы знаете, что должен
делать тап по нему: выберите любую автоматизацию, скрипт или сцену из
списка с поиском — тап её запустит (`automation.trigger`,
`script.turn_on` или `scene.turn_on` по типу цели, с тостом «Запущено»).
Скрипт — идиоматичное «действие» HA, поэтому предлагаются все три
запускаемых типа: не нужны автоматизации-пустышки без триггера.
Сохранение без цели блокируется; удалённая позже цель подсвечивается в
диалоге и безопасно отвечает тостом при тапе.
- **Чекбокс «Спрашивать подтверждение»** — защита от случайных нажатий для
любого действия, меняющего состояние: и toggle, и запуск, включая
настенные выключатели с привязанными целями. Диалог собственный (Esc,
клик мимо или «Отмена» — ничего не происходит), поэтому работает и на
настенном планшете.
- **Шторы переключаются нативно:** cover и valve добавлены в домены
card-wide toggle — с одним осознанным исключением: гаражные ворота, двери
и калитки (классы garage/door/gate) остаются ВНЕ дефолтного переключения —
случайный тап не должен открыть гараж. Явный per-device toggle остаётся
осознанным выбором владельца, а замки и охранные панели, как и всегда,
с плана неприкосновенны.
## v1.52.2 — 2026-07-29
**По ревью v1.52.1** (рантайм не менялся — качество теста и формулировок)
- Проверка регресса в редакторе плана целится ИМЕННО в лампу (HP-1521-01):
старый ассерт принимал любой жёлтый значок, и горящая розетка из той же
фикстуры прошла бы его даже без фикса лампы. Проверено мутацией: откат
гейта v1.52.1 валит смок.
- Последняя формулировка «жёлтый в любом режиме» — пункт чек-листа и
комментарий в _stateClass — приведена к фактическому контракту: состояние
источника считается условием glow-пула, а жёлтая подложка показывается
только там, где пятно не рисуется (HP-1521-02).
## v1.52.1 — 2026-07-29
**По ревью v1.52.0**
- **У горящей лампы всегда ровно один индикатор (HP-1520-01).** В редакторе
плана glow-слой скрыт, но подавление жёлтого там всё равно действовало —
горящая лампа не показывала ни пятна, ни подложки. Гейт подавления теперь
равен фактической видимости слоя: где пятно не рисуется, жёлтая подложка
возвращается.
- **Статичная карточка уважает размер и поворот маркера (HP-1513-01).** Один
и тот же маркер рендерился на read-only карточке базовым и неповёрнутым.
Теперь она зеркалит --dev-scale и угол полной карточки — только геометрия,
по-прежнему схема.
- Документация догнала цветовой контракт v1.52.0 (HP-1520-02): ожидания
RGB-окраски в TESTING/UX-MODES и устаревший комментарий в коде.
## v1.52.0 — 2026-07-29
**Один вид источников света, чем бы их ни переключали** (правило владельца)
- **Цвет лампы живёт только в её свечении.** RGB-окраска иконки, рамки и
тени убрана: в зависимости от того, пришли ли с состоянием данные о
цвете, одна и та же лампа попадала то в ветку «цветная иконка на тёмной
подложке», то в «жёлтая подложка» — выключение одной лампы тапом и
остальных выключателем выглядело по-разному. Ветки больше нет.
- **В режиме glow индикатор — само пятно света.** Подложка источника всегда
стандартная, горит он или нет — всё говорит свет вокруг. Розетка,
вентилятор или чайник остаются жёлтыми и в glow: они не светят, правило —
только для источников.
- **В остальных режимах горящий источник — просто жёлтый**, как греющая
термоголовка; RGB и обычные лампы одинаково.
- Морфинг иконки (контур «лампа с лучами») остаётся во всех режимах, а цвет
пульсации по-прежнему берёт фолбэком цвет свечения лампы.
## v1.51.3 — 2026-07-29
- **Множитель «размер значка» масштабирует и сам глиф, а не только
подложку.** Изменение размера устройства растило подложку, пульсацию и
бейджи значений, но иконка внутри оставалась дефолтной — большая пустая
рамка вокруг маленького глифа (репорт пользователя). Теперь глиф считается
от того же поустройственного размера, что и всё остальное, и держит
пропорцию при любом множителе и зуме.
## v1.51.2 — 2026-07-29
**По ревью v1.51.1**
- **Авто-сетка одинакова на обеих карточках (HP-1511-01).** Полная карточка
резервирует клетки сетки за скрытыми устройствами (их призраки держат
место в редакторе); статичная уплотняла сетку по одним видимым — свежее
устройство без сохранённой позиции вставало на разных местах двух
карточек. Теперь статичная передаёт сетке полный список и по-прежнему
рисует только видимых.
- **Ripple-призрак сохраняет базовую иконку (HP-1511-02).** Скрытый маркер с
отображением «пульсация» рендерился безликим неподвижным пульсом — в
редакторе его нельзя было узнать. Призрак теперь полностью снимает
«одежду» отображения: базовая иконка и имя, каким бы ни был режим.
## v1.51.1 — 2026-07-29
**По ревью v1.51.0**
- **Статичная карточка снова учитывает скрытые устройства в LQI комнаты
(HP-1510-01).** Её фильтр видимости незаметно стал фильтром агрегации: одна
и та же комната показывала разное Zigbee-здоровье на двух карточках. Теперь
агрегация и отрисовка используют разные списки — скрытые устройства
считаются в сигнале на обеих карточках, не рисуются ни на одной и
по-прежнему не дают света.
- **Призрак не показывает живых чисел (HP-1510-02).** Скрытое устройство в
«Показать скрытые» подавляло цвета состояний, но по-прежнему рисовало
значение, температуру, влажность, бейдж LQI и морф иконки. Всё это убрано:
у призрака только базовая иконка и имя — достаточно, чтобы узнать
устройство и открыть диалог.
## v1.51.0 — 2026-07-29
**Скрытие — теперь явная галка** (docs/FILTERING.md)
- **В диалоге каждого устройства — включая виртуальные — есть галка «Скрыть
устройство с плана».** Старый фильтр «на лету» остался только сеятелем
этих галок: при первом открытии редактором конфиг один раз
материализуется — нефизические устройства (мосты, сцены, служебные
интеграции, лампы, свёрнутые в световую группу) получают галку, и дальше
она принадлежит вам. Снятая галка — навсегда: сеятель не возвращается к
устройству, о котором вы уже решили. Новые нефизические прячутся молча;
физические — с красной точкой, как раньше.
- **«Показать все» стала «Показать скрытые»** — локальный инструмент
редактора устройств (на настенных планшетах ничего не мигает): скрытые
рисуются полупрозрачными СИНИМИ пунктирными призраками — не спутать с
серым недоступным — и совсем без живой индикации: призрак — это
конфигурация, а не статус. Клик — и галку можно снять. «Удалить с плана»
у привязанных устройств убрана (галка — единственный путь); «Удалить» у
виртуального по-прежнему удаляет.
- Скрытые устройства учитываются в Zigbee-сигнале комнаты, но не дают ни
пятна света, ни заливки — не видно устройства, не видно и света. Климат
комнат без изменений. Старые конфиги ведут себя по-старому, пока их не
материализует клиент с правом записи.
**Жёлтый значит «работает прямо сейчас»**
- Один принцип для светящегося значка: лампа светит, розетка подаёт,
вентилятор крутится, медиа играет, пылесос убирает — или термоголовка
РЕАЛЬНО греет (hvac_action), а не просто включена на зиму. Раньше
термоголовка могла светиться жёлтым из-за включённой защиты от накипи, а
реально греющая оставалась тёмной: выбор главной сущности пропускал
служебный свитч производителя вперёд видимого climate. Исправлено —
служебная сущность больше никогда не побеждает видимую основную функцию
(это чинит и tap-переключение, и морфинг иконок у таких устройств).
- Пятно света и цвет значка задают один и тот же вопрос: горящая лампа
желтит значок в любом режиме заливки ровно тем условием, что зажигает её
пятно. README (en+ru) описывает язык цветов.
**Редакторы на телефоне**
- Пинч-зум и панорамирование жестами работают во всех редакторах: рисование
кликовое, так что они совместимы — палец с движением панорамирует, два
пальца зумируют, отпускание после жеста не ставит точку, чистый тап —
ставит.
**Кнопка настроек комнаты**
- Отвязана от (передвигаемого) названия: стоит в ВИЗУАЛЬНОМ центре комнаты —
центр наибольшей вписанной окружности с тяготением к центроиду площади,
поэтому вытянутая комната центрирует её по обеим осям, а Г-образная
держит в середине широкой части и не утаскивает в узкую.
- Вдвое меньше прежней, размер от иконки устройства (70% бокса) и зумится
ВМЕСТЕ с планом, а не держит постоянный экранный размер.
- Мелкие подписи под названием комнаты (температура, влажность, сигнал,
свет) теперь видны и в редакторе плана, а название рендерится ровно в том
же месте в просмотре и в редакторе.
## v1.50.4 — 2026-07-29
**По ревью v1.50.3**
- **Обе карточки строят модель одним кодом (HP-1503-01).** Полная карточка
носила рукописную копию общего построителя модели, и копия не получила
фолбэки для legacy-store из v1.50.3 — один и тот же битый store в
статической карточке рендерился починенным, а в основной — пустым
`viewBox="0 0 0 0"`. Дубликат удалён: полная карточка вызывает общий
построитель и лишь подменяет сырой url плана, который нужен её подписи.
Новый смок прогоняет точный вектор аудита через обе модели и оба
DOM-дерева и проверяет паритет.
## v1.50.3 — 2026-07-29
**По ревью v1.50.2**
- **Размер — не координата (HP-1502-01).** Граница ±4 из v1.50.2 мерила все
четыре элемента view_box и w/h комнат одинаково, поэтому `[0, 0, 0, 0]` и
отрицательные размеры всё ещё проходили — а нулевая ось сериализуется в
`viewBox="0 0 0 0"`: пустой план у всех клиентов, и `aspect-ratio: 0 / 0` у
статичной карточки сверху. У размеров теперь свой validator: строго
положительные, пол — одна тысячная холста; координаты по-прежнему могут
быть отрицательными — начало кропа законно выходит за край. А поскольку в
сторе битый viewport может уже лежать, обе карточки падают на полный холст
вместо пустого экрана, и legacy-прямоугольник с отрицательным размером
читается как тот же прямоугольник, нарисованный из другого угла.
**Также в этом релизе**
- Кнопка настроек комнаты переехала в самый низ карточки комнаты, а имя
комнаты отображается ровно в одном и том же месте в просмотре и в
редакторе плана — кнопка и метрики больше не участвуют в центрировании.
## v1.50.2 — 2026-07-29
**По ревью v1.50.1**
- **Величины геометрии ограничены на обоих слоях (HP-1501-01).** v1.50.1
ограничила позиции устройств, но прямоугольники комнат, вершины полигонов,
view_box и координаты проёмов всё ещё принимали любое конечное число — одна
проходящая схему вершина 1e100 растягивала кадр так, что план становился
точкой у всех клиентов, и сервер хранил это как вполне корректную
конфигурацию. Теперь схема конфига ограничивает геометрию ±4 (углы ±360°),
а рамка содержимого применяет к вершинам комнат тот же конверт холста, что
уже применяла к позициям устройств — конфиг, где абсурдная координата уже
лежит с прежних времён, всё равно рендерится: точка рисуется, где стоит,
просто кадром больше не командует. Вершина чуть за краём холста работает
как раньше.
- **Пустой repair больше не съедает бэкап (HP-1501-02).** Опечатка в space_id
«успешно» отвечала moved: 0 — и её пустой результат заменял бэкап глубиной
один, уничтожая единственный путь назад ровно тогда, когда он нужнее всего:
сразу после починки не того пространства. Теперь «нечего чинить» — ошибка
(`nothing_to_repair`): ничего не пишется, ревизия не растёт, предыдущий
repair по-прежнему отменяем.
## v1.50.1 — 2026-07-29
**По ревью v1.50.0**
- **Карточка ниже другого контента дашборда снова получает сцену
(HP-1500-02).** Замер высоты в v1.50.0 брал абсолютную координату документа:
высокая карточка перед этой записывалась в «шапку», и сцена схлопывалась в
ноль. Теперь меряется только собственная обвязка плюс ограниченная поправка
на то, что дашборд держит сверху; перемер — по resize окна, слушатель
снимается при демонтаже.
- **Рамка содержимого больше не бывает вырожденной или абсурдной
(HP-1500-03).** Одинокий значок в пустом пространстве давал viewBox нулевой
площади — пустую сцену; одна сохранённая координата вида 1e100 (любое
конечное число проходило проверку) растягивала рамку так, что план
становился точкой — у всех зрителей пространства. Почти нулевая ось теперь
раскрывается до минимального кадра вокруг значка, точки далеко за холстом
рамкой не командуют (рисуются, где стоят), а сервер отклоняет координаты вне
±4 — щедрый запас для значка, утащенного за край, но не для абсурда. Узкая
комната сохраняет тесный кадр, датчик калитки чуть за краём по-прежнему
учитывается.
- **Путь восстановления для установок, застрявших в окне миграции v1.48
(HP-1500-01).** Если старая миграция упала между двумя записями, значки
пространства остаются в старых координатах, и по данным это недоказуемо — а
повторное преобразование правильных координат их испортит, поэтому ничего
автоматического тут быть не может. Явный ответ —
`houseplan/geometry/repair {space_id, aspect}`: `dry_run` показывает точные
перемещения, прежние позиции уезжают той же записью хранилища как резервная
копия глубиной один, `undo` их возвращает, и обычные перетаскивания эту
копию больше не затирают. Протокол `geom_pending` из v1.50.0 уже защищает
все будущие миграции; это — для тех, кому он опоздал.
## v1.50.0 — 2026-07-28
**Задачи владельца**
- **Масштаб по умолчанию считает устройства содержимым.** Им можно стоять вне
комнат — датчик калитки у забора, камера на столбе — и стартовый вид теперь
включает их, даже в пространстве совсем без комнат.
- **Вход в редактор больше не сдвигает план.** Высота сцены считалась как
«экран минус 118px шапки», а шапка редактора выше: сцена уезжала вниз на
разницу, низ пропадал за краём. Карточка измеряет, где сцена начинается на
самом деле, и отдаёт ей остаток экрана.
- **Масштаб теперь и отдаляется.** До 0.4×; в отдалении план висит по центру,
а не прилипает к углу.
**По ревью v1.49.0**
- **Миграция на квадратный холст переживает сбой между двумя записями
(HP-1490-01).** Конфиг и позиции живут в разных хранилищах, пишутся по
очереди, и первая запись удаляла именно те поля, которые нужны второй — сбой
между ними навсегда оставлял значки в старых координатах. Теперь намерение
миграции сохраняется до того, как что-либо меняется, и снимается той же
записью, что сохраняет позиции: какая половина не успела — ту следующий
запуск и доделает, ровно один раз.
- **Параллельные загрузки не проскакивают квоту вместе (HP-1490-02).** N
загрузок мерили хранилище до того, как любая из них записала файл, и все
проходили предел, под который помещалась одна. Замер и запись — один
атомарный шаг под отдельным замком; отдельным — чтобы медленный обход папки
не тормозил сохранения конфига.
- **Редакторы снова видят весь холст (HP-1490-03).** Рамка содержимого
ограничивала и панорамирование, и координаты указателя, так что после первой
комнаты рисовать вторую было негде. Режимы редактирования меряют от полного
квадрата; просмотр остаётся по содержимому, а смена режима пересчитывает
вид, вместо того чтобы тащить его прижатым не к той основе.
- **Сохранение ждёт пропорции выбранного плана (HP-1490-04).** Save до ответа
картинки записывал пропорции ПРЕДЫДУЩЕГО файла, и новый план навсегда
оставался в чужой форме. Выбор плана сразу стирает старое значение, Save
дожидается ограниченного по времени чтения; не дождался — записывается
«неизвестно»: честный квадрат лучше унаследованной формы.
- Гигиена релиза из §5: package-lock.json догнал версию пакета, задвоенный
комментарий в space-geometry.ts убран.
## v1.49.0 — 2026-07-28
**Холст стал квадратным** (см. v1.48.0, выпущена вместе с этой).
- **Масштаб открывается по нарисованному, а не по всему холсту.** Пространство
без подложки теперь вписывается по границам своих комнат с полями 5%: маленький
план на большом холсте заполняет экран, а не сидит посередине точкой. С
подложкой ничего не меняется — картинка и есть план, и обрезать её по комнатам
значило бы спрятать то, что ещё не обведено.
- **Переключение пространств свайпом и в киоске стало с анимацией.** План
уезжает туда, куда пошёл палец, следующий приходит с другой стороны.
Уважает системную настройку «уменьшить движение».
- Кнопка настроек комнаты подписана «Настройки комнаты», а не просто «Комната»,
и слегка светлеет под курсором.
- Слово «курирование» заменено на «фильтрацию» — в интерфейсе, документации и
коде.
**По ревью v1.47.0**
- **Только что выбранный план больше нельзя удалить из того же диалога
(HP-1470-02).** Он ещё не сохранён, поэтому сервер справедливо считал его
свободным — а сохранение потом записывало ссылку, за которой нет файла.
Кнопка заблокирована, и, поскольку два клиента могут сделать это в любом
порядке, сервер сверяет с диском каждую внутреннюю ссылку, которую
конфигурация добавляет, и отказывает, если файла нет. Ссылку, уже записанную
в конфигурации, он пропускает: файл может исчезнуть и мимо Home Assistant, а
отказ заблокировал бы ровно ту правку, которая его отцепляет. Чужие ссылки не
трогаются.
- **Загрузки ограничены (HP-1470-01).** По возрасту не удаляется ничего — это
дважды стоило настоящих планов, — поэтому предел стоит там, где решение и так
принимается: загрузка отклоняется, если хранилище перевалит за 256 МБ или 200
планов (1 ГБ и 1000 для вложений) либо если на диске останется меньше 512 МБ.
Список планов отдаётся по 60 самых свежих, миниатюры грузятся лениво.
- **Выбор сохранённого плана читает его настоящие пропорции (HP-1470-03).**
Карточка ничего не ждала и, если подпись для защищённой ссылки ещё не пришла,
записывала пропорции «по умолчанию» — квадратный план получался растянутым.
Теперь она дожидается подписи, привязывает результат к тому диалогу, который
спрашивал, а превью в диалоге подписывается, как и всё остальное.
## v1.48.0 — 2026-07-28 (холст всегда квадратный)
- **У пространства больше нет собственных пропорций.** Область рисования —
квадрат, а картинка плана сохраняет свою форму и вписывается в него по
центру: у широкого плана появляются поля сверху и снизу, у вытянутого — по
бокам. Выбирать нечего, поэтому настройка ориентации холста для пространств
без картинки убрана.
- **Существующие планы переносятся один раз, при обновлении.** В самом рисунке
ничего не меняется: коробка дополняется до квадрата, и все координаты
пересчитываются относительно неё — комнаты, двери и окна, декор, позиции
значков и сохранённая область просмотра. Углы, пропорции комнат и взаимное
расположение сохраняются точно. Для вытянутых планов заодно пересчитывается
масштаб в сантиметрах на клетку: сетка привязана к ширине, и без этого стены
молча стали бы короче.
## v1.47.0 — 2026-07-28 (выбор из уже загруженных планов)
- **Диалог пространства показывает планы, сохранённые на сервере.** Отцепление
плана оставляет картинку на диске — так с v1.46.4, но вернуть её можно было
только найдя исходный файл у себя и загрузив заново. «Уже загруженные»
показывают, что есть: миниатюра, размер файла и то, какое пространство его
использует. Клик прикрепляет, пропорции читаются из самой картинки — так же,
как при загрузке.
- **Там же план и удаляется.** Файл плана никогда не удаляется автоматически —
ни за отцепление, ни за возраст, — и это разумная политика ровно до тех пор,
пока видно, что именно хранится, и есть способ убрать это осознанно. Кнопка
корзины делает это и отказывает, пока план используется пространством: ответ
на вопрос «можно ли удалить» даёт сохранённая конфигурация, а не браузер.
- Документация догнала код: несколько комментариев всё ещё описывали удаление по
возрасту, убранное в v1.46.6.
## v1.46.6 — 2026-07-28 (обещание про отцепление, теперь выполненное)
- **Переключение пространства в «нарисовать» больше не удаляет его картинку.**
v1.46.4 и v1.46.5 утверждали, что не удаляет, и плановая уборка действительно
отцеплённые планы не трогала — но само сохранение удаляло файл в тот момент,
когда снималась ссылка, ещё до всех этих проверок. Причина: файл, покинувший
конфигурацию, считался «заменённым», а по одной этой разнице замену плана,
отцепление и удаление пространства различить невозможно. Удалением, о котором
просили, является только первое. Теперь переход классифицируется по
пространству-владельцу, и та же разница применяется к вложениям: убрали файл у
существующего устройства — он удаляется, удалили устройство — его инструкции
остаются.
- **План удалённого пространства сохраняется**, а не тридцать дней, как обещала
v1.46.5: тридцать дней по возрасту файла всё равно бессмысленны — обычно он
загружен месяцы назад.
- **По возрасту больше не удаляется ничего**, кроме промежуточной папки диалога.
Правило, которое вычищало «отвергнутые загрузки», оказалось в гонке с
повторной попыткой: уборка удаляла файл неудавшегося сохранения ровно тогда,
когда следующая попытка коммитила ссылку на него. Правило, способное удалить
файл, на который кто-то вот-вот сошлётся, не стоит освобождаемого места.
Файлы уходят по действию, в остальных случаях остаются.
## v1.46.5 — 2026-07-28 (ревизия всех автоматических удалений)
- **Отцеплённый план не удаляется никогда, ни в каком возрасте.** В v1.46.4 ему
давался месяц; теперь это навсегда, и причина записана там, где её увидит
следующая правка. Правило, оно же в docs/SCOPE.md: компонент вправе удалить
файл только тогда, когда об этом говорит действие пользователя — замена
плана, удаление вложения, удаление устройства. «На это больше никто не
ссылается» таким действием не является. Ошибки несимметричны: занятое зря
место видно, стоит копейки и обратимо; удалённый файл — ничего из этого.
- **`houseplan/files/cleanup` больше не сносит папку по слову клиента.** После
перепривязки устройства файлы копируются под новый id, а старая папка
удалялась — через `rmtree`, по тому id, который прислала карточка. Два плохих
исхода: при частичном копировании часть ссылок продолжает указывать внутрь
этой папки (миграция намеренно их не переписывает — то есть это были живые
ссылки на удаляемые файлы), а неверный или устаревший id от любого клиента
уничтожил бы инструкции существующего устройства. Теперь сервер сам сверяется
с сохранённой конфигурацией, под её блокировкой, и удаляет только то, на что
никто не ссылается.
- **План удалённого пространства ждёт тридцать дней вместо часа.** Удаление
пространства осознанно, но час — короткое окно, чтобы заметить промах.
## v1.46.4 — 2026-07-28 (потеря данных: отцеплённые планы убирались как мусор)
- **Отцеплённый план больше не удаляется через час.** Переключение пространства
в режим «нарисовать» снимает ссылку и, как редактор всегда и говорил,
оставляет картинку на диске, чтобы её можно было вернуть. Уборка, добавленная
в 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).** Сборка привязана к записи конфигурации —
+7 -2
View File
@@ -49,8 +49,13 @@ cp dist/houseplan-card.js custom_components/houseplan/frontend/
## Deployment to the dacha (ha.jbstudio.pro)
- SSH: port **323**, root, key `ha_jb` (the user uploads it to the chat; in the sandbox /tmp/ha_jb, chmod 600).
- JS: `scp -P 323 -i /tmp/ha_jb dist/houseplan-card.js root@ha.jbstudio.pro:/config/custom_components/houseplan/frontend/`
- SSH: port **22222**, root, key `ha_jb` (lives in the user folder `houseplan/.secrets/ha_jb`,
outside git; copy into the sandbox with chmod 600 — only ask the user if it is gone).
- **The HA config root is `/mnt/data/supervisor/homeassistant`** — in this SSH
environment `/config` does not exist; a deploy aimed at `/config/...` fails
with "No such file or directory".
- JS: `scp -P 22222 -i <key> dist/houseplan-card.js root@ha.jbstudio.pro:/mnt/data/supervisor/homeassistant/custom_components/houseplan/frontend/`
- Cache busting: `sed` the `?v=` version in `.storage/lovelace_resources`, then restart HA.
- **The `frontend/` subfolder is not optional.** `__init__.py` registers
`Path(__file__).parent / "frontend" / "houseplan-card.js"` as the static path.
A copy dropped next to `__init__.py` (…/houseplan/houseplan-card.js) is served
+61
View File
@@ -0,0 +1,61 @@
# Filtering: the explicit "hide from plan" flag
Agreed with the owner 2026-07-29. This document is the source of truth for the
mechanism; the code follows it.
## Principle
Whether a device is on the plan is an EXPLICIT, per-device fact: the
"Hide from plan" checkbox, stored as `marker.hidden`. The old on-the-fly
filtering algorithm survives only as the SEEDER of those flags — it decides
the initial value once, and the user owns the flag from then on.
## Data model
- `marker.hidden: true` — hidden from the plan. For an auto device without a
marker, hiding creates a stub marker (this mechanism predates this spec).
- `marker.hidden: false` (marker present) — explicitly VISIBLE: the seeder
never touches a device that has any marker, so unhiding must KEEP the stub
marker. That is the re-seed protection.
- No marker — never evaluated by the seeder yet, or a plain physical device.
- `settings.filter_seeded: true` — this config has been materialised.
- `settings.show_all` — removed (deleted during materialisation). The old
toggle was shared config state; the new one is a local editor tool.
## Seeding
"Non-physical" = the old filter rules: excluded integration domains, model
"Group", scene-like models, bridges, myheat children, and individual lamp
devices in an area covered by a light group (when group folding is on).
The seeder runs on the editing client (write permission required) whenever
devices rebuild, and creates `hidden: true` stub markers for non-physical
devices in BOUND areas that have NO marker. It is idempotent: marked devices
are never revisited. It fires on:
1. first load of a config without `filter_seeded` (materialises the current
behaviour; nothing changes visually, the flags become real and editable);
2. an area newly bound to the plan;
3. a new device appearing in a bound area — non-physical ones are hidden
silently (no red dot); physical ones keep the red-dot flow.
Until a config is seeded (`filter_seeded` absent), `buildDevices` applies the
LEGACY runtime filter, so a read-only client on an old config sees exactly
the old behaviour until an editing client materialises it.
## Behaviour
- Hidden devices ARE built (flagged `hidden`), but not rendered in any mode,
except the device editor with "Show hidden devices" on — there they render
ghosted (translucent, dashed) and clicking opens the dialog to untick.
- "Show hidden devices" (rename of "Show all") is LOCAL, ephemeral state of
the current tab.
- Room LQI counts hidden devices (owner's decision).
- Light fill and glow do NOT count hidden devices — an invisible device casts
no visible light (owner's decision). Room climate is registry-wide and
unaffected, as before.
- The checkbox appears in the dialog of EVERY device kind, virtual included.
- "Remove from plan" disappears for auto/entity devices (the checkbox is the
one way to hide); a virtual device's "Delete" remains a real deletion.
- Duplicate names are still numbered, light groups still fold — those are
aggregation, not hiding.
+14 -12
View File
@@ -35,12 +35,12 @@ Track progress in `custom_components/houseplan/quality_scale.yaml` (done/exempt
- [ ] `test-coverage` ≥95% backend; frontend: extract remaining pure logic (view math,
marker resolution) into `logic.ts`/`devices.ts` and cover with node:test.
- [x] `diagnostics.py`: config + layout dump with `async_redact_data` (redact names/links/PDF paths).
- [ ] `reconfiguration-flow` + richer **options flow**: admin_only, curation defaults
- [ ] `reconfiguration-flow` + richer **options flow**: admin_only, filtering defaults
(exclude domains — UI editable, replacing the hardcoded EXCLUDED_DOMAINS fallback),
LQI thresholds, group_lights default.
- [ ] `repair-issues`: broken plan file references, orphaned layout entries, storage
migration failures → Repairs UI instead of silent logs.
- [ ] `system_health.py`: config rev, spaces/markers count, storage sizes.
- [x] `repair-issues`: broken plan references in Repairs (repairs.py, re-checked on
every config save); geometry/repair WS command for stranded migrations (v1.50.1).
- [x] `system_health.py` (v1.12.0).
- [ ] `exception-translations` + `icon-translations` where applicable.
- [ ] Frontend resource registration: adopt the community-consensus embedded-card pattern
end-to-end (we already do StaticPathConfig + storage-mode resource + `?v=` busting;
@@ -48,26 +48,28 @@ Track progress in `custom_components/houseplan/quality_scale.yaml` (done/exempt
## Phase 9 — Universality & flexibility (product depth)
- [ ] **Areas/floors registry integration**: import HA floors as spaces, suggest area
- [x] **Areas/floors registry integration**: floors-import wizard (v1.13.0); further: suggest area
bindings from the registry, sync names (HA is moving this way — native Areas/Home
dashboard; riding the registry is our moat).
- [ ] **Curation without hardcode**: icon rules (`iconFor`) become data — user-editable
- [x] **Filtering without hardcode**: icon rules became data (v1.13.0); the runtime
filter itself became explicit per-device hide flags in v1.51.0 (docs/FILTERING.md);
icon rules are user-editable
mapping (regex/domain/device_class → mdi icon) stored in config, shipping EN+RU
defaults; drop dacha-specific patterns from code.
- [ ] **Click actions** per device/domain: toggle / more-info / navigate / custom service
call (configurable, like standard card `tap_action`).
- [ ] **Theming**: respect light themes (currently dark-leaning), use HA theme variables
- [x] **Click actions** per device (v1.13.0, simplified v1.38.1): card / more-info /
toggle, with the lock/alarm safety model.
- [x] **Theming**: light-theme pass done in v1.13.0; HA theme variables
everywhere, optional per-space background color.
- [ ] Multi-instance question: keep single-instance (one house) but support **multiple
cards** with different default spaces (already works) — document as a decision.
- [ ] Plan formats: keep SVG/PNG/JPG/WebP; add max dimensions guidance; optional
auto-downscale on upload.
- [ ] More locales: extract i18n dictionaries to JSON so contributors can add languages
without touching TS.
- [x] More locales: i18n dictionaries are JSON since v1.13.0 (src/i18n/*.json).
## Phase 10 — Community & distribution
- [ ] hacs/default PR #8995 through moderation (expect drafting for fixes).
- [ ] hacs/default PR **#9004** through moderation (#8995 was bot-closed for a
non-template body; #9004 is queued with the label since 2026-07-22).
- [ ] Demo GIF/video for README (the single biggest driver of adoption for dashboard cards).
- [ ] Forum post in the Floorplan category + Reddit r/homeassistant showcase once
the demo assets exist.
+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.
+30 -9
View File
@@ -11,16 +11,16 @@
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
> ARCHITECTURE.md for design changes, ROADMAP.md when plans move.
## Snapshot (2026-07-28)
## Snapshot (2026-07-29)
| Item | State |
|---|---|
| Version | **v1.46.2** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Version | **v1.53.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 1–3 months (checked 2026-07-24) |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.46.2** via direct copy (HACS custom repo also installed) |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled, mergeable clean, never drafted. Queue: 1212 open, 835 older than ours. Merge rate COLLAPSED: 75 in July but almost all in the first decade, 0 in the last week (checked 2026-07-29) — maintainers process in rare bursts; ETA unknowable, months at best. Nothing actionable on our side |
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), deployed **v1.53.0** via direct copy (HACS custom repo also installed) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
@@ -48,6 +48,24 @@
- **Dialog UX**: binding radios + entities checkbox + search dropdown
(v1.38.0); tap actions simplified to Device card / more-info / Toggle,
right-click → more-info (v1.38.1); Esc closes every dialog (v1.30.4).
- **Room settings, tier 3** (v1.42.0): per-room fill/temp-source/label sizes;
the settings button sits at the room's VISUAL centre (inscribed circle +
centroid pull), icon-derived size, zooms with the plan (v1.51.0).
- **Files & plans** (v1.44–v1.50): signed content urls with sandbox CSP,
copy-on-write plan files, "already uploaded" picker + explicit delete
(v1.47.0), store quotas instead of any age-based deletion (v1.49.0),
nothing is ever deleted on an inference (docs/SCOPE.md rule).
- **Square canvas** (v1.48.0) with a crash-safe two-store migration
(geom_pending, v1.50.0) and an explicit geometry/repair command (v1.50.1);
content-fit default zoom with devices as content, zoom out to 0.4×,
measured stage height (v1.49–v1.50.2).
- **Explicit hide flags** (v1.51.0, docs/FILTERING.md): per-device
"Hide from plan" checkbox seeded once from the old filter; local
"Show hidden" ghosts; hidden counts toward room LQI on both cards, casts
no light (v1.51.1).
- **Yellow = working right now** (v1.51.0): climate by hvac_action, service
switches can no longer become primary, glow pool and icon share one
condition. Editor gestures on touch (pinch/pan) landed the same release.
## Recent milestones (details in CHANGELOG.md)
@@ -129,8 +147,9 @@
1. **hacs/default PR #9004** — accepted by the bot into the review queue ('New default
repository' label). Minor issues ⇒ the bot drafts the PR (fix and re-ready).
2. GitHub PAT `houseplan-card-publish` (repo+workflow) expires ~2026-07-12; in sandbox
`~/.git-credentials`. Revoke after the HACS queue clears, or re-issue when needed.
2. GitHub auth: fine-grained PAT (Contents R/W, issued 2026-07-23) in the sandbox
`~/.git-credentials`; pushes go over SSH with the `ha_jb` key. The old classic PAT
expired and is gone.
3. Privacy: legacy real-house plan sources (`assets/`) removed from the tree in
v1.13.3, but they persist in git history and old release archives; 8 README
screenshots in docs/images are still from the real house (replacement with
@@ -150,12 +169,14 @@
2. Restore the repo: `git clone <user-folder>/houseplan-card.git.bundle hpcN` in `/tmp`
(files from *previous* sandbox sessions in `/tmp` belong to `nobody` and are unreadable —
always clone into a fresh directory; `npm ci` again).
3. Deployment needs the `ha_jb` SSH key — ask the user to upload it (uploads are readable
only in the session they were uploaded in).
3. Deployment needs the `ha_jb` SSH key — it lives in the user folder at
`houseplan/.secrets/ha_jb` (outside git) and often survives in the sandbox home
`~/.ssh/ha_jb`; copy with chmod 600. Only ask the user if both are gone.
4. Build only in `/tmp` (never on the mount), `npm run build` (starts with `tsc --noEmit`),
md5-verify after every deploy, restart HA via
`nohup ha core restart >/dev/null 2>&1 </dev/null &` (otherwise the SSH session hangs).
5. GitHub pushes need a PAT (create via the user's Chrome: settings/tokens, repo+workflow scope).
5. GitHub pushes: SSH remote with the `ha_jb` key; API releases with the fine-grained
PAT from `~/.git-credentials` (see the watchlist).
## Product scope
+161 -11
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
@@ -206,8 +206,10 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
lock locked↔unlocked, bulb on; custom icons and unavailable states never morph [manual]
- [ ] display "Value instead of an icon": the marker shows the measurement (°/%/unit)
as its body, small badges hidden; non-numeric fallback keeps the icon [manual]
- [ ] RGB lights (v1.27.0): an on light with a color tints its icon/glow and the ripple
(explicit ripple color still wins); off/white lights unchanged [manual]
- [ ] RGB lights (v1.27.0, contract changed in v1.52.0): a lamp's colour lives
in its glow spot and the ripple fallback ONLY — the icon/badge/border get
no RGB tint; explicit ripple color still wins; off lights unchanged
[auto: smoke_light_badges + smoke_rgb_alarm]
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
display mode; clears on 'off'; unavailable never alarms [manual]; reduced-motion static
- [ ] Render cost (v1.43.1, audit L1): geometry (space model, open pairs) is
@@ -239,11 +241,158 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
on the plan after a reload. Same for each tap action and each fill mode
[auto: backend test_every_display_mode_the_editor_offers_is_accepted and
neighbours, test_a_marker_showing_its_value_can_be_saved]
- [ ] Nothing accumulates on an idle instance (v1.46.2, HP-1461-01): attach a
file, cancel the dialog, and do not save anything else — an hour later (or
after a restart) the file is gone, while every file the configuration
still references is untouched
[auto: backend test_scheduled_sweep_collects_what_no_commit_will]
- [ ] Room settings button (dev): detached from the (movable) name label —
always at the room's geometric centre, one button-height below it; sized
at 70% of a device icon and zooming WITH the plan; the small metric rows
under the room name now show in the plan editor too
[auto: smoke_room_cards gearDetached/plainInPlan]
- [ ] One indicator always (v1.52.1, HP-1520-01): in a glow space the plan
editor (no glow layer) shows the lit lamp yellow again; the devices
editor draws the layer and keeps the badge standard
[auto: smoke_light_badges]
- [ ] Size/angle parity (v1.52.1, HP-1513-01): a marker with size 3 / angle 37
scales x3 and rotates on BOTH cards [auto: smoke_size_angle_parity]
- [ ] Tap runs an automation (dev, owner's spec 2026-07-29): the tap-action
list has "Run automation/script/scene" with a searchable picker; saving
without a target is refused; the confirm checkbox guards toggle AND run
(our dialog, Esc/cancel = no call); automation.trigger / script.turn_on /
scene.turn_on per domain; a deleted target toasts and calls nothing;
covers/valves joined the card-wide toggle EXCEPT garage/door/gate
device classes (explicit per-device toggle still works for them)
[auto: smoke_tap_run + unit resolveTapAction/runServiceFor + backend
test_run_target_is_bounded_to_runnable_domains]
- [ ] Light-source badges (v1.52.0): in glow fill a lit lamp's badge stays
standard (the spot is the indicator) and a lit socket stays yellow; in
other fills a lit lamp is plain yellow with no RGB tint; morphing and
the ripple colour fallback survive [auto: smoke_light_badges +
smoke_rgb_alarm]
- [ ] Icon size multiplier scales the glyph (dev): set a marker's size to 3 —
the icon inside grows with the badge instead of staying default
[auto: smoke_icon_scale]
- [ ] Auto-grid parity (v1.51.2, HP-1511-01): with an empty layout, a visible
device among hidden ones sits at the same spot on both cards
[auto: smoke_hidden_flag autoGridParity]
- [ ] Ripple ghost (v1.51.2, HP-1511-02): a hidden ripple-display marker shows
its base icon, no pulse [auto: smoke_hidden_flag rippleGhost*]
- [ ] Hidden LQI parity (v1.51.1, HP-1510-01): a room whose only Zigbee
devices are hidden paints the same lqi fill on the full and the static
card [auto: smoke_hidden_flag lqiParity]
- [ ] Ghost shows no numbers (v1.51.1, HP-1510-02): a hidden value-display
device renders as a plain ghost — no value/temp/hum/LQI, no icon morph
[auto: smoke_hidden_flag ghostHidesValue]
- [ ] Hide-from-plan flag (dev, docs/FILTERING.md): every device dialog has
the checkbox, incl. virtual; hidden devices vanish from every mode and
the count, still count toward room LQI, cast no glow/light fill; the
device editor's "Show hidden" (local, per tab) shows them as BLUE
dashed ghosts — distinct from a grey unavailable icon — with NO live
state paint (no yellow, no alarm, no ripple);
unticking keeps a hidden:false marker (re-seed protection); an old
config materialises on first load by an editing client and legacy
clients keep the old behaviour until then
[auto: smoke_hidden_flag + unit seedHiddenBindings/seeded/legacy]
- [ ] Yellow means working (dev): a TRV whose hvac_action is heating glows
yellow; one that is merely enabled (idle) or has a service switch on
(anti-scaling, child lock) stays dark; a lit light turns its state on by
the same condition that lights the glow pool — and shows the yellow
badge only where the glow spot is NOT drawn: with the glow layer
visible the spot is the one indicator (v1.52.0)
[auto: smoke_yellow_principle + smoke_light_badges]
- [ ] Editor gestures on touch (dev): in the plan editor on a phone, pinch
zooms and a moving finger pans; releasing after a gesture does not draw
a point, a clean tap still does [auto: smoke_editor_gestures]
- [ ] Legacy geometry parity (v1.50.4, HP-1503-01): a store with a zero
viewport and a negative rect renders identically sane in BOTH cards —
full canvas fallback, normalised rectangle [auto: smoke_legacy_geometry]
- [ ] Sizes are positive (v1.50.3, HP-1502-01): view_box or room w/h of zero
or below is refused; a store that already holds one opens on the full
canvas, not a blank screen [auto: test_sizes_are_not_coordinates + unit
safeViewBox fallback]
- [ ] Room card layout (v1.50.3): the settings button is the bottom row of the
card and the room name sits in the same spot in view and plan modes
[manual; verified by vb-coordinate measurement]
- [ ] Geometry bounds (v1.50.2, HP-1501-01): a config with a 1e100 room
vertex is refused by the server; one already stored still renders with a
sane frame [auto: test_geometry_magnitudes_are_bounded + unit
contentBounds legacy case]
- [ ] No-op repair (v1.50.2, HP-1501-02): geometry/repair with a typo'd space
id errors, moves no revision and keeps the previous backup undoable
[auto: test_a_noop_repair_does_not_eat_the_backup]
- [ ] Card below other dashboard content (v1.50.1, HP-1500-02): place the card
after a tall card in a normal dashboard — the plan still gets most of the
viewport instead of a zero-height stage [auto: smoke_zoom_out]
- [ ] Frame never degenerate (v1.50.1, HP-1500-03): a space with one lone
marker opens with canvas around it, not an empty scene; an absurd stored
coordinate neither hides the plan nor is accepted by the server
[auto: unit contentBounds + backend test_layout_coordinates_are_bounded]
- [ ] Stranded migration repair (v1.50.1, HP-1500-01): geometry/repair with
dry_run previews, applies with a backup, undo restores; wrong space is
recoverable [auto: test_geometry_repair_is_explicit_previewable_and_undoable]
- [ ] Editors see the whole canvas (v1.50.0, HP-1490-03): a hand-drawn space
with one small room opens content-fit in View; switching to the plan
editor shows the full square with room to draw a second room far away;
back to View restores the content fit [auto: smoke_audit_1490]
- [ ] Save waits for a picked plan's proportions (v1.50.0, HP-1490-04): pick a
saved plan and hit Save before the thumbnail loads — the stored aspect is
the real one, never the previous file's [auto: smoke_audit_1490]
- [ ] Zoom goes below the fit (v1.50.0): minus past 100% floats the plan
centred, floor at 0.4x; entering an editor keeps the stage inside the
viewport [auto: smoke_zoom_out]
- [ ] Migration crash recovery (v1.50.0, HP-1490-01): kill HA between the two
store writes of the square migration — the next start finishes the layout
half from the saved intent
[auto: test_square_migration_finishes_after_a_crash_between_the_writes]
- [ ] Parallel upload quota (v1.50.0, HP-1490-02): two simultaneous uploads
with one slot left — exactly one succeeds
[auto: test_parallel_uploads_cannot_slip_past_the_quota_together]
- [ ] Zoom opens on the content (v1.49.0): a space with no background and one
small room opens with that room filling the screen, with a small margin.
With a background it still fits the whole image
[auto: unit: contentBounds]
- [ ] Deleting a picked plan is refused (v1.49.0, HP-1470-02): pick a saved
plan, reopen the list — its delete button is disabled. Ask the server to
store a plan url whose file is gone: `missing_plan`, and the revision does
not move [auto: smoke_saved_plans + backend
test_config_set_refuses_a_plan_that_no_longer_exists]
- [ ] Uploads are bounded (v1.49.0, HP-1470-01): past the store quota an upload
is refused with a clear error and the disk does not grow; the plan list
returns the newest 60 with a total
[auto: unit: test_check_quota_counts_the_whole_store_not_one_request,
backend test_uploads_are_bounded_by_a_store_quota]
- [ ] Square canvas migration (v1.48.0): after the upgrade every existing plan
looks exactly as before, just with margins where the canvas was extended.
Measure a wall in the plan editor — the length in cm is unchanged. Marker
positions, doors, decor and the saved zoom are all where they were
[auto: unit: test_a_wide_plan_gains_margins_above_and_below and neighbours,
test_migration_preserves_real_lengths_and_shapes]
- [ ] A plan image is centred (v1.48.0): a wide image sits in the middle with
empty bands above and below, a tall one with bands at the sides, and it is
never stretched [auto: unit: fitInSquare + smoke_space_settings]
- [ ] Re-attaching a detached plan (v1.47.0): detach a plan, save, RELOAD THE
PAGE, open space settings → "Already uploaded" → the image is listed with
its size and no "in use" note → attach it → it renders. The one a space
uses shows that space and cannot be deleted; a free one can, with a
confirm, and disappears from the list
[auto: smoke_saved_plans + backend test_stored_plans_can_be_listed_and_deleted_on_request]
- [ ] Detaching a plan keeps the file (v1.46.6): switch a space to "draw" and
SAVE — the image is still in `config/houseplan/plans/` right afterwards,
and after a restart, and can be re-attached. Deleting the space keeps it
too. Replacing a plan still removes the one it replaced, immediately.
Check straight after the save: the earlier bug deleted the file at that
moment, while every scheduled-pass test passed
[auto: unit: test_plan_collection_matrix, test_attachment_collection_matrix,
backend test_detaching_a_plan_keeps_the_file]
- [ ] Rebinding a device does not eat its manuals (v1.46.5): attach two files to
a device, rebind it to another HA device — both are readable afterwards.
If a copy failed, the file it failed on is still there rather than deleted
with the folder [auto: backend test_files_cleanup_keeps_referenced_files]
- [ ] Nothing accumulates on an idle instance (v1.46.2/v1.46.3, HP-1461-01,
HP-1462-01): attach a file, cancel the dialog, and do not save anything
else — the file is gone after a restart AND after the daily pass, while
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
@@ -435,8 +584,9 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
same domain still win over hidden ones [manual: click hallway lamps]
- [ ] Marker controls (v1.36.0): a marker with "Controls light sources" and
tap action Toggle flips all bound lights/switches at once (any on → all
off, all off → all on, one service call); the icon and its RGB tint
mirror the targets, not the marker's own entity; without explicit Toggle
off, all off → all on, one service call); the icon state (yellow badge)
mirrors the targets, not the marker's own entity — the RGB tint is gone
since v1.52.0, target colours reach only the glow/ripple; without explicit Toggle
the click opens info as usual; the info card lists targets with states;
locks/other domains are filtered out of controls [auto: smoke_controls]
- [ ] Glow fill (v1.35.0): fill mode "Light sources" — every room painted with
+8 -4
View File
@@ -59,8 +59,10 @@ Header in View: space tabs, device count, zoom cluster. Nothing else.
- Icon dragging (ONLY here). Click on a device opens the **edit dialog directly**
(binding, name, icon, size/angle, display badge/ripple + colors, tap override,
model/link/description/PDFs, room).
- + add device/entity/virtual, hide device, ↺ reset layout, 👁 show-all (curation
tool), ⬡ icon rules.
- + add device/entity/virtual, the "Hide device from plan" checkbox (since
v1.51.0 the one hiding mechanism, docs/FILTERING.md), ↺ reset layout,
👁 "Show hidden" (local editor tool; replaced the shared show-all toggle),
⬡ icon rules.
## Deprecations decided
@@ -74,7 +76,9 @@ Header in View: space tabs, device count, zoom cluster. Nothing else.
1. State-reflecting icons (open/closed door variants etc., like core HA).
2. `display: value` — show the measurement instead of an icon.
3. Light color in the icon/ripple (RGB lights).
3. Light color in the icon/ripple (RGB lights). *(Shipped in v1.27.0;
superseded in v1.52.0: the colour lives in the glow spot and the ripple
fallback only, the icon tint was removed by the owner's rule.)*
4. Alarm visual (leak/smoke/doorbell): red pulse overlay.
5. Rooms as sub-areas without an HA area + manual device placement by room id.
6. Backlog (not planned): music notes for players, directional TV ripples.
@@ -85,7 +89,7 @@ Header in View: space tabs, device count, zoom cluster. Nothing else.
edit interactions/buttons (biggest UX win, smallest surface).
- **It.2 — Plan tab:** move markup tools + space dialogs + labels drag + openings
editing under Plan; colored frame indicator.
- **It.3 — Devices tab:** drag + direct-edit click + curation tools under Devices.
- **It.3 — Devices tab:** drag + direct-edit click + filtering tools under Devices.
- **It.4+:** follow-up features 1–5 above, each its own release.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.45.4",
"version": "1.53.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.45.4",
"version": "1.53.0",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.46.2",
"version": "1.53.0",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+94 -22
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';
@@ -14,6 +14,8 @@ export interface BuildCtx {
markers: Marker[];
settings: ServerConfig['settings'];
excluded: Set<string>;
/** LEGACY only: honoured while the config has no settings.filter_seeded.
* Seeded configs hide by explicit marker flags (docs/FILTERING.md). */
showAll: boolean;
firstSpaceId: string;
/** Localized display strings for generated device names. */
@@ -56,14 +58,22 @@ export function primaryEntity(hass: any, entIds: string[], icon: string): string
const all = entIds
.map((eid) => ({ eid, reg: hass.entities[eid], st: hass.states[eid] }))
.filter((e) => e.reg);
// Tiers: visible primary entities first, but a HIDDEN light still beats a
// visible config switch. Real case: individual lamps folded into a light
// group get hidden in the registry — the device's main function is still
// the lamp, and tap-toggle must flip IT, not the do-not-disturb switch.
// Tiers, in order: functional and visible; functional but hidden (lamps
// folded into a light group get hidden in the registry — the device's main
// function is still the lamp, and tap-toggle must flip IT, not the
// do-not-disturb switch); config/diagnostic but visible; everything.
//
// The TIER loop is the OUTER one. It used to be inner, which made the
// domain priority stronger than visibility — and `switch` outranks
// `climate`, so a TRV's primary was whatever service switch the vendor
// ships (child lock, anti-scaling): the icon glowed yellow because scale
// protection was on, while the head that was actually HEATING stayed dark
// (owner's install, verified live 2026-07-29). A service entity must never
// beat the device's visible main function.
const tiers = [
all.filter((e) => !e.reg.hidden && !e.reg.entity_category),
all.filter((e) => !e.reg.hidden),
all.filter((e) => !e.reg.entity_category),
all.filter((e) => !e.reg.hidden),
all,
];
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') {
@@ -72,16 +82,35 @@ export function primaryEntity(hass: any, entIds: string[], icon: string): string
if (t) return t.eid;
}
}
for (const dom of DOMAIN_PRIORITY) {
for (const tier of tiers) {
for (const tier of tiers) {
for (const dom of DOMAIN_PRIORITY) {
const found = tier.find((e) => e.eid.split('.')[0] === dom);
if (found) return found.eid;
}
}
// no known domain anywhere — first entity of the best non-empty tier
for (const tier of tiers) if (tier.length) return tier[0].eid;
return undefined;
}
/**
* The lit light entity of a device, or null — ONE truth for "this thing is
* shining": the glow-mode pool and the yellow icon both ask here, so the
* light pool and the icon can never disagree (owner's principle, 2026-07-29).
* Normally that is a lit `light.*`; with the "is a light source" flag (a smart
* switch driving dumb fixtures) any lit entity counts — the switch itself, or
* the lights it controls when they are bound.
*/
export function litLightEntity(
hass: any, d: { entities: string[]; marker?: { is_light?: boolean | null; controls?: string[] | null } | null },
): string | null {
const forced = d.marker?.is_light === true;
const pool = forced
? [...(d.marker?.controls || []), ...d.entities]
: d.entities.filter((e) => e.startsWith('light.'));
return pool.find((e) => hass.states[e]?.state === 'on') || null;
}
/** Average zigbee LQI across the device's entities (*_linkquality/*_lqi sensors or an attribute). */
export function lqiFor(hass: any, entIds: string[]): number | null {
const vals: number[] = [];
@@ -175,6 +204,7 @@ export function resolveIcon(hass: any, name: string, model: string | undefined,
function applyMarker(item: DevItem, m: Marker): void {
item.marker = m;
if (m.hidden) item.hidden = true;
if (m.name) item.name = m.name;
if (m.icon) item.icon = m.icon;
if (m.model != null) item.model = m.model;
@@ -184,7 +214,44 @@ function applyMarker(item: DevItem, m: Marker): void {
item.tapAction = m.tap_action ?? null;
}
/** Curation + light groups + markers (metadata/rebinding) + virtual ones. A hybrid. */
/**
* The SEEDER (docs/FILTERING.md): bindings of non-physical devices in bound
* areas that have no marker yet. The editing client turns each into a
* `hidden: true` stub marker — after that the flag belongs to the user, and a
* marker of ANY kind (even `hidden: false`) makes the device invisible to
* this function forever. Idempotent by construction.
*/
export function seedHiddenBindings(ctx: Omit<BuildCtx, 'showAll' | 'loc'>): string[] {
const { hass: h, areaToSpace, markers, settings, excluded, iconRules } = ctx;
const groupLights = settings.group_lights !== false;
const groups = lightGroups(h, groupLights);
const groupedAreas = new Set(groups.map((g) => g.area));
const entsBy = entitiesByDevice(h);
const marked = new Set(markers.map((m) => m.binding));
const out: string[] = [];
for (const dev of Object.values<any>(h.devices)) {
const area = dev.area_id;
if (!area || !areaToSpace[area]) continue;
if (dev.entry_type === 'service') continue;
if (marked.has('device:' + dev.id)) continue;
const entIds = entsBy[dev.id] || [];
const dom = domainOfDevice(h, dev, entIds);
let nonPhysical =
excluded.has(dom)
|| dev.model === 'Group'
|| /scene/i.test(dev.model || '')
|| /bridge/i.test((dev.model || '') + (dev.name || ''))
|| (dom === 'myheat' && !!dev.via_device_id);
if (!nonPhysical && groupLights && groupedAreas.has(area)) {
const name = (dev.name_by_user || dev.name || '').trim();
if (resolveIcon(h, name, dev.model, entIds, iconRules) === 'mdi:lightbulb') nonPhysical = true;
}
if (nonPhysical) out.push('device:' + dev.id);
}
return out;
}
/** 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;
@@ -207,11 +274,13 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
if (dev.entry_type === 'service') continue;
if (claimed.has('device:' + dev.id)) continue; // a marker will take over below
const marker = markerFor('device', dev.id);
if (marker && marker.hidden) continue;
if (marker && marker.hidden && !settings.filter_seeded) continue; // legacy: dropped entirely
const entIds = entsBy[dev.id] || [];
const dom = domainOfDevice(h, dev, entIds);
// curation (can be turned off with the “show all” toggle)
if (!showAll) {
// LEGACY runtime filter: only while the config is not yet materialised
// (docs/FILTERING.md). A seeded config hides by explicit marker flags.
const legacy = !settings.filter_seeded;
if (legacy && !showAll) {
if (excluded.has(dom)) continue;
if (dev.model === 'Group') continue;
if (/scene/i.test(dev.model || '')) continue;
@@ -222,7 +291,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
const key = name + '|' + area;
let icon = resolveIcon(h, name, dev.model, entIds, iconRules);
if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock';
if (!showAll && groupLights && icon === 'mdi:lightbulb' && groupedAreas.has(area)) continue;
if (legacy && !showAll && groupLights && icon === 'mdi:lightbulb' && groupedAreas.has(area)) continue;
// duplicates by “name|zone” are numbered rather than hidden
seen[key] = (seen[key] || 0) + 1;
const dispName = seen[key] > 1 ? name + ' ' + seen[key] : name;
@@ -265,7 +334,10 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
// 3) explicit markers (rebinding/metadata/virtual)
for (const m of markers) {
if (m.hidden) continue;
// Hidden is a FLAG now, not an absence: the device is built (room LQI
// still counts it) and the renderer decides. Legacy configs keep the old
// "hidden = gone" until they are seeded (docs/FILTERING.md).
if (m.hidden && !settings.filter_seeded) continue;
const [kind, ref] = m.binding.split(':');
if (kind === 'device') {
const dev = h.devices[ref];
@@ -343,10 +415,10 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
* Light situation of an area: 'on' if any light entity of the area's devices is on,
* 'off' if lights exist but none is on, 'none' when the area has no lights at all.
*/
export function areaLights(hass: any, devices: { area: string; entities: string[] }[], area: string): 'on' | 'off' | 'none' {
export function areaLights(hass: any, devices: { area: string; entities: string[]; hidden?: boolean }[], area: string): 'on' | 'off' | 'none' {
let seen = false;
for (const d of devices) {
if (d.area !== area) continue;
if (d.area !== area || d.hidden) continue; // an invisible device casts no visible light
for (const eid of d.entities) {
if (!eid.startsWith('light.')) continue;
seen = true;
@@ -391,7 +463,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 +488,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 +523,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); }
@@ -503,13 +575,13 @@ export function areaClimate(
/** How many of the area's lights are on: {on, total}, or null without lights. */
export function areaLightStats(
hass: any,
devices: { area: string; entities: string[] }[],
devices: { area: string; entities: string[]; hidden?: boolean }[],
area: string,
): { on: number; total: number } | null {
const seen = new Set<string>();
let on = 0;
for (const dv of devices) {
if (dv.area !== area) continue;
if (dv.area !== area || dv.hidden) continue; // hidden lights are not part of the picture
for (const eid of dv.entities) {
if (!eid.startsWith('light.') || seen.has(eid)) continue;
seen.add(eid);
+604 -137
View File
File diff suppressed because it is too large Load Diff
+32 -5
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 hidden devices (ghosted, this tab only)",
"title.markup": "Room markup: grid, lines, outlines",
"title.configure_space": "Configure space",
"title.add_space": "Add space",
@@ -244,7 +244,7 @@
"opening.lock_pending": "Working…",
"title.close_editor": "Close editor (back to view)",
"devbar.add": "Add",
"devbar.show_all": "Show all",
"devbar.show_all": "Show hidden",
"devbar.rules": "Icon rules",
"space.roomcard_section": "Room card shows:",
"space.label_temp": "Temperature",
@@ -323,10 +323,37 @@
"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}",
"marker.hide": "Hide device from plan",
"marker.hide_tip": "The device is not drawn on the plan but still counts toward the room signal. To see it: the \"Show hidden\" button in the device editor.",
"tap.run": "Run automation/script/scene",
"marker.run_target_label": "What to run",
"marker.run_search_ph": "Search: automation, script or scene…",
"marker.run_target_gone": "Target {id} not found — pick again",
"marker.tap_confirm": "Ask for confirmation",
"marker.tap_confirm_tip": "Show a confirmation dialog before acting — a guard against accidental taps.",
"run.automation": "automation",
"run.script": "script",
"run.scene": "scene",
"confirm.tap_run": "Run \"{name}\"?",
"confirm.tap_toggle": "Toggle \"{name}\"?",
"toast.run_started": "Started: {name}",
"toast.run_target_missing": "Run target not found — check the device settings",
"toast.run_target_required": "Pick an automation, script or scene",
"btn.run": "Run"
}
+32 -5
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": "Добавить пространство",
@@ -244,7 +244,7 @@
"opening.lock_pending": "Выполняется…",
"title.close_editor": "Закрыть редактор (вернуться к просмотру)",
"devbar.add": "Добавить",
"devbar.show_all": "Показать все",
"devbar.show_all": "Показать скрытые",
"devbar.rules": "Правила иконок",
"space.roomcard_section": "В карточке комнаты:",
"space.label_temp": "Температура",
@@ -323,10 +323,37 @@
"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}",
"marker.hide": "Скрыть устройство с плана",
"marker.hide_tip": "Устройство не отображается на плане, но участвует в расчёте сигнала комнаты. Показать: кнопка «Показать скрытые» в редакторе устройств.",
"tap.run": "Запустить автоматизацию/скрипт/сцену",
"marker.run_target_label": "Что запускать",
"marker.run_search_ph": "Поиск: автоматизация, скрипт или сцена…",
"marker.run_target_gone": "Цель {id} не найдена — выберите заново",
"marker.tap_confirm": "Спрашивать подтверждение",
"marker.tap_confirm_tip": "Перед выполнением показать диалог подтверждения — защита от случайных нажатий.",
"run.automation": "автоматизация",
"run.script": "скрипт",
"run.scene": "сцена",
"confirm.tap_run": "Запустить «{name}»?",
"confirm.tap_toggle": "Переключить «{name}»?",
"toast.run_started": "Запущено: {name}",
"toast.run_target_missing": "Цель запуска не найдена — проверьте настройки устройства",
"toast.run_target_required": "Выберите автоматизацию, скрипт или сцену",
"btn.run": "Выполнить"
}
+98 -4
View File
@@ -238,6 +238,74 @@ export function segmentsProperlyCross(
* triangle centroid of consecutive vertices — the first point that passes
* pointStrictlyInside wins.
*/
/**
* The visual centre of a polygon: the centre of the largest inscribed circle
* (pole of inaccessibility). `interiorPoint` only promises "inside", and for
* an L-shaped room it lands near the seam — the owner's kitchen-living room
* got its settings button visibly off-centre (2026-07-29). Grid search with
* one refinement pass; exact enough for a button, cheap enough to cache.
*/
export function poleOfInaccessibility(poly: number[][], steps = 24): number[] {
const xs = poly.map((p) => p[0]);
const ys = poly.map((p) => p[1]);
const minX = Math.min(...xs), maxX = Math.max(...xs);
const minY = Math.min(...ys), maxY = Math.max(...ys);
const span = Math.max(maxX - minX, maxY - minY) || 1;
// Area centroid (shoelace-weighted). Clearance alone has a PLATEAU on any
// elongated room — every point of the long midline fits the same circle —
// and a plain argmax took the first plateau point: left of centre on the
// owner's kitchen, above centre in the sauna. A soft pull toward the
// centroid breaks the tie along the plateau without ever dragging the
// point into a thinner limb (clearance differences dominate the score).
let a2 = 0, cx = 0, cy = 0;
for (let i = 0; i < poly.length; i++) {
const p = poly[i], q = poly[(i + 1) % poly.length];
const cross = p[0] * q[1] - q[0] * p[1];
a2 += cross;
cx += (p[0] + q[0]) * cross;
cy += (p[1] + q[1]) * cross;
}
const centroid = Math.abs(a2) > 1e-9
? [cx / (3 * a2), cy / (3 * a2)]
: [(minX + maxX) / 2, (minY + maxY) / 2];
const clearance = (x: number, y: number): number => {
if (!pointInPolygon([x, y], poly)) return -Infinity;
let d = Infinity;
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
d = Math.min(d, distToSegment([x, y], [a[0], a[1], b[0], b[1]]));
}
return d;
};
const score = (x: number, y: number): number => {
const d = clearance(x, y);
if (d === -Infinity) return d;
return d - 0.08 * Math.hypot(x - centroid[0], y - centroid[1]) - 0.0001 * span;
};
let best: number[] | null = null;
let bestS = -Infinity;
for (let i = 1; i < steps; i++) {
for (let j = 1; j < steps; j++) {
const x = minX + ((maxX - minX) * i) / steps;
const y = minY + ((maxY - minY) * j) / steps;
const sc = score(x, y);
if (sc > bestS) { bestS = sc; best = [x, y]; }
}
}
if (best) {
const [bx, by] = best;
const cw = (maxX - minX) / steps, ch = (maxY - minY) / steps;
for (let i = -4; i <= 4; i++) {
for (let j = -4; j <= 4; j++) {
const x = bx + (cw * i) / 4, y = by + (ch * j) / 4;
const sc = score(x, y);
if (sc > bestS) { bestS = sc; best = [x, y]; }
}
}
}
return best || interiorPoint(poly) || poly[0];
}
export function interiorPoint(poly: number[][], eps = 1e-6): number[] | null {
if (!poly || poly.length < 3) return null;
const n = poly.length;
@@ -528,7 +596,7 @@ export function safeUrl(url: string | null | undefined): string | null {
// ---------------- tap actions ----------------
export type TapAction = 'info' | 'more-info' | 'toggle';
export type TapAction = 'info' | 'more-info' | 'toggle' | 'run';
/** Domains a card-wide `tap_action: toggle` may toggle (accidental-tap safe). */
/**
@@ -547,12 +615,12 @@ export type TapAction = 'info' | 'more-info' | 'toggle';
* 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;
export const TAP_ACTIONS = ['info', 'more-info', 'toggle', 'run'] 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']);
export const TOGGLE_SAFE_DOMAINS = new Set(['light', 'switch', 'fan', 'humidifier', 'cover', 'valve']);
/**
* Domains that must NEVER toggle from a plan tap, even with an explicit
@@ -569,10 +637,16 @@ export const TOGGLE_FORBIDDEN_DOMAINS = new Set(['lock', 'alarm_control_panel'])
* TOGGLE_SAFE_DOMAINS; an explicit per-device toggle affects any domain
* except TOGGLE_FORBIDDEN_DOMAINS. Everything else falls back to 'info'.
*/
/** Cover classes that stay OUT of the card-wide toggle: an accidental tap
* opening the garage or the driveway gate is a security matter, like locks.
* An explicit per-device toggle remains the owner's conscious choice. */
export const COVER_GUARDED_CLASSES = new Set(['garage', 'door', 'gate']);
export function resolveTapAction(
explicit: string | null | undefined,
cardDefault: string | null | undefined,
domain: string | null | undefined,
deviceClass?: string | null,
): TapAction {
// Pure light sources (the device's PRIMARY function is a lamp: bulbs,
// chandeliers, night lights, light groups) toggle by default — no explicit
@@ -580,10 +654,30 @@ export function resolveTapAction(
// backlight) have a non-light primary and keep the info default.
const want = explicit || cardDefault || (domain === 'light' ? 'toggle' : 'info');
if (want === 'more-info') return 'more-info';
// 'run' is EXPLICIT-only by construction: it needs a per-marker target, so
// it can never arrive as a card-wide default
if (want === 'run') return explicit === 'run' ? 'run' : 'info';
if (want !== 'toggle') return 'info';
if (!domain || TOGGLE_FORBIDDEN_DOMAINS.has(domain)) return 'info';
if (explicit === 'toggle') return 'toggle';
return TOGGLE_SAFE_DOMAINS.has(domain) ? 'toggle' : 'info';
if (!TOGGLE_SAFE_DOMAINS.has(domain)) return 'info';
// covers joined the safe set for curtains and blinds (owner, 2026-07-29) —
// but the garage door is a cover too, and it stays shut on a default tap
if (domain === 'cover' && COVER_GUARDED_CLASSES.has(String(deviceClass || ''))) return 'info';
return 'toggle';
}
/** Domains a tap may RUN (owner's spec 2026-07-29): the runnable units of
* HA. An automation is triggered, a script and a scene are turned on. */
export const RUN_TARGET_DOMAINS = ['automation', 'script', 'scene'] as const;
/** Service to start a runnable target, or null when the id is not runnable. */
export function runServiceFor(target: string | null | undefined): { domain: string; service: string } | null {
const dom = String(target || '').split('.')[0];
if (dom === 'automation') return { domain: 'automation', service: 'trigger' };
if (dom === 'script') return { domain: 'script', service: 'turn_on' };
if (dom === 'scene') return { domain: 'scene', service: 'turn_on' };
return null;
}
// ---------------- floors import ----------------
+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',
+109 -11
View File
@@ -6,17 +6,59 @@
import { declump, contentUrl } from './logic';
import type { ServerConfig, SpaceModel, RoomCfg, DevItem } from './types';
export const NORM_W = 1000; // width of the render space for normalized configs
export const NORM_W = 1000; // side of the render space — the canvas is square
/**
* Where a plan image sits inside the square canvas (v1.48.0).
*
* The canvas has no proportions of its own any more; the image keeps its own
* and is centred, so a wide plan gets margins above and below and a tall one
* gets them at the sides. `ratio` is the image's width/height; without it we
* assume square, which is only ever a brief guess before the file loads.
*/
export function fitInSquare(ratio: number | null | undefined, side: number) {
const r = Number(ratio);
const a = Number.isFinite(r) && r > 0 ? r : 1;
const w = a >= 1 ? side : side * a;
const h = a >= 1 ? side / a : side;
return { x: (side - w) / 2, y: (side - h) / 2, w, h };
}
export type Pt = { x: number; y: number };
export type Layout = Record<string, { s?: string; x: number; y: number } | undefined>;
/** Build render-space models (NORM_W × NORM_W/aspect) from a server config. */
/** Build render-space models (NORM_W × NORM_W) from a server config. */
/** A stored view_box the render can trust: 4 finite numbers, positive sizes.
* The server refuses anything else NOW (HP-1502-01), but a store may already
* hold [0,0,0,0] or negative sizes from before — and a zero axis serialises
* into viewBox="0 0 0 0", which draws nothing on every client. Bad input
* falls back to the whole canvas rather than to a blank screen. */
function safeViewBox(vb: any): [number, number, number, number] {
if (
Array.isArray(vb) && vb.length === 4 && vb.every((n: any) => Number.isFinite(n))
&& vb[2] > 1e-6 && vb[3] > 1e-6
) return vb as [number, number, number, number];
return [0, 0, 1, 1];
}
/** Legacy rectangle rooms, normalised: a negative size is the same rectangle
* drawn from the other corner; the maths downstream assumes w/h >= 0. */
function normRect(r: any): { x?: number; y?: number; w?: number; h?: number } {
if (r.x == null || r.y == null) return { x: r.x, y: r.y, w: r.w, h: r.h };
const w = Number(r.w) || 0, h = Number(r.h) || 0;
return {
x: w < 0 ? r.x + w : r.x,
y: h < 0 ? r.y + h : r.y,
w: Math.abs(w),
h: Math.abs(h),
};
}
export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
if (!cfg || !Array.isArray(cfg.spaces)) return [];
return cfg.spaces.map((s: any) => {
const H = NORM_W / s.aspect;
const scale = (r: any): RoomCfg => ({
const H = NORM_W; // square canvas
const scale = (raw: any): RoomCfg => { const r = { ...raw, ...normRect(raw) }; return {
id: r.id,
name: r.name,
area: r.area ?? null,
@@ -30,17 +72,75 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
w: r.w != null ? r.w * NORM_W : undefined,
h: r.h != null ? r.h * H : undefined,
poly: r.poly ? r.poly.map((p: number[]) => [p[0] * NORM_W, p[1] * H]) : undefined,
});
}; };
const vb = safeViewBox(s.view_box);
return {
id: s.id,
title: s.title,
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
bg: s.plan_url ? { href: contentUrl(s.plan_url), x: 0, y: 0, w: NORM_W, h: H } : null,
vb: [vb[0] * NORM_W, vb[1] * H, vb[2] * NORM_W, vb[3] * H],
bg: s.plan_url ? { href: contentUrl(s.plan_url), ...fitInSquare(s.plan_aspect, NORM_W) } : null,
rooms: (s.rooms || []).map(scale),
} as SpaceModel;
});
}
/**
* What the plan actually occupies, padded by `pad` of the larger side.
*
* The canvas is a square big enough for any house, so a small hand-drawn plan
* used to open as a speck in the middle of it. Zooming to the CONTENT instead
* of the canvas is what people expect — but only when there is no background
* image: with one, the image is the plan, and cropping to the rooms would hide
* the parts of it nobody has outlined yet.
*
* Returns null when there is nothing drawn, so the caller keeps the full canvas.
*/
export function contentBounds(
space: SpaceModel, pad = 0.05, extra?: ReadonlyArray<readonly [number, number]>,
): { x: number; y: number; w: number; h: number } | null {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
// Only points within a bounded envelope around the canvas command the
// opening view. The server bounds what it ACCEPTS now (±4 normalised,
// HP-1501-01), but a store may already hold an absurd coordinate from
// before that door existed — and one 1e100 vertex framed the space so wide
// the plan was a dot for every client. An out-of-envelope point is still
// rendered wherever it is; it just does not decide the frame. This applies
// to ROOM GEOMETRY and device positions alike (HP-1500-03, HP-1501-01).
const lo = -NORM_W * 0.25, hi = NORM_W * 1.25;
const add = (x: number, y: number) => {
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
if (x < lo || x > hi || y < lo || y > hi) return;
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
};
for (const r of space.rooms || []) {
if (r.poly) for (const p of r.poly) add(p[0], p[1]);
else if (r.x != null && r.y != null) {
add(r.x, r.y);
add(r.x + (r.w || 0), r.y + (r.h || 0));
}
}
// things that live outside any room still count as content — a gate sensor
// by the fence, a camera on a pole (the card passes device positions here)
for (const p of extra || []) add(p[0], p[1]);
if (minX > maxX || minY > maxY) return null;
// A single marker (or a collinear row of them) has no area, and an SVG
// viewBox with a zero axis draws nothing at all (HP-1500-03). An axis with
// essentially no span — nothing there but icons — opens up to a floor:
// enough canvas around a lone marker to see where it stands. A REAL thin
// shape (a 100-unit corridor) keeps its tight frame; only the degenerate
// case is padded, so the threshold sits at about an icon's size.
const FLOOR = NORM_W * 0.2;
const DEGENERATE = NORM_W * 0.03;
if (maxX - minX < DEGENERATE) { const c = (minX + maxX) / 2; minX = c - FLOOR / 2; maxX = c + FLOOR / 2; }
if (maxY - minY < DEGENERATE) { const c = (minY + maxY) / 2; minY = c - FLOOR / 2; maxY = c + FLOOR / 2; }
const m = Math.max(maxX - minX, maxY - minY) * pad;
const x = minX - m, y = minY - m;
return { x, y, w: (maxX - minX) + m * 2, h: (maxY - minY) + m * 2 };
}
/** Bounding rectangle of a room (rect or polygon) in render units. */
export function roomBounds(r: RoomCfg): { x: number; y: number; w: number; h: number } {
if (r.poly && r.poly.length) {
@@ -91,8 +191,7 @@ export function defaultPositions(devs: DevItem[], model: SpaceModel, iconPct: nu
export function markerPos(d: DevItem, layout: Layout, cfg: ServerConfig, defPos: Record<string, Pt>, model: SpaceModel): Pt {
const saved = layout[d.id];
if (saved && saved.s === d.space) {
const aspect = cfg.spaces.find((x: any) => x.id === d.space)?.aspect || 1;
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
return { x: saved.x * NORM_W, y: saved.y * NORM_W };
}
if (defPos[d.id]) return defPos[d.id];
const vb = model.vb;
@@ -103,8 +202,7 @@ export function markerPos(d: DevItem, layout: Layout, cfg: ServerConfig, defPos:
export function labelPos(r: RoomCfg, spaceId: string, layout: Layout, cfg: ServerConfig): Pt {
const saved = layout['rl_' + (r.id || '')];
if (saved && saved.s === spaceId) {
const aspect = cfg.spaces.find((x: any) => x.id === spaceId)?.aspect || 1;
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
return { x: saved.x * NORM_W, y: saved.y * NORM_W };
}
const c = roomCenter(r);
return { x: c[0], y: c[1] };
+25 -7
View File
@@ -65,8 +65,19 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
loc,
iconRules,
});
const devs = all.filter((d) => d.space === o.spaceId);
const defPos = defaultPositions(devs, space, iconPct);
// Two lists, two jobs (HP-1510-01): AGGREGATION sees every device of the
// space — hidden ones still count toward room LQI, same as the full card —
// while RENDERING sees only the visible ones (there is no editor here, so
// hidden devices are never drawn). Filtering one list for both jobs made
// the same room show different Zigbee health on the two cards.
const spaceDevs = all.filter((d) => d.space === o.spaceId);
const devs = spaceDevs.filter((d) => !d.hidden);
// The auto grid is computed over the FULL roster, hidden included — the
// full card reserves grid cells for hidden devices (their ghosts keep a
// place in the device editor), so the static card must too, or the same
// visible marker with no saved position lands in different spots on the
// two cards (HP-1511-01). Rendering still draws `devs` only.
const defPos = defaultPositions(spaceDevs, space, iconPct);
const roomShapes = space.rooms
.filter((r) => r.area || disp.showBorders)
@@ -82,9 +93,9 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const fillC = r.area
? roomFillStyle(
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,
fill === 'lqi' ? areaLqi(o.hass, spaceDevs, r.area) : null,
fill === 'light' ? areaLights(o.hass, spaceDevs, r.area) : 'none',
fill === 'temp' ? areaTemp(o.hass, spaceDevs, r.area) : null,
disp.tempMin,
disp.tempMax,
fillColorsOf(o.cfg?.settings),
@@ -110,8 +121,15 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const p = markerPos(d, o.layout, o.cfg, defPos, space);
const left = ((p.x - vb[0]) / vb[2]) * 100;
const top = ((p.y - vb[1]) / vb[3]) * 100;
return html`<div class="dev ${d.virtual ? 'virtual' : ''}" style="left:${left}%;top:${top}%">
<ha-icon icon="${d.icon}"></ha-icon>
// per-marker size and rotation, exactly as on the full card (HP-1513-01):
// the same stored marker must look the same on both cards. Geometry only —
// no live-state dressing here, the static card stays a schematic.
const scale = Number(d.marker?.size) > 0 ? Number(d.marker!.size) : 1;
const angle = Number(d.marker?.angle) || 0;
const st = [`left:${left}%`, `top:${top}%`];
if (scale !== 1) st.push(`--dev-scale:${scale}`);
return html`<div class="dev ${d.virtual ? 'virtual' : ''}" style="${st.join(';')}">
<ha-icon icon="${d.icon}" style=${angle ? `transform:rotate(${angle}deg)` : nothing}></ha-icon>
</div>`;
});
+97 -14
View File
@@ -383,27 +383,57 @@ export const cardStyles = css`
gap: 0.25em;
font-size: calc(1em * var(--rl-name, 1));
}
/* Switching spaces by swipe or on the kiosk carousel: the plan flies out
the way the finger went and the next one arrives from the other side. */
@keyframes hp-slide-left {
0% { transform: translateX(22%); opacity: 0; }
100% { transform: translateX(0); opacity: 1; }
}
@keyframes hp-slide-right {
0% { transform: translateX(-22%); opacity: 0; }
100% { transform: translateX(0); opacity: 1; }
}
.zoomwrap.slide-left { animation: hp-slide-left 0.26s cubic-bezier(0.22, 0.61, 0.36, 1); }
.zoomwrap.slide-right { animation: hp-slide-right 0.26s cubic-bezier(0.22, 0.61, 0.36, 1); }
@media (prefers-reduced-motion: reduce) {
.zoomwrap.slide-left, .zoomwrap.slide-right { animation: none; }
}
/* The name is the anchor: the label box is centred on the room point, so
anything that takes part in its layout SHIFTS THE NAME. The gear button
and the metrics hang below as absolutes — the name renders in exactly
the same place in view mode and in the plan editor (owner's request),
and the button sits at the very bottom of the card. */
/* Standalone, centred on the room, sized from the device icon: icon-size
already rescales with the view, so the button zooms WITH the plan
instead of keeping a constant screen size (owner's spec). */
.rlgearbtn {
--gear-h: calc(var(--icon-size, 2.5cqw) * 0.77); /* owner: half the previous size */
position: absolute;
/* dead-centred on the room, both axes (owner's spec) */
transform: translate(-50%, -50%);
display: inline-flex;
align-items: center;
gap: 4px;
margin-bottom: 3px;
padding: 3px 8px;
gap: 0.35em;
height: var(--gear-h);
padding: 0 calc(var(--gear-h) * 0.38);
border: 0;
border-radius: 999px;
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
font: inherit;
font-size: 11px;
font-size: calc(var(--gear-h) * 0.42);
font-weight: 600;
line-height: 1;
white-space: nowrap;
cursor: pointer;
pointer-events: auto;
opacity: 0.92;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
z-index: 2;
}
.rlgearbtn:hover { opacity: 1; }
.rlgearbtn ha-icon { --mdc-icon-size: 14px; display: inline-flex; }
.rlgearbtn { transition: opacity 0.15s, filter 0.15s; }
.rlgearbtn:hover { opacity: 1; filter: brightness(1.18); }
.rlgearbtn ha-icon { --mdc-icon-size: calc(var(--gear-h) * 0.55); display: inline-flex; }
.rlgear {
--mdc-icon-size: 0.9em;
display: inline-flex;
@@ -424,6 +454,11 @@ export const cardStyles = css`
}
.stage.mode-view .rlgo:hover { opacity: 1; }
.roomlabel .rlmetrics {
position: absolute; /* below the name, outside the centring math */
top: calc(100% + 0.15em);
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
display: flex;
align-items: center;
gap: 0.55em;
@@ -841,13 +876,9 @@ export const cardStyles = css`
white-space: nowrap;
}
/* RGB lights: the bulb takes the light's actual color */
.dev.rgb ha-icon { color: var(--light-color); }
.dev.rgb.on {
box-shadow: 0 0 10px var(--light-color);
border-color: var(--light-color);
background: var(--hp-bg);
color: var(--light-color);
}
/* v1.52.0: the RGB tint of the icon/border is gone — a lamp's colour
lives ONLY in its glow spot (owner's rule). The ripple-color fallback
keeps using the light colour; that is set inline via --ripple-color. */
/* alarms pulse red over everything */
.dev.alarm::after {
content: '';
@@ -904,7 +935,11 @@ export const cardStyles = css`
z-index: 2;
}
.dev ha-icon {
--mdc-icon-size: calc(var(--icon-size, 2.5cqw) * 0.62);
/* from --dev-size, NOT --icon-size: the per-device size multiplier must
scale the GLYPH with its badge. Pinned to the base size, "make this
icon bigger" grew an empty box around a default-size glyph (user
report via the owner, 2026-07-29). */
--mdc-icon-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.62);
display: flex;
align-items: center;
justify-content: center;
@@ -934,6 +969,20 @@ export const cardStyles = css`
.dev.virtual {
border-style: dashed;
}
/* "hide from plan" flag, shown only in the device editor with the
"show hidden devices" toggle on (docs/FILTERING.md). BLUE, so a hidden
device cannot be mistaken for an unavailable one (translucent dark) —
and no live-state paint at all: a ghost is configuration, not status
(owner's request). */
.dev.ghost {
opacity: 0.6;
border-style: dashed;
border-color: var(--hp-accent);
background: rgba(62, 166, 255, 0.22); /* fallback for old WebViews */
background: color-mix(in srgb, var(--hp-accent) 30%, var(--card-background-color, #1c2530));
color: var(--hp-accent);
box-shadow: none;
}
.dev.sel {
border-color: #ffc14d;
box-shadow: 0 0 0 3px rgba(255, 193, 77, 0.35);
@@ -1062,6 +1111,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;
+10 -1
View File
@@ -51,6 +51,10 @@ export interface Marker {
description?: string | null;
pdfs?: PdfRef[];
tap_action?: string | null; // per-device override: 'info' | 'more-info' | 'toggle'
/** 'run' target: automation./script./scene. entity id. */
tap_target?: string | null;
/** Ask before toggle/run — accidental-tap guard (owner's spec). */
tap_confirm?: boolean | null;
room_id?: string | null; // manual placement into a room WITHOUT an HA area (sub-area rooms)
display?: 'badge' | 'ripple' | 'icon_ripple' | 'value' | null; // how the device is drawn
ripple_color?: string | null;
@@ -90,7 +94,9 @@ export interface ServerConfig {
settings: {
exclude_integrations?: string[];
group_lights?: boolean;
show_all?: boolean;
show_all?: boolean; // legacy: removed when the config is materialised
/** The filter flags are materialised into markers (docs/FILTERING.md). */
filter_seeded?: boolean;
icon_rules?: { pattern: string; icon: string }[];
};
}
@@ -101,6 +107,9 @@ export interface DevItem {
model: string;
area: string;
space: string;
/** "Hide from plan": built (so room LQI still counts it) but not rendered,
* except ghosted in the device editor with "show hidden" on. */
hidden?: boolean;
icon: string;
entities: string[];
primary?: string;
+103 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue , areaClimate, areaClimateMap } from '../test-build/devices.js';
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue , areaClimate, areaClimateMap, litLightEntity, seedHiddenBindings } from '../test-build/devices.js';
import { compileIconRules, iconFor } from '../test-build/rules.js';
/** Minimal fake hass around the pieces buildDevices reads. */
@@ -483,3 +483,105 @@ test('areaClimateMap: температура и влажность живут в
};
assert.deepEqual(areaClimateMap(hass).get('hall'), { temp: 21.4, hum: 48 });
});
test('primaryEntity: a service switch never beats the visible main function (TRV)', () => {
// the owner's radiator heads, verified live: switch outranks climate in the
// domain list, and the old inner-tier loop let a CONFIG switch (anti
// scaling, child lock) become primary — the icon glowed yellow because
// scale protection was on, while the head actually heating stayed dark
const hass = {
entities: {
'climate.trv': {},
'sensor.trv_temp': {},
'switch.trv_anti_scaling': { entity_category: 'config' },
'switch.trv_child_lock': { entity_category: 'config' },
},
states: {},
};
assert.equal(
primaryEntity(hass, ['switch.trv_anti_scaling', 'switch.trv_child_lock', 'climate.trv', 'sensor.trv_temp'], 'mdi:thermostat'),
'climate.trv',
);
// but a device whose only functional entity IS a switch keeps it
const plug = { entities: { 'switch.plug': {}, 'sensor.plug_power': {} }, states: {} };
assert.equal(primaryEntity(plug, ['sensor.plug_power', 'switch.plug'], 'mdi:power-socket'), 'switch.plug');
});
test('litLightEntity: one truth for "this thing is shining"', () => {
const hass = { states: {
'light.a': { state: 'off' }, 'light.b': { state: 'on' },
'switch.wall': { state: 'on' }, 'sensor.x': { state: '5' },
} };
// a lit light.* anywhere in the device
assert.equal(litLightEntity(hass, { entities: ['light.a', 'light.b'] }), 'light.b');
assert.equal(litLightEntity(hass, { entities: ['light.a'] }), null);
// without the flag a lit switch is NOT a light
assert.equal(litLightEntity(hass, { entities: ['switch.wall'] }), null);
// with "is a light source" any lit entity counts, controls first
assert.equal(
litLightEntity(hass, { entities: ['sensor.x'], marker: { is_light: true, controls: ['switch.wall'] } }),
'switch.wall',
);
});
// ---------- explicit hide-from-plan flags (docs/FILTERING.md) ----------
test('seedHiddenBindings: non-physical devices in bound areas, unmarked only', () => {
const h = mkHass({ devices: {
lamp: dev('lamp', 'Lamp', 'Bulb', 'living'),
bridge: dev('bridge', 'Z2M Bridge', 'Bridge', 'living'),
grp: dev('grp', 'All lights', 'Group', 'living'),
scn: dev('scn', 'Evening', 'Scene switch', 'living'),
faraway: dev('faraway', 'Bridge 2', 'Bridge', 'garage'), // unbound area
}});
const ctx = { hass: h, areaToSpace: { living: 'f1' }, markers: [], settings: {},
excluded: new Set(['hacs']), firstSpaceId: 'f1' };
assert.deepEqual(seedHiddenBindings(ctx).sort(), ['device:bridge', 'device:grp', 'device:scn']);
// a marker of ANY kind — even hidden: false — is the user's decision
const marked = { ...ctx, markers: [
{ id: 'x', binding: 'device:bridge', hidden: false },
{ id: 'y', binding: 'device:grp', hidden: true },
] };
assert.deepEqual(seedHiddenBindings(marked), ['device:scn'], 'marked devices are never revisited');
});
test('seeded config: hidden is a flag, not an absence', () => {
const h = mkHass({ devices: {
lamp: dev('lamp', 'Lamp', 'Bulb', 'living'),
plug: dev('plug', 'Plug', 'Socket', 'living'),
}});
const res = buildDevices(baseCtx(h, {
settings: { filter_seeded: true },
markers: [{ id: 'hplug', binding: 'device:plug', hidden: true }],
}));
const plug = res.find((d) => d.bindingRef === 'plug');
assert.ok(plug, 'the hidden device is BUILT — room LQI still counts it');
assert.equal(plug.hidden, true, 'and flagged for the renderer');
assert.ok(!res.find((d) => d.id === 'lamp').hidden);
});
test('legacy config (no filter_seeded): the runtime filter still applies', () => {
const h = mkHass({ devices: {
lamp: dev('lamp', 'Lamp', 'Bulb', 'living'),
bridge: dev('bridge', 'Hub', 'Bridge', 'living'),
}});
const res = buildDevices(baseCtx(h));
assert.deepEqual(res.map((d) => d.id), ['lamp'], 'a read-only client on an old config sees the old behaviour');
// and a hidden marker still removes the device entirely, as before
const res2 = buildDevices(baseCtx(h, { markers: [{ id: 'x', binding: 'device:lamp', hidden: true }] }));
assert.deepEqual(res2.map((d) => d.id), []);
});
test('hidden lights cast no visible light, but count toward LQI', () => {
const h = mkHass({
devices: {},
states: { 'light.a': { state: 'on', attributes: {} } },
});
const devices = [
{ area: 'living', entities: ['light.a'], hidden: true },
];
assert.equal(areaLights(h, devices, 'living'), 'none', 'an invisible device casts no visible light');
assert.equal(areaLightStats(h, devices, 'living'), null);
});
+53 -4
View File
@@ -15,8 +15,7 @@ import {
contentUrl, chunk, referencedContentUrls, MAX_SIGN_PATHS,
interiorPoint,
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
} from '../test-build/logic.js';
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility, runServiceFor, TOGGLE_SAFE_DOMAINS } from '../test-build/logic.js';
import {
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
} from '../test-build/rules.js';
@@ -191,8 +190,13 @@ test('tap action: defaults — info everywhere except pure lights (v1.39.0)', ()
test('tap action: card-wide toggle only touches safe domains', () => {
assert.equal(resolveTapAction(null, 'toggle', 'light'), 'toggle');
assert.equal(resolveTapAction(null, 'toggle', 'switch'), 'toggle');
assert.equal(resolveTapAction(null, 'toggle', 'cover'), 'info'); // garage stays shut
assert.equal(resolveTapAction(null, 'toggle', 'valve'), 'info');
// covers/valves joined the safe set for curtains (owner, 2026-07-29)…
assert.equal(resolveTapAction(null, 'toggle', 'cover', 'curtain'), 'toggle');
assert.equal(resolveTapAction(null, 'toggle', 'valve'), 'toggle');
// …but the garage door is a cover too, and it STAYS SHUT on a default tap
assert.equal(resolveTapAction(null, 'toggle', 'cover', 'garage'), 'info');
assert.equal(resolveTapAction(null, 'toggle', 'cover', 'gate'), 'info');
assert.equal(resolveTapAction('toggle', null, 'cover', 'garage'), 'toggle', 'explicit stays a conscious choice');
assert.equal(resolveTapAction(null, 'toggle', 'sensor'), 'info');
});
@@ -910,3 +914,48 @@ test('chunk / referencedContentUrls: signing batches and cache pruning (review R
assert.equal(referencedContentUrls(null).size, 0);
assert.equal(referencedContentUrls({}).size, 0);
});
test('poleOfInaccessibility: the VISUAL centre, not just any interior point', () => {
// a square: the pole is the centre
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
const p = poleOfInaccessibility(sq);
assert.ok(Math.abs(p[0] - 5) < 0.6 && Math.abs(p[1] - 5) < 0.6);
// an ELONGATED rectangle: clearance is a plateau along the midline, and a
// plain argmax used to take its first point — left of centre on the
// owner's kitchen, above centre in the sauna. The centroid pull centres it.
const wide = [[0, 0], [30, 0], [30, 10], [0, 10]];
const w = poleOfInaccessibility(wide);
assert.ok(Math.abs(w[0] - 15) < 1.2 && Math.abs(w[1] - 5) < 1.2, 'centred both axes: ' + w);
const tall = [[0, 0], [8, 0], [8, 28], [0, 28]];
const tl = poleOfInaccessibility(tall);
assert.ok(Math.abs(tl[0] - 4) < 1 && Math.abs(tl[1] - 14) < 1.2, 'sauna case, centred vertically: ' + tl);
// the owner's kitchen-living room shape: a THICK top slab with a narrower
// column hanging off the right side. The button belongs near the middle of
// the slab — pulled toward the area centroid, never down the thin column.
const L = [[0, 0], [20, 0], [20, 16], [16, 16], [16, 8], [0, 8]];
const q = poleOfInaccessibility(L);
assert.ok(pointInPolygon(q, L), 'inside, always');
assert.ok(q[1] < 8, 'in the thick slab (y < 8), not down the column: ' + q);
assert.ok(Math.abs(q[0] - 11.3) < 2.5, 'near the centroid x within the slab: ' + q);
});
test('tap action run: explicit-only, with runnable targets (owner spec 2026-07-29)', () => {
// 'run' resolves only as an explicit per-marker action
assert.equal(resolveTapAction('run', undefined, 'switch'), 'run');
assert.equal(resolveTapAction(null, 'run', 'switch'), 'info', 'never a card-wide default');
// runnable domains map to their services; anything else is not runnable
assert.deepEqual(runServiceFor('automation.morning'), { domain: 'automation', service: 'trigger' });
assert.deepEqual(runServiceFor('script.curtains'), { domain: 'script', service: 'turn_on' });
assert.deepEqual(runServiceFor('scene.movie'), { domain: 'scene', service: 'turn_on' });
assert.equal(runServiceFor('light.lamp'), null);
assert.equal(runServiceFor(''), null);
// covers and valves joined the card-wide toggle domains
assert.ok(TOGGLE_SAFE_DOMAINS.has('cover') && TOGGLE_SAFE_DOMAINS.has('valve'));
assert.equal(resolveTapAction(null, 'toggle', 'cover', 'shutter'), 'toggle');
// locks and alarms stay forbidden, run or not
assert.equal(resolveTapAction('toggle', undefined, 'lock'), 'info');
});
+125 -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,103 @@ test('defaultPositions: several devices in one room are spread (declumped, disti
});
test('NORM_W is 1000', () => assert.equal(NORM_W, 1000));
test('contentBounds: fits what is drawn, with a 5% margin', () => {
const one = spaceModels({ spaces: [{
id: 's', view_box: [0, 0, 1, 1],
rooms: [{ id: 'r', poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }],
}], markers: [] })[0];
// 200x200 render units in the middle of a 1000x1000 canvas, +5% of 200 each side
assert.deepEqual(contentBounds(one), { x: 390, y: 390, w: 220, h: 220 });
// rectangles count too, and the margin follows the LARGER side
const rect = spaceModels({ spaces: [{
id: 's', view_box: [0, 0, 1, 1],
rooms: [{ id: 'r', x: 0.1, y: 0.4, w: 0.6, h: 0.1 }],
}], markers: [] })[0];
const b = contentBounds(rect);
assert.equal(Math.round(b.w), 660); // 600 + 2 * 5% of 600
assert.equal(Math.round(b.h), 160); // 100 + the same absolute margin
assert.equal(Math.round(b.x), 70);
// nothing drawn → the caller keeps the whole canvas
const empty = spaceModels({ spaces: [{ id: 's', view_box: [0, 0, 1, 1], rooms: [] }], markers: [] })[0];
assert.equal(contentBounds(empty), null);
});
test('contentBounds: devices outside every room stretch the frame', () => {
const one = spaceModels({ spaces: [{
id: 's', view_box: [0, 0, 1, 1],
rooms: [{ id: 'r', poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }],
}], markers: [] })[0];
// a gate sensor far to the right of the room
const b = contentBounds(one, 0.05, [[900, 500]]);
// span 400..900 wide, 400..600 tall; margin 5% of the larger side (500)
assert.deepEqual(b, { x: 375, y: 375, w: 550, h: 250 });
// devices alone are content enough — an empty yard with two cameras
const empty = spaceModels({ spaces: [{ id: 's', view_box: [0, 0, 1, 1], rooms: [] }], markers: [] })[0];
const only = contentBounds(empty, 0.05, [[100, 100], [300, 200]]);
assert.equal(Math.round(only.w), 220);
// and junk coordinates are ignored, not spread across the canvas
assert.deepEqual(contentBounds(one, 0.05, [[NaN, 5]]), contentBounds(one));
});
test('contentBounds: never degenerate, never unbounded (HP-1500-03)', () => {
const empty = spaceModels({ spaces: [{ id: 's', view_box: [0, 0, 1, 1], rooms: [] }], markers: [] })[0];
// a single marker has no area — the frame gets a floor instead of a 0x0 viewBox
const one = contentBounds(empty, 0.05, [[500, 500]]);
assert.ok(one.w >= 200 && one.h >= 200, 'a lone marker still frames some canvas');
assert.ok(Math.abs(one.x + one.w / 2 - 500) < 1, 'centred on the marker');
// a collinear row: the flat axis gets the floor, the long one keeps its span
const row = contentBounds(empty, 0.05, [[100, 500], [900, 500]]);
assert.ok(row.h >= 200, 'the flat axis is opened up');
assert.ok(row.w > 800, 'the long axis is untouched');
// an absurd stored coordinate does not command the frame...
const room = spaceModels({ spaces: [{
id: 's', view_box: [0, 0, 1, 1],
rooms: [{ id: 'r', poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }],
}], markers: [] })[0];
assert.deepEqual(contentBounds(room, 0.05, [[1e100, 500]]), contentBounds(room));
assert.deepEqual(contentBounds(room, 0.05, [[-1e100, -1e100]]), contentBounds(room));
// ...but a device a bit past the canvas edge still counts (the gate sensor)
const near = contentBounds(room, 0.05, [[1100, 500]]);
assert.ok(near.x + near.w > 1050, 'slightly outside the canvas still stretches the frame');
// HP-1501-01: the same envelope guards ROOM GEOMETRY — the server refuses
// absurd vertices now, but a store may hold one from before that door
// existed, and a legacy 1e100 vertex must not frame the plan into a dot
const legacy = spaceModels({ spaces: [{
id: 's', view_box: [0, 0, 1, 1],
rooms: [
{ id: 'ok', poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] },
{ id: 'bad', poly: [[0, 0], [1e100, 0], [1, 1]] },
],
}], markers: [] })[0];
const lb = contentBounds(legacy);
assert.ok(lb.w < 1500 && lb.h < 1500, 'the absurd vertex does not command the frame');
assert.ok(lb.x <= 400 && lb.x + lb.w >= 600, 'the sane room is still inside it');
// a space where EVERY point is absurd falls back to the whole canvas
const allBad = spaceModels({ spaces: [{
id: 's', view_box: [0, 0, 1, 1],
rooms: [{ id: 'b', poly: [[1e100, 1e100], [2e100, 1e100], [2e100, 2e100]] }],
}], markers: [] })[0];
assert.equal(contentBounds(allBad), null, 'the caller keeps the full canvas');
});
test('spaceModels: a stored broken view_box falls back to the canvas (HP-1502-01)', () => {
// the server refuses these now, but a store may already hold one
for (const vb of [[0, 0, 0, 0], [0, 0, -1, -2], [0, 0, 1], null, [0, 0, NaN, 1]]) {
const m = spaceModels({ spaces: [{ id: 's', view_box: vb, rooms: [] }], markers: [] })[0];
assert.deepEqual(m.vb, [0, 0, 1000, 1000], JSON.stringify(vb) + ' falls back');
}
// a legitimate crop viewport is preserved
const crop = spaceModels({ spaces: [{ id: 's', view_box: [0.1, 0.2, 0.5, 0.4], rooms: [] }], markers: [] })[0];
assert.deepEqual(crop.vb, [100, 200, 500, 400]);
// a legacy rect with a negative size is the same rectangle from the other corner
const m = spaceModels({ spaces: [{
id: 's', view_box: [0, 0, 1, 1],
rooms: [{ id: 'r', x: 0.6, y: 0.7, w: -0.2, h: -0.3 }],
}], markers: [] })[0];
const r = m.rooms[0];
assert.deepEqual([r.x, r.y, r.w, r.h].map(Math.round), [400, 400, 200, 300]);
});
+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
+561 -57
View File
@@ -176,10 +176,12 @@ async def test_files_migrate_copies_and_reports_mapping(
assert src_kept, "migrate must COPY, not move (review CR-2)"
assert other == b"OTHER" and copied == b"SOURCE"
# cleanup runs only after the config is safely committed
# cleanup runs only after the config is safely committed, and since v1.46.5
# reports how many files it removed rather than a bare boolean — it now also
# keeps anything the stored configuration still references
await client.send_json_auto_id({"type": "houseplan/files/cleanup", "marker_id": "old1"})
resp2 = await client.receive_json()
assert resp2["success"] and resp2["result"]["removed"] is True
assert resp2["success"] and resp2["result"]["removed"] >= 1 and resp2["result"]["kept"] == 0
assert not await hass.async_add_executor_job(lambda: os.path.isdir(src))
@@ -340,34 +342,41 @@ async def test_commit_does_not_collect_another_client_s_uncommitted_upload(
assert not (plans / pa).exists()
async def test_abandoned_uploads_are_collected_once_old(
async def test_a_rejected_upload_is_kept_not_aged_out(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""A rejected upload must not accumulate forever — but only age may free it."""
"""v1.46.6: age is never a reason to delete a plan file.
It used to be, for "a file of a space that has a plan and never was one" —
an upload whose save had failed. That raced the retry: the sweep removed the
file while the next save was committing a reference to it. Keeping it costs
a few megabytes nobody can lose.
"""
import os
import time
from pathlib import Path
from custom_components.houseplan.const import PLANS_DIR, PLAN_ORPHAN_TTL_S
from custom_components.houseplan.const import PLANS_DIR, SCHEDULED_GRACE_S
from custom_components.houseplan.store import get_data
await _setup(hass)
client = await hass_ws_client(hass)
plans = Path(hass.config.path(PLANS_DIR))
plans.mkdir(parents=True, exist_ok=True)
for stale in plans.glob("r3.*"):
stale.unlink()
plans = hass.config.path(PLANS_DIR)
url0, p0 = await _upload(client, "r3", b"zero")
rev = (await _save(client, await _cfg([{"id": "r3", "plan_url": url0}]), 0))["result"]["rev"]
_url, orphan = await _upload(client, "r3", b"abandoned")
old = time.time() - PLAN_ORPHAN_TTL_S - 60
os.utime(plans / orphan, (old, old))
_url, orphan = await _upload(client, "r3", b"never saved")
old = time.time() - SCHEDULED_GRACE_S * 12
os.utime(os.path.join(plans, orphan), (old, old))
ok = await _save(client, await _cfg([{"id": "r3", "plan_url": url0}]), rev)
assert ok["success"]
assert not (plans / orphan).exists(), "an aged, unreferenced upload is collected"
assert (plans / p0).is_file(), "the referenced plan is never touched"
# a commit, and the scheduled pass, and any amount of age: it stays
assert (await _save(client, await _cfg([{"id": "r3", "plan_url": url0}]), rev))["success"]
data = get_data(hass)
await data.sweep()
await hass.async_block_till_done()
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, orphan))
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, p0))
async def test_collection_ignores_files_that_are_not_plans(
@@ -609,7 +618,8 @@ async def test_layout_keeps_its_revision_and_announces_changes(
assert not bad["success"] and bad["error"]["code"] == "conflict"
await hass.async_block_till_done()
assert [e["rev"] for e in events] == [1, 2, 3]
# the bus does not promise ordering between separately fired events
assert sorted(e["rev"] for e in events) == [1, 2, 3]
async def test_uploaded_svg_is_sandboxed_and_a_pdf_is_not(
@@ -774,8 +784,22 @@ async def test_repair_issue_goes_when_its_space_does(
client = await hass_ws_client(hass)
registry = ir.async_get(hass)
gone = "/api/houseplan/content/plans/_/nosuchfile.png"
rev = (await _save(client, await _cfg([{"id": "r7", "plan_url": gone}]), 0))["result"]["rev"]
# A reference can only go bad AFTER it is stored — config/set refuses a new
# one that is already broken (HP-1470-02). So: attach a real plan, then let
# the file disappear the way it does in life, from outside Home Assistant.
import os
from custom_components.houseplan.const import PLANS_DIR
url, name = await _upload(client, "r7", b"PLAN")
rev = (await _save(client, await _cfg([{"id": "r7", "plan_url": url}]), 0))["result"]["rev"]
await hass.async_block_till_done()
assert registry.async_get_issue(HP_DOMAIN, "broken_plan_r7") is None, "the file is there"
await hass.async_add_executor_job(
os.remove, os.path.join(hass.config.path(PLANS_DIR), name)
)
rev = (await _save(client, await _cfg([{"id": "r7", "plan_url": url}]), rev))["result"]["rev"]
await hass.async_block_till_done()
assert registry.async_get_issue(HP_DOMAIN, "broken_plan_r7") is not None
@@ -855,61 +879,541 @@ async def test_cancelling_an_upload_takes_its_temporary_with_it(
assert entry
async def test_scheduled_sweep_collects_what_no_commit_will(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1461-01: a commit collects what it superseded — but only when it runs.
Cancel a dialog after the file uploaded, or lose the connection right after,
and nothing references the file and no future write notices it. The daily
sweep used to remove only streaming temporaries, so the promise that a
cancelled attachment goes after an hour did not hold on an instance nobody
edits.
"""
async def _seed_aged(hass, names) -> None:
"""Create the given files and backdate them past the orphan TTL."""
import os
import time
from custom_components.houseplan.const import FILES_DIR, PLANS_DIR, PLAN_ORPHAN_TTL_S
from custom_components.houseplan.const import SCHEDULED_GRACE_S
await _setup(hass)
client = await hass_ws_client(hass)
files = hass.config.path(FILES_DIR)
plans = hass.config.path(PLANS_DIR)
old = time.time() - PLAN_ORPHAN_TTL_S - 60
old = time.time() - SCHEDULED_GRACE_S - 60 # past every grace
def _seed() -> None:
os.makedirs(os.path.join(files, "m5"), exist_ok=True)
os.makedirs(os.path.join(files, "up_cancelled"), exist_ok=True)
os.makedirs(plans, exist_ok=True)
for path in (
os.path.join(files, "m5", "kept.pdf"),
os.path.join(files, "up_cancelled", "manual.pdf"),
os.path.join(plans, "s5.tok.png"),
os.path.join(plans, "s5.orphan.png"),
):
def _do() -> None:
for path in names:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as fh:
fh.write(b"x")
os.utime(path, (old, old))
await hass.async_add_executor_job(_seed)
await hass.async_add_executor_job(_do)
def _paths(hass):
import os
from custom_components.houseplan.const import FILES_DIR, PLANS_DIR
files = hass.config.path(FILES_DIR)
plans = hass.config.path(PLANS_DIR)
return files, plans, {
"kept_file": os.path.join(files, "m5", "kept.pdf"),
"kept_plan": os.path.join(plans, "s5.tok.png"),
"orphan_file": os.path.join(files, "up_cancelled", "manual.pdf"),
# a plan file is never collected by age any more; keep one around and
# assert exactly that
"kept_reject": os.path.join(plans, "s5.reject.png"),
}
async def _referenced_config() -> dict:
cfg = await _cfg([{"id": "s5", "plan_url": "/api/houseplan/content/plans/_/s5.tok.png"}])
cfg["markers"] = [
{"id": "m5", "binding": "virtual",
"pdfs": [{"name": "k", "url": "/api/houseplan/content/files/m5/kept.pdf"}]}
]
assert (await _save(client, cfg, 0))["success"]
return cfg
async def _assert_swept(hass, p) -> None:
import os
assert await hass.async_add_executor_job(os.path.isfile, p["kept_file"]), "referenced file kept"
assert await hass.async_add_executor_job(os.path.isfile, p["kept_plan"]), "referenced plan kept"
assert not await hass.async_add_executor_job(os.path.isfile, p["orphan_file"]), (
"a staging folder from a dialog nobody saved is the one thing age collects"
)
assert await hass.async_add_executor_job(os.path.isfile, p["kept_reject"]), (
"a plan file is never removed for being old"
)
async def test_startup_sweep_collects_what_no_commit_will(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1461-01 / HP-1462-01: collection must not depend on a future save.
The files are seeded AFTER the configuration is stored. The previous version
of this test seeded them before, and `config/set` collects too — so it
passed without the startup pass doing anything, hiding HP-1462-01: during
setup the entry is not "loaded" yet, so looking its runtime data up by
domain returned None and the pass degraded to removing streaming
temporaries only.
"""
import os
await _setup(hass)
client = await hass_ws_client(hass)
files, _plans, p = _paths(hass)
# the plan it names has to exist: config/set refuses a NEW broken
# reference (HP-1470-02). The orphans still arrive after the save.
await _seed_aged(hass, [p["kept_plan"]])
assert (await _save(client, await _referenced_config(), 0))["success"]
await _seed_aged(hass, list(p.values()))
# reload the entry: that is what runs the sweep at startup
entry = hass.config_entries.async_entries(DOMAIN)[0]
assert await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
exists = lambda p: os.path.isfile(p) # noqa: E731
assert await hass.async_add_executor_job(exists, os.path.join(files, "m5", "kept.pdf"))
assert await hass.async_add_executor_job(exists, os.path.join(plans, "s5.tok.png"))
await _assert_swept(hass, p)
assert not await hass.async_add_executor_job(
exists, os.path.join(files, "up_cancelled", "manual.pdf")
), "an aged cancelled attachment is collected without any further config write"
assert not await hass.async_add_executor_job(exists, os.path.join(plans, "s5.orphan.png"))
assert not await hass.async_add_executor_job(os.path.isdir, os.path.join(files, "up_cancelled"))
os.path.isdir, os.path.join(files, "up_cancelled")
), "the emptied staging folder goes with its last file"
async def test_periodic_sweep_collects_too(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""The scheduled pass, invoked directly rather than by faking a 24 h jump.
Firing a time change proves the timer fires; awaiting the callback proves it
does the work. This asserts the second, which is the part that regressed.
"""
from custom_components.houseplan.store import get_data
await _setup(hass)
client = await hass_ws_client(hass)
_files, _plans, p = _paths(hass)
# the plan it names has to exist: config/set refuses a NEW broken
# reference (HP-1470-02). The orphans still arrive after the save.
await _seed_aged(hass, [p["kept_plan"]])
assert (await _save(client, await _referenced_config(), 0))["success"]
await _seed_aged(hass, list(p.values()))
data = get_data(hass)
assert data is not None and data.sweep is not None, "setup must publish the sweep"
await data.sweep()
await hass.async_block_till_done()
await _assert_swept(hass, p)
async def test_sweep_and_a_config_write_do_not_race(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Both take the same write lock, so an accepted config cannot lose a file.
Without it the sweep could decide a file is unreferenced, a commit could
start referencing it, and the file would go — leaving the accepted revision
pointing at nothing.
"""
import asyncio
import os
await _setup(hass)
client = await hass_ws_client(hass)
_files, plans, p = _paths(hass)
# the plan it names has to exist: config/set refuses a NEW broken
# reference (HP-1470-02). The orphans still arrive after the save.
await _seed_aged(hass, [p["kept_plan"]])
rev = (await _save(client, await _referenced_config(), 0))["result"]["rev"]
# an aged, currently unreferenced plan that the commit below adopts
newcomer = os.path.join(plans, "s5.newcomer.png")
await _seed_aged(hass, [p["kept_file"], p["kept_plan"], newcomer])
cfg2 = await _referenced_config()
cfg2["spaces"][0]["plan_url"] = "/api/houseplan/content/plans/_/s5.newcomer.png"
# Drive the sweep directly rather than through a reload: an entry reload has
# an unload window in which any WS call legitimately answers `not_ready`, so
# a save racing THAT proves nothing about the lock and fails at random.
from custom_components.houseplan.store import get_data
data = get_data(hass)
assert data is not None and data.sweep is not None
_swept, saved = await asyncio.gather(data.sweep(), _save(client, cfg2, rev))
await hass.async_block_till_done()
# assert the CONCRETE outcome: a save that came back `not_ready` would leave
# the old config pointing at the old file and satisfy a vaguer check
assert saved["success"], saved.get("error")
await client.send_json_auto_id({"type": "houseplan/config/get"})
stored = (await client.receive_json())["result"]["config"]
assert stored["spaces"][0]["plan_url"].endswith("s5.newcomer.png")
assert await hass.async_add_executor_job(
os.path.isfile, os.path.join(plans, "s5.newcomer.png")
), "the file the accepted config points at must exist"
async def test_files_cleanup_keeps_referenced_files(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""v1.46.5: the client may say what it no longer needs, never what may go.
`files/cleanup` used to rmtree the folder it was handed. A partial migration
leaves some urls pointing into that folder — the copy deliberately does not
rewrite the ones it could not confirm — so those were live links to files
being deleted. A wrong id from any client had the same effect on a device's
manuals.
"""
import os
from custom_components.houseplan.const import FILES_DIR
await _setup(hass)
client = await hass_ws_client(hass)
folder = os.path.join(hass.config.path(FILES_DIR), "m7")
def _seed() -> None:
os.makedirs(folder, exist_ok=True)
for n in ("kept.pdf", "orphan.pdf"):
with open(os.path.join(folder, n), "wb") as fh:
fh.write(b"x")
await hass.async_add_executor_job(_seed)
cfg = await _cfg([{"id": "s7", "plan_url": None}])
cfg["markers"] = [
{"id": "other", "binding": "virtual",
"pdfs": [{"name": "k", "url": "/api/houseplan/content/files/m7/kept.pdf"}]}
]
assert (await _save(client, cfg, 0))["success"]
await client.send_json_auto_id({"type": "houseplan/files/cleanup", "marker_id": "m7"})
resp = await client.receive_json()
assert resp["success"] and resp["result"] == {"ok": True, "removed": 1, "kept": 1}
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(folder, "kept.pdf")), (
"a file the configuration still references survives a cleanup of its folder"
)
assert not await hass.async_add_executor_job(os.path.isfile, os.path.join(folder, "orphan.pdf"))
async def test_detaching_a_plan_keeps_the_file(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1465-01, through the real save — where the earlier tests never looked.
Every check for this lived in the pure collector with old and new config
equal, i.e. the scheduled pass. The transition that matters is a save, and
there the file was deleted the moment the reference was cleared.
"""
import os
from custom_components.houseplan.const import PLANS_DIR
await _setup(hass)
client = await hass_ws_client(hass)
plans = hass.config.path(PLANS_DIR)
url, name = await _upload(client, "d1", b"PLAN", ext="png")
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": url}]), 0))["result"]["rev"]
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name))
# detach: the space stays, its plan does not
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": None}]), rev))["result"]["rev"]
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name)), (
"the editor says the image stays on disk — it has to actually stay"
)
# a restart does not change its mind either
entry = hass.config_entries.async_entries(DOMAIN)[0]
assert await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name))
# re-attach, then replace: THAT removes the one it replaced
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": url}]), rev))["result"]["rev"]
url2, name2 = await _upload(client, "d1", b"NEWPLAN", ext="png")
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": url2}]), rev))["result"]["rev"]
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name2))
assert not await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name))
# and deleting the space keeps its plan
await _save(client, await _cfg([]), rev)
await hass.async_block_till_done()
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name2))
async def test_stored_plans_can_be_listed_and_deleted_on_request(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1466-01/-02: "we never delete" needs the files to be findable.
A detached plan stays on disk. Without a way to see it, that is neither a
recovery path nor a disk policy — it is just accumulation. Listing gives
both: the user attaches it again, or deletes it on purpose, which is the
only way a plan file is ever removed.
"""
await _setup(hass)
client = await hass_ws_client(hass)
used_url, used = await _upload(client, "p1", b"USED", ext="png")
_free_url, free = await _upload(client, "p2", b"FREE", ext="png")
rev = (await _save(client, await _cfg([{"id": "p1", "plan_url": used_url}]), 0))["result"]["rev"]
await client.send_json_auto_id({"type": "houseplan/plans/list"})
# the HA test config dir is shared across the module: look at ours, not all
plans = {p["name"]: p for p in (await client.receive_json())["result"]["plans"]}
assert {used, free} <= set(plans)
assert plans[used]["used_by"] and not plans[free]["used_by"]
assert plans[used]["size"] == 4 and plans[free]["url"].endswith(free)
# a plan a space still uses is refused — the config decides, not the client
await client.send_json_auto_id({"type": "houseplan/plans/delete", "name": used})
resp = await client.receive_json()
assert not resp["success"] and resp["error"]["code"] == "in_use"
# an unused one goes on request
await client.send_json_auto_id({"type": "houseplan/plans/delete", "name": free})
assert (await client.receive_json())["result"]["removed"] is True
# detach the other, and now it is deletable — and listed as free until then
await _save(client, await _cfg([{"id": "p1", "plan_url": None}]), rev)
await client.send_json_auto_id({"type": "houseplan/plans/list"})
plans = {p["name"]: p for p in (await client.receive_json())["result"]["plans"]}
assert used in plans, "the detached plan is still there, ready to re-attach"
assert not plans[used]["used_by"]
assert free not in plans, "and the one we deleted is gone"
# nothing outside the plans folder can be reached through the name
await client.send_json_auto_id(
{"type": "houseplan/plans/delete", "name": "../../configuration.yaml"}
)
bad = await client.receive_json()
assert not bad["success"] and bad["error"]["code"] == "invalid_name"
async def test_config_set_refuses_a_plan_that_no_longer_exists(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1470-02: a stored internal plan url must name a file that exists.
The card can pick a plan and delete it from the same dialog, and two clients
can do the same in either order. The lock serialises them; it says nothing
about whether the file survived, so the check has to be here.
"""
await _setup(hass)
client = await hass_ws_client(hass)
url, name = await _upload(client, "x1", b"PLAN", ext="png")
await client.send_json_auto_id({"type": "houseplan/plans/delete", "name": name})
assert (await client.receive_json())["result"]["removed"] is True
resp = await _save(client, await _cfg([{"id": "x1", "plan_url": url}]), 0)
assert not resp["success"] and resp["error"]["code"] == "missing_plan"
# an external or legacy url is the user's business, not ours to verify
assert (await _save(client, await _cfg([{"id": "x1", "plan_url": "/local/mine.png"}]), 0))["success"]
async def test_uploads_are_bounded_by_a_store_quota(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch
) -> None:
"""HP-1470-01: nothing is deleted for being old, so growth stops at the door."""
from custom_components.houseplan import websocket_api as wsapi
from pathlib import Path
from custom_components.houseplan.const import PLANS_DIR
from custom_components.houseplan.plans import dir_usage
await _setup(hass)
client = await hass_ws_client(hass)
# every test in this module shares one config directory, so the plans folder
# is not empty here — budget two more from whatever is already stored
stored, _b = await hass.async_add_executor_job(
lambda: dir_usage(Path(hass.config.path(PLANS_DIR)))[::-1]
)
monkeypatch.setattr(wsapi, "MAX_PLANS_FILES", stored + 2)
await _upload(client, "q1", b"one")
await _upload(client, "q1", b"two")
import base64
await client.send_json_auto_id({
"type": "houseplan/plan/set", "space_id": "q1", "ext": "png",
"data": base64.b64encode(b"three").decode(),
})
resp = await client.receive_json()
assert not resp["success"] and resp["error"]["code"] == "too_many_files"
async def test_parallel_uploads_cannot_slip_past_the_quota_together(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch
) -> None:
"""HP-1490-02: N uploads used to measure the store before any of them
wrote, so all N passed a quota only one of them fits under. The
check→write pair is one job under upload_lock now, so whatever the
interleaving, at most ONE of two competing uploads can take the last slot.
"""
import asyncio
import base64
from custom_components.houseplan import websocket_api as wsapi
from custom_components.houseplan.const import PLANS_DIR
from custom_components.houseplan.plans import dir_usage
from pathlib import Path
await _setup(hass)
c1 = await hass_ws_client(hass)
c2 = await hass_ws_client(hass)
_bytes, stored = await hass.async_add_executor_job(
dir_usage, Path(hass.config.path(PLANS_DIR))
)
monkeypatch.setattr(wsapi, "MAX_PLANS_FILES", stored + 1) # room for ONE
payload = base64.b64encode(b"PLAN").decode()
async def upload(client, sid):
await client.send_json_auto_id({
"type": "houseplan/plan/set", "space_id": sid, "ext": "png", "data": payload,
})
return await client.receive_json()
r1, r2 = await asyncio.gather(upload(c1, "pa"), upload(c2, "pb"))
oks = [r for r in (r1, r2) if r["success"]]
errs = [r for r in (r1, r2) if not r["success"]]
assert len(oks) == 1, "exactly one takes the last slot"
assert errs and errs[0]["error"]["code"] == "too_many_files"
_bytes2, after = await hass.async_add_executor_job(
dir_usage, Path(hass.config.path(PLANS_DIR))
)
assert after == stored + 1, "the store holds what the quota promised, not more"
async def test_layout_coordinates_are_bounded(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1500-03: any finite float used to pass, and one stored 1e100
stretched every client's frame until the plan was invisible. Positions are
normalised to the canvas; +-4 is generous slack for an icon dragged past an
edge, not an envelope for absurdity."""
await _setup(hass)
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "houseplan/layout/update", "device_id": "d1",
"pos": {"s": "f1", "x": 1e100, "y": 0.5},
})
resp = await client.receive_json()
assert not resp["success"], "an absurd coordinate is refused at the door"
await client.send_json_auto_id({
"type": "houseplan/layout/update", "device_id": "d1",
"pos": {"s": "f1", "x": -1.2, "y": 0.5},
})
resp = await client.receive_json()
assert resp["success"], "a bit past the edge is a dragged icon, not an attack"
async def test_geometry_repair_is_explicit_previewable_and_undoable(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1500-01: an install that crashed in the v1.48 migration window has a
square config and an old-coordinates layout, and NOTHING left to tell —
both triggers went with the write that succeeded. No automation can fix
that safely (a correct layout looks the same), so the repair is a person
saying "this space, this old aspect": preview first, one-deep backup with
the write, undo restores it."""
await _setup(hass)
client = await hass_ws_client(hass)
# the stranded state: nothing in the layout says it is old
await client.send_json_auto_id({
"type": "houseplan/layout/set",
"layout": {"lamp": {"s": "wide", "x": 0.2, "y": 0.1},
"other": {"s": "elsewhere", "x": 0.9, "y": 0.9}},
})
assert (await client.receive_json())["success"]
# preview does not touch the store
await client.send_json_auto_id({
"type": "houseplan/geometry/repair", "space_id": "wide", "aspect": 2.0, "dry_run": True,
})
dry = (await client.receive_json())["result"]
assert dry["moved"] == 1 and dry["after"]["lamp"]["y"] == 0.3
await client.send_json_auto_id({"type": "houseplan/layout/get"})
assert (await client.receive_json())["result"]["layout"]["lamp"]["y"] == 0.1
# the repair itself
await client.send_json_auto_id({
"type": "houseplan/geometry/repair", "space_id": "wide", "aspect": 2.0,
})
res = (await client.receive_json())["result"]
assert res["moved"] == 1
await client.send_json_auto_id({"type": "houseplan/layout/get"})
lay = (await client.receive_json())["result"]["layout"]
assert lay["lamp"] == {"s": "wide", "x": 0.2, "y": 0.3}
assert lay["other"] == {"s": "elsewhere", "x": 0.9, "y": 0.9}, "another space untouched"
# a routine drag must not eat the backup...
await client.send_json_auto_id({
"type": "houseplan/layout/update", "device_id": "other",
"pos": {"s": "elsewhere", "x": 0.5, "y": 0.5},
})
assert (await client.receive_json())["success"]
# ...because undo is the safety net for repairing the WRONG space
await client.send_json_auto_id({
"type": "houseplan/geometry/repair", "space_id": "wide", "aspect": 2.0, "undo": True,
})
res = (await client.receive_json())["result"]
assert res["restored"] == 1
await client.send_json_auto_id({"type": "houseplan/layout/get"})
lay = (await client.receive_json())["result"]["layout"]
assert lay["lamp"] == {"s": "wide", "x": 0.2, "y": 0.1}, "back to before the repair"
assert lay["other"]["x"] == 0.5, "the drag after the repair survives the undo"
# undo without a backup for that space is a clean error
await client.send_json_auto_id({
"type": "houseplan/geometry/repair", "space_id": "nosuch", "aspect": 2.0, "undo": True,
})
bad = await client.receive_json()
assert not bad["success"] and bad["error"]["code"] == "no_backup"
async def test_a_noop_repair_does_not_eat_the_backup(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1501-02: a repair that matches nothing used to answer moved: 0 and
replace the one-deep backup with an empty one — a typo after a wrong
repair destroyed the only way back. Now it is an error, the revision does
not move, and the previous repair is still undoable."""
await _setup(hass)
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "houseplan/layout/set",
"layout": {"lamp": {"s": "wide", "x": 0.2, "y": 0.1}},
})
assert (await client.receive_json())["success"]
await client.send_json_auto_id({
"type": "houseplan/geometry/repair", "space_id": "wide", "aspect": 2.0,
})
rev = (await client.receive_json())["result"]["rev"]
# the typo: syntactically valid, matches nothing
await client.send_json_auto_id({
"type": "houseplan/geometry/repair", "space_id": "wid", "aspect": 2.0,
})
bad = await client.receive_json()
assert not bad["success"] and bad["error"]["code"] == "nothing_to_repair"
await client.send_json_auto_id({"type": "houseplan/layout/get"})
got = (await client.receive_json())["result"]
assert got["rev"] == rev, "a refused repair moves nothing, including the revision"
# the wrong-space repair from before the typo is STILL undoable
await client.send_json_auto_id({
"type": "houseplan/geometry/repair", "space_id": "wide", "aspect": 2.0, "undo": True,
})
res = (await client.receive_json())["result"]
assert res["restored"] == 1
await client.send_json_auto_id({"type": "houseplan/layout/get"})
lay = (await client.receive_json())["result"]["layout"]
assert lay["lamp"] == {"s": "wide", "x": 0.2, "y": 0.1}
+413 -42
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]})
@@ -182,7 +190,7 @@ def test_collection_caps():
def test_finite_on_every_coordinate():
"""audit follow-up B5: NaN/Infinity must be refused everywhere, not only in layout."""
base = {"id": "s1", "title": "S", "aspect": 1.0, "view_box": [0, 0, 100, 100], "rooms": []}
base = {"id": "s1", "title": "S", "aspect": 1.0, "view_box": [0, 0, 1, 1], "rooms": []}
# view_box
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [{**base, "view_box": [0, 0, "NaN", 100]}]})
@@ -203,13 +211,59 @@ def test_finite_on_every_coordinate():
{"id": "r", "name": "R", "poly": [[0, 0], [1, 0], [1, 1]]}]}]})
def test_geometry_magnitudes_are_bounded():
"""HP-1501-01: any finite float used to pass, and one schema-valid 1e100
room vertex made the space unviewable for every client — the exact failure
HP-1500-03 closed for layout positions, one schema over. ±4 is slack for a
vertex nudged past an edge, not an envelope for absurdity."""
base = {"id": "s1", "title": "S", "view_box": [0, 0, 1, 1], "rooms": []}
huge = 1e100
for cfg in (
{**base, "rooms": [{"id": "r", "name": "R", "poly": [[0, 0], [huge, 0], [1, 1]]}]},
{**base, "rooms": [{"id": "r", "name": "R", "poly": [[0, 0], [-huge, 0], [1, 1]]}]},
{**base, "rooms": [{"id": "r", "name": "R", "x": huge, "y": 0, "w": 1, "h": 1}]},
{**base, "rooms": [{"id": "r", "name": "R", "x": 0, "y": 0, "w": huge, "h": 1}]},
{**base, "view_box": [0, 0, huge, 1]},
{**base, "openings": [{"id": "o", "type": "door", "x": huge, "y": 0.5,
"angle": 0, "length": 0.1}]},
{**base, "openings": [{"id": "o", "type": "door", "x": 0.5, "y": 0.5,
"angle": 1e6, "length": 0.1}]},
):
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [cfg]})
# a vertex a bit past the canvas edge is a drawing, not an attack
assert v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
{"id": "r", "name": "R", "poly": [[-0.2, 0], [1.3, 0], [1, 1]]}]}]})
def test_sizes_are_not_coordinates():
"""HP-1502-01: a size must be strictly positive — SVG refuses zero and
negative width/height, and the clients divide by these. view_box [0,0,0,0]
used to pass the shared validator and blank the plan on every client.
Coordinates may still be negative: a crop origin can sit past the edge."""
base = {"id": "s1", "title": "S", "view_box": [0, 0, 1, 1], "rooms": []}
for cfg in (
{**base, "view_box": [0, 0, 0, 0]},
{**base, "view_box": [0, 0, -1, -2]},
{**base, "view_box": [0, 0, 1, 0.0001]}, # below the 0.001 floor
{**base, "rooms": [{"id": "r", "name": "R", "x": 0.1, "y": 0.1, "w": 0, "h": 0.5}]},
{**base, "rooms": [{"id": "r", "name": "R", "x": 0.1, "y": 0.1, "w": 0.5, "h": -1}]},
):
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [cfg]})
# negative COORDINATES stay legal, and a normal crop viewport passes
assert v.CONFIG_SCHEMA({"spaces": [{**base, "view_box": [-0.2, -0.1, 1.4, 1.2]}]})
assert v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
{"id": "r", "name": "R", "x": -0.1, "y": -0.1, "w": 0.4, "h": 0.3}]}]})
def test_openings_cap_enforced():
"""audit follow-up B5: MAX_OPENINGS was defined but never wired in."""
many = [{"id": f"o{i}", "type": "door", "x": 0.1, "y": 0.1, "angle": 0, "length": 0.1}
for i in range(v.MAX_OPENINGS + 1)]
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [{"id": "s1", "title": "S", "aspect": 1.0,
"view_box": [0, 0, 100, 100], "rooms": [],
"view_box": [0, 0, 1, 1], "rooms": [],
"openings": many}]})
@@ -265,17 +319,6 @@ def test_collect_plans_keeps_a_fresh_unreferenced_upload(tmp_path):
assert (d / "f1.inflight.png").is_file()
def test_collect_plans_takes_an_aged_orphan(tmp_path):
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
@@ -347,6 +390,16 @@ def test_every_display_mode_the_editor_offers_is_accepted():
v.MARKER_SCHEMA(_marker(display="wat"))
def test_run_target_is_bounded_to_runnable_domains():
"""Owner's spec 2026-07-29: a tap may RUN automations, scripts and scenes —
and nothing else. The target rides the marker, so the schema is the door."""
for ok in ("automation.morning", "script.curtains", "scene.movie"):
v.MARKER_SCHEMA(_marker(tap_action="run", tap_target=ok, tap_confirm=True))
for bad in ("light.lamp", "lock.front_door", "shell_command.rm", "automation.", "x"):
with pytest.raises(vol.Invalid):
v.MARKER_SCHEMA(_marker(tap_action="run", tap_target=bad))
def test_every_tap_action_the_editor_offers_is_accepted():
for action in _ts_list("TAP_ACTIONS"):
v.MARKER_SCHEMA(_marker(tap_action=action))
@@ -477,31 +530,6 @@ def test_attachment_refs_reads_marker_urls():
assert plans.attachment_refs(cfg) == set()
def test_collect_attachments_supersedes_and_ages(tmp_path):
import os
import time
collect_attachments = plans.collect_attachments
files = tmp_path / "files"
(files / "m1").mkdir(parents=True)
for n in ("old.pdf", "new.pdf", "cancelled.pdf"):
(files / "m1" / n).write_bytes(b"x")
# the commit swapped old.pdf for new.pdf; cancelled.pdf is a fresh upload
# nobody saved — it may belong to a dialog that is still open
removed = collect_attachments(files, _acfg("m1/old.pdf"), _acfg("m1/new.pdf"))
assert removed == 1
assert not (files / "m1" / "old.pdf").exists()
assert (files / "m1" / "new.pdf").is_file()
assert (files / "m1" / "cancelled.pdf").is_file()
old = time.time() - const.PLAN_ORPHAN_TTL_S - 60
os.utime(files / "m1" / "cancelled.pdf", (old, old))
assert collect_attachments(files, _acfg("m1/new.pdf"), _acfg("m1/new.pdf")) == 1
assert not (files / "m1" / "cancelled.pdf").exists()
assert (files / "m1" / "new.pdf").is_file()
def test_collect_attachments_removes_the_empty_folder_and_never_raises(tmp_path):
import os
import time
@@ -552,3 +580,346 @@ def test_legacy_segments_are_dropped_by_the_server():
"segments": [[1, 2, 3, 4]] * 100000,
})
assert "segments" not in out
def _aged(path, seconds):
import os
import time
t = time.time() - seconds
os.utime(path, (t, t))
def _sp(sid, url):
return {"id": sid, "plan_url": f"/api/houseplan/content/plans/_/{url}" if url else None}
def test_plan_collection_matrix(tmp_path):
"""Which config transition means "the user asked for this file to go"?
`old_refs - new_refs` cannot tell replace, detach and delete-space apart —
they look identical. v1.46.4/v1.46.5 added guards for detach and only ever
reached them on the scheduled pass, so the commit itself still deleted a
detached plan the moment it was detached (HP-1465-01). One case is a
deletion the user asked for; the rest are kept.
"""
collect = plans.collect_plans
d = tmp_path / "plans"
d.mkdir()
def seed(*names):
for n in names:
(d / n).write_bytes(b"x")
_aged(d / n, const.SCHEDULED_GRACE_S * 2) # old enough for any rule
# 1. replace: the user picked a different image for the same space
seed("f1.old.png", "f1.new.png")
assert collect(d, {"spaces": [_sp("f1", "f1.old.png")]},
{"spaces": [_sp("f1", "f1.new.png")]}) == 1
assert not (d / "f1.old.png").exists() and (d / "f1.new.png").is_file()
# 2. detach: same space, switched to "draw"
seed("f2.png")
assert collect(d, {"spaces": [_sp("f2", "f2.png")]},
{"spaces": [_sp("f2", None)]}) == 0
assert (d / "f2.png").is_file(), "the editor says the file stays — it stays"
# 3. the space is deleted outright
seed("f3.png")
assert collect(d, {"spaces": [_sp("f3", "f3.png")]}, {"spaces": []}) == 0
assert (d / "f3.png").is_file()
# 4. the scheduled pass, later, still keeps both
cfg = {"spaces": [_sp("f2", None)]}
assert collect(d, cfg, cfg) == 0
assert (d / "f2.png").is_file() and (d / "f3.png").is_file()
# 5. an upload whose save was rejected is kept too, at any age. Ageing those
# out raced the retry: the sweep deleted a file the save was committing a
# reference to (caught by test_sweep_and_a_config_write_do_not_race).
seed("f4.current.png", "f4.reject.png")
live = {"spaces": [_sp("f4", "f4.current.png")]}
assert collect(d, live, live) == 0
assert (d / "f4.current.png").is_file() and (d / "f4.reject.png").is_file()
# 6. the same file still referenced by another space is never touched
seed("shared.png")
assert collect(d, {"spaces": [_sp("a", "shared.png"), _sp("b", "shared.png")]},
{"spaces": [_sp("a", None), _sp("b", "shared.png")]}) == 0
assert (d / "shared.png").is_file()
def test_attachment_collection_matrix(tmp_path):
"""Removing an attachment is a trash button; deleting the device is not."""
collect = plans.collect_attachments
def case(name):
d = tmp_path / name
d.mkdir()
return d
def seed(root, folder, fname, age=None):
(root / folder).mkdir(parents=True, exist_ok=True)
p = root / folder / fname
p.write_bytes(b"x")
if age:
_aged(p, age)
return p
def cfg(*markers):
return {"markers": [
{"id": mid, "pdfs": [{"url": f"/api/houseplan/content/files/{mid}/{n}"} for n in names]}
for mid, names in markers
]}
# 1. the user removed one attachment from a device that still exists
d = case("dropped")
seed(d, "m1", "dropped.pdf")
seed(d, "m1", "kept.pdf")
assert collect(d, cfg(("m1", ["dropped.pdf", "kept.pdf"])), cfg(("m1", ["kept.pdf"]))) == 1
assert not (d / "m1" / "dropped.pdf").exists()
assert (d / "m1" / "kept.pdf").is_file()
# 2. the device itself is gone: its manuals are not ours to throw away
d = case("device_gone")
seed(d, "m2", "manual.pdf", age=const.SCHEDULED_GRACE_S * 2)
assert collect(d, cfg(("m2", ["manual.pdf"])), cfg()) == 0
assert (d / "m2" / "manual.pdf").is_file()
# and the scheduled pass, later, agrees
assert collect(d, cfg(), cfg()) == 0
assert (d / "m2" / "manual.pdf").is_file()
# 3. a dialog that was never saved, in its own staging folder
d = case("staging")
seed(d, "up_x", "manual.pdf", age=const.PLAN_ORPHAN_TTL_S + 60)
assert collect(d, cfg(), cfg()) == 1
assert not (d / "up_x").exists()
# 4. an upload into a live device's folder whose save was rejected: kept,
# for the same reason as a plan's — a retry may be about to reference it
d = case("reject")
seed(d, "m3", "current.pdf")
seed(d, "m3", "rejected.pdf", age=const.SCHEDULED_GRACE_S + 60)
live = cfg(("m3", ["current.pdf"]))
assert collect(d, live, live) == 0
assert (d / "m3" / "current.pdf").is_file()
assert (d / "m3" / "rejected.pdf").is_file()
def test_only_a_staging_folder_ages_out(tmp_path):
"""The one age rule left. Everything else waits for the user to say so."""
import os
import time
files = tmp_path / "files"
(files / "m1").mkdir(parents=True)
(files / "up_abandoned").mkdir(parents=True)
ancient = time.time() - const.SCHEDULED_GRACE_S * 12
hour_ago = time.time() - const.PLAN_ORPHAN_TTL_S - 60
for path, when in (
((files / "m1" / "ancient.pdf"), ancient),
((files / "up_abandoned" / "manual.pdf"), hour_ago),
):
path.write_bytes(b"x")
os.utime(path, (when, when))
cfg = {"markers": [{"id": "m1", "pdfs": []}]}
assert plans.collect_attachments(files, cfg, cfg) == 1
assert (files / "m1" / "ancient.pdf").is_file(), "age alone is never a reason"
assert not (files / "up_abandoned").exists(), "a cancelled dialog goes after an hour"
# ---------- square canvas migration (v1.48.0) ----------
gm = _load_pure("geometry_migration")
def _sq(space, layout=None):
cfg = {"spaces": [space]}
gm.migrate_config(cfg, layout if layout is not None else {})
return cfg["spaces"][0]
def test_the_viewport_becomes_the_whole_square():
"""The grid is drawn over the view box, and the fit fits it.
Transforming the old rectangle instead would leave the new margins outside
the canvas — no grid there and nothing to draw on — which is the room the
square canvas was meant to add.
"""
sp = _sq({"id": "f1", "aspect": 0.5, "view_box": [0.1, 0.2, 0.5, 0.5], "rooms": []})
assert sp["view_box"] == [0.0, 0.0, 1.0, 1.0]
def test_a_wide_plan_gains_margins_above_and_below():
sp = _sq({
"id": "f1", "aspect": 2.0, "cell_cm": 5, "view_box": [0, 0, 1, 1],
"rooms": [{"id": "r", "x": 0.0, "y": 0.0, "w": 1.0, "h": 1.0}],
})
r = sp["rooms"][0]
assert (r["x"], r["w"]) == (0.0, 1.0), "the width is untouched"
assert r["y"] == 0.25 and r["h"] == 0.5, "half the height, centred"
assert sp["cell_cm"] == 5, "the grid is tied to the width, which did not change"
assert "aspect" not in sp
def test_a_tall_plan_gains_margins_on_the_sides_and_rescales_the_grid():
sp = _sq({
"id": "f1", "aspect": 0.5, "cell_cm": 5, "view_box": [0, 0, 1, 1],
"rooms": [{"id": "r", "poly": [[0, 0], [1, 0], [1, 1], [0, 1]]}],
})
poly = sp["rooms"][0]["poly"]
assert [round(c, 6) for c in poly[0]] == [0.25, 0.0]
assert [round(c, 6) for c in poly[2]] == [0.75, 1.0], "half the width, centred"
assert sp["cell_cm"] == 10, "the canvas got twice as wide, so a cell is twice the cm"
def test_a_square_plan_is_left_alone():
before = {
"id": "f1", "aspect": 1.0, "cell_cm": 5, "view_box": [0, 0, 1, 1],
"rooms": [{"id": "r", "x": 0.1, "y": 0.2, "w": 0.3, "h": 0.4}],
}
sp = _sq({**before, "rooms": [dict(before["rooms"][0])]})
assert sp["rooms"][0] == before["rooms"][0]
assert sp["cell_cm"] == 5 and sp["view_box"] == [0.0, 0.0, 1.0, 1.0]
def test_migration_preserves_real_lengths_and_shapes():
"""A wall keeps its length in centimetres, and a square stays square."""
GRID = 1000.0
def wall_cm(space, p, q):
# render units per normalised unit is the canvas width, always 1000
dx = (q[0] - p[0]) * GRID
dy = (q[1] - p[1]) * GRID
pitch = GRID / 40 # whatever the grid is, the same constant both sides
return ((dx * dx + dy * dy) ** 0.5 / pitch) * float(space["cell_cm"])
for aspect in (2.0, 0.5, 0.8155784250916674, 1.4142):
# a square room, 0.2 x 0.2 of the OLD box, i.e. 200 x 200/aspect render
old = {"id": "f", "aspect": aspect, "cell_cm": 5,
"rooms": [{"id": "r", "poly": [[0.2, 0.2], [0.4, 0.2], [0.4, 0.4], [0.2, 0.4]]}]}
before_w = 0.2 * GRID
before_h = 0.2 * GRID / aspect
before_cm_w = (before_w / (GRID / 40)) * 5
sp = _sq(old)
poly = sp["rooms"][0]["poly"]
after_w = (poly[1][0] - poly[0][0]) * GRID
after_h = (poly[2][1] - poly[1][1]) * GRID
assert abs(after_w / after_h - before_w / before_h) < 1e-9, "shape preserved"
# cell_cm is stored rounded — a user reads it — so allow 0.01 cm on a
# 40 cm wall rather than pretending the scale is infinitely precise
assert abs(wall_cm(sp, poly[0], poly[1]) - before_cm_w) < 1e-2, "length in cm preserved"
def test_migration_moves_marker_positions_of_that_space_only():
layout = {
"a": {"s": "f1", "x": 0.5, "y": 0.5},
"b": {"s": "other", "x": 0.5, "y": 0.5},
"c": "not a dict",
}
cfg = {"spaces": [{"id": "f1", "aspect": 2.0, "rooms": []},
{"id": "other", "rooms": []}]}
assert gm.migrate_config(cfg, layout) is True
assert layout["a"] == {"s": "f1", "x": 0.5, "y": 0.5}, "x untouched for a wide plan"
assert layout["a"]["y"] == 0.5
assert layout["b"] == {"s": "other", "x": 0.5, "y": 0.5}, "another space is not touched"
def test_migration_runs_once_and_only_when_needed():
cfg = {"spaces": [{"id": "f1", "aspect": 2.0, "rooms": [], "cell_cm": 5}]}
assert gm.migrate_config(cfg, {}) is True
snapshot = repr(cfg)
assert gm.migrate_config(cfg, {}) is False, "already square: nothing to do"
assert repr(cfg) == snapshot
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"