With the day/night background the plan went blurry after zooming from 100 %
(sharp when the page was opened at 800 %) and navigating a strongly zoomed
plan flashed the page white. Both came from #582's composition, not from the
wall hatch that #685 replaced:
- `.stage.daycycle.hp-safe-daycycle-outline .plan-svg` promoted the scene
with `will-change: transform`; Chromium freezes the raster scale of such a
layer, so the 100 % raster was shown stretched. The explicit layer #582
needs is now `will-change: opacity` (re-rasters at the current scale).
- The filtered outline had `overflow: visible` and a gesture exposed every
scene (#544) without bound, so the promoted layers grew with zoom squared
(CDP LayerTree, ~460 %: plan-svg 15.9x, outline 13.2x the stage; 39x after
navigating at 800 %). The full card clips its outline to its box and marks
it data-hp-live-overflow="clip" (never exposed); the live viewport bounds
every other exposure with an inline clip-path: inset(-25%) that leaves
with it, so idle DOM stays byte-identical (#531).
Owner-verified in Chrome 152 (built-in browser, DPR 2): sharp after 100 ->
800 %, no white flashes after reloading at 800 %.
Owner decision: #685's analytic gradient is reverted (13af1d5e), the single
<pattern> is back at every zoom; its close-up golden scenes stay and check
the pattern, its terminal-frame smoke checks the pattern.
Witnesses: demo/smoke_daycycle_zoom_layers.mjs (800 % x DPR 2: layers vs
stage, the hint, reload path, button/wheel/pinch); #582/#532 smokes now pin
the opacity hint; test/live-viewport.test.mjs (bounded exposure, clipped
scene); test/daycycle-layers.test.mjs (cascade). Four Node-guarded mutants.
Issue: #689
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
44 KiB
House Plan architecture
One HACS repository (category Integration) ships the backend
(custom_components/houseplan), both Lovelace cards and the /houseplan panel
(src/ → dist/). This document is the map: each subsystem gets a short
description and a link to its canonical document, which owns the details.
Styles (#266)
src/styles.ts composes cardStyles = [base, plan, devices, chrome, dialogs, isoTiles] from src/styles/*.styles.ts; the order is a cascade contract (the
golden set is accepted against it; isoTiles last so 2.5D tiles beat
equal-weight Flat marker rules, #649). base holds host, variables and rules
shared by two surfaces; plan stage ink, devices markers, chrome
toolbars/tabs/menus, dialogs dialogs/forms/pickers. form-kit.styles.ts
(#594), editor-secondary.styles.ts and the summary-panel sheets live outside
that aggregator. test/styles-split.test.mjs pins composition, no cross-file
duplicate selectors and @media wrappers; scripts/dev/styles-diff.mjs proves
a move refactor-only. Public selectors: STYLING-HOOKS.md.
Layout
houseplan-card/
├─ src/ # card sources (TypeScript + Lit 3)
│ ├─ houseplan-card.ts # eager full card: View shell, HA lifecycle, lazy-runtime hosts
│ ├─ houseplan-panel.ts # /houseplan sidebar host around one full card (#486)
│ ├─ space-card.ts · space-render.ts # read-only houseplan-space-card and its static renderer
│ ├─ houseplan-editor-runtime.ts # lazy Plan/Devices/Background composition root
│ ├─ houseplan-onboarding-runtime.ts # lazy first-space/import dialogs, independent of the editor
│ ├─ iso-*.ts · pdf/ · stairs*.ts · furniture*.ts # 2.5D, PDF export, stairs, furniture (own docs)
│ ├─ devices.ts · rules.ts # device list from registries; icon rules and exclusions
│ ├─ ha-binding-status.ts · device-presentation*.ts # binding authority; marker face decisions
│ ├─ space-geometry.ts · wall-*.ts · physical-geometry.ts · logic.ts · types.ts # pure, no Lit/DOM
│ ├─ config-store.ts · config-adoption.ts · command-stack.ts # config cache, #500 boundary, Undo
│ ├─ sun.ts · day-cycle-render.ts # window rays, four-phase background (SUN.md)
│ ├─ vacuum*.ts · radar-*.ts · zigbee-topology*.ts · summary-panel*.ts # live subsystems
│ ├─ hp-dialog.ts · hp-confirm.ts · danger-confirm.ts · hp-help.ts · floating-surface*.ts
│ ├─ editor-runtime-loader.ts · version-recovery*.ts · editor-secondary.ts # lazy loader; #462; tray
│ ├─ editor.ts · space-editor.ts # Lovelace GUI config editors of both cards
│ └─ editors/ · render/ · styles/ · i18n/ # settings dialogs, SVG projections, sheets, locales
├─ dist/ # two stable entries, houseplan-assets.json, hashed chunks
├─ demo/ # demo rig and smokes; golden/ (HP-QA-01), performance/
├─ scripts/ · .github/workflows/ # build/bundle gates, release-*.mjs; CI and release
├─ custom_components/houseplan/ # the HA integration
│ ├─ __init__.py · store.py # setup/unload; versioned stores and per-entry runtime data
│ ├─ websocket_api.py · http_api.py · auth.py # WS commands, uploads, the one may_write policy
│ ├─ validation.py · coordinate_canonicalization.py # pure schema validation, write canonicalisation
│ ├─ frontend_registration.py · frontend_assets.py · panel_registration.py # resource, chunks, panel
│ ├─ import_export.py · decor_assets.py · plans.py # backup/import, decor images, plan blobs
│ ├─ trails.py · vacuum_routes.py · radar*.py · virtual_lights.py # live-subsystem backends
│ ├─ config_flow.py · const.py · system_health.py · diagnostics.py · repairs.py · support_*.py
│ └─ frontend/ # release-only snapshot of dist (#657)
└─ docs/ # this documentation
Rollup emits two stable roots, houseplan-card.js and houseplan-panel.js,
plus hashed chunks and dist/houseplan-assets.json (graph, sizes, SHA-256).
The panel imports the card chunk by its hashed name, never through the card's
unversioned facade: entries are served without Cache-Control, and a stale
facade once ran an old card against the current backend (#535). Both roots keep
the fail-loud stale-load wrapper. View loads only the initial graph; editor,
onboarding, 2.5D (#649), PDF, locale and furniture-art graphs are lazy, each
passing the exact-build fingerprint handshake (one cache-busted retry, atomic
install), and scripts/bundle-budget.mjs fails a build whose lazy graph is
missing or leaks into the initial graph. The backend serves only manifest-listed
chunks. Commands and gates: DEVELOPMENT.md › Build, › Tests, › Release.
Key decisions
- One repository — integration + panel + cards. The integration serves
its own JS; the exact versioned module URL is the resource identity, a
writable Lovelace resource registry is authoritative and
add_extra_js_urlthe truthful fallback. Eachhouseplan/config/getcarries the authoritativeintegration_version: View offers a manual reload, kiosk reloads once per backend target in a safe idle state, the space card has no version controller (DEVELOPMENT.md› Resource registration and version recovery (#462)). After migrations succeed, setup registers the public/houseplanpanel (require_admin=False, #486): a thin app-bar host around one ordinary full card, fail-soft, removed on unload only by the exact owner and setup generation (UX-MODES.md› Principle;TESTING.md› Installation / upgrade / removal). In a Sectionslayout="grid"slot HA owns the height:.stageis the shrinking flex child and transitions use the measured card height, neverwindow.innerHeight;getGridOptions()is full width, 10 rows, min 6 (#648). - Server-side storage, no token.
store.pykeeps.storage/houseplan.config,houseplan.layout(marker positions) andhouseplan.virtual_lights;trails.pykeepshouseplan.trails. All traffic uses the frontendhassconnection (Integration WS API below); House Plan opens no socket and holds no token. localStorage keeps a start-up snapshot and, only if no backend ever answered, a local layout. Registries come from oneha-binding-statusfetch and subscription pair per HA connection, shared by every card on the page; live rows augment an older snapshot at once and a debounced full reload reconcilesdisabled_byeven without registry events (FILTERING.md› Behaviour). - Reactivity. Every
hassupdate re-renders. Registry rebuilds also run the puredevice-area-relocationresolver: pending ids override stale layout in full and static cards at once; the writer deletes those points before advancing Area provenance and restores them if the config write is rejected (a failed restore leaves the attention marker); only the moved device's Undo/Redo is invalidated; limited snapshots never infer movement (CONFIG-COMPATIBILITY.md› Marker Area provenance (#126)). - One modal contract. Card modals render through
hp-dialog:ha-dialogif registered when the instance connects, otherwise a first-class native<dialog>for its lifetime (top layer, backdrop, reopened once if:modalwas lost); a late registration never swaps an open surface, and alert confirmations stay native for realalertdialogsemantics. The wrapper owns title, initial focus, Escape and a shadow-root-scoped restore-focus session (nested dialogs return to their trigger, a replacement to the first opener).flex-contentforwardsflexcontentso an inner scroller is height-bound (#508); the footer stays a full-width slot item; titles wrap; destructive actions stay left while Cancel/Save wrap right together. Dangerous actions use one eagerHpConfirmControlleronHouseplanCardplus statelesshp-confirm(#32): replace-not-queue, every dismissal cancels, a token rejects stale decisions, callers re-resolve targets afterawait. Nativeconfirm()is not a supported surface. - Open passages are negative architecture (#157).
type=passageshares placement, wall cut and tunnels with other openings but has no leaf, binding or 2.5D panel; static wall fingerprints add only passage cuts, so door/window/ gate output is unchanged (CONFIG-COMPATIBILITY.md› Open-passage opening type). - One transient-surface contract (#68, #57).
hp-dialogkeeps a scoped LIFO registry for help and colour-picker surfaces: Escape/toast close the top one first; a new one replaces the previous only inside the same dialog.hp-helpandhp-color-opacitysharefloating-surface.tsplacement andfloating-surface-controller.ts(Popover top layer, dialog-owned portal fallback). Help is localized by the owning card and exists only when body and accessible label are both non-empty. Page-wide lazy runtimes — locales (#62, #400) and furniture artwork (#474, #593) — settle after two failed attempts into English / no artwork with one toast, never an inert card. - One four-phase environment resolver.
resolveDayCycle()(src/sun.ts) andsrc/day-cycle-render.tsserve View, kiosk and the space card; plan content is never phase-filtered (SUN.md› Current four-phase background). The stage-sized outline root owns the triple drop-shadow andwill-change: filter; its child is only the exact paper alpha silhouette, and the plan root gets an explicit stage-sized transform layer so Chromium never rediscovers it as an implicit overlap layer (#532, #582).
Coordinate system
Plan geometry and marker positions are stored normalised on an unbounded canvas:
0..1 = NORM_W (1000) render units, ±5000 validation, 1/240 lattice
(src/canvas-constants.ts). Source of truth: CANVAS.md › Principle, › Model,
› Grid precision and visual units, › Persisted coordinate canonicalisation.
Card data model (runtime)
Optional space model (#113). Without authoritative spaces there is no
SpaceModel: _spaceModel() returns undefined and never invents one. Its
active-or-first choice serves rendering and navigation only; commands carrying a
stable space id use the exact _spaceModelById() and abort on a stale id before
any config, layout, file or service effect (space-model-selection.ts). The
first update that sees an authoritative empty spaces runs one cleanup (pointer
capture, gestures, draft/history, space dialogs, pending config write, back to
View); recreating a space re-arms it. Pure helpers may return empty results;
mutation entry points guard explicitly.
DevItem (src/types.ts) is rebuilt by buildDevices() (src/devices.ts)
from the active registry projection plus config markers: id, name, model, area,
space, icon, entities[] (active runtime entities only), allEntities[]
(metadata only), primary (first resolved state entity for single-target
actions; marker faces use the resolved role, DEVICE-PRESENTATION.md),
temp/hum, marker metadata and effective controls. bindingStatus (active,
ha_disabled, orphaned, unverified) comes only from
resolveHaBindingStatus(), never mutates markers, and gates every plan-level
consumer (Glow, climate, LQI, live text, openings, controls, vacuum).
Auto-discovery takes non-service devices whose HA Area is bound to a space. The
base icon is the first matching icon rule ("<name> <model>") → entity
device_class → mdi:chip; a device with a lock.* entity is always
mdi:lock. Duplicate name|area pairs are numbered; HA light groups and Z2M
group devices become lg_<entity> items that fold their lamps. Hiding is
FILTERING.md.
Live data
State, values, LQI and activity are resolved once per frame into one
ResolvedDevicePresentation (see Device markers); explicit marker.value_source
and marker.value_badge go through src/device-value-badge.ts. Decision table,
plate colours and LQI scale: DEVICE-PRESENTATION.md.
Presence radars (#485 Stage 1)
marker.radar is an optional versioned namespace saved only by the ordinary
config transaction. One backend RadarCoordinator per entry alone reads raw HA
states and streams clipped, ACL-filtered frames over houseplan/radar/subscribe;
observations, calibration samples and trails never reach config, uploads,
support reports or browser storage. Contract and code map: RADAR.md;
normative limits: specs/485-radar-presence-stage1.md.
Robot vacuums
src/vacuum-routes.ts is the only map-to-space authority (six fail-closed
results, #162; mirrored byte-for-byte by vacuum_routes.py), src/vacuum.ts
owns telemetry normalization, path arbitration and smoothing, and trails.py
records runs server-side. Contract and code map: VACUUM.md.
Sizes
icon_size is a percentage of the plan (default 2.5; legacy px values > 8 fall
back to 2.5), converted once at the surface boundary and rendered in cqw inside
.stage { container-type: inline-size } — CANVAS.md §6.
Sticky header
.head is position: sticky; top: var(--header-height, 56px), so ordinary
dashboard cards keep ha-card { overflow: visible } (hidden overflow breaks
sticky). The bounded panel-host and Sections layout="grid" branches own their
complete height chain and clip at the external slot instead (Key decision 1).
Device markers
config.markers[] records are
{id, binding: 'device:<id>'|'entity:<eid>'|'virtual', space?, area?, hidden?, removed?, name?, icon?, …}
plus presentation, light, control, vacuum and radar fields (validation.py is
authoritative). Registry devices appear on their own; a device: marker
overrides one, entity: covers groups/helpers, virtual is a manual icon. The
id (device id, lg_<eid> or v_<rand>) keys the layout. hidden is the
reversible hide flag, removed a never-rendered binding tombstone (FILTERING.md).
View, kiosk, the static card and the dialog preview share one pipeline:
device-visual.ts classifies, device-presentation.ts resolves sources, the pure
device-presentation-policy.ts owns priority, device-pulse.ts owns activity and
device-face.ts only paints. normalizeDeviceDisplay() is the mandatory read gate
for display (badge|icon_ripple|value|static_icon|value_static_icon; legacy
ripple → icon_ripple). The saved coordinate is the icon-core centre;
overlapping 44 px targets get one screen-space owner (device-hit-owner.ts),
latched for the whole pointer sequence. Rules: DEVICE-PRESENTATION.md;
stored fields: CONFIG-COMPATIBILITY.md.
Attachments are staged in up_*, promoted into <config>/houseplan/files/<id>/
on Save and served by signed /api/houseplan/content/files/… URLs (Integration
WS API). Custom Background images use the content-addressed
<config>/houseplan/assets/ store (asset_id = SHA-256 of canonical bytes;
config never carries bytes or URLs) — DECOR-EDITOR.md §7.
Server-side configuration
.storage/houseplan.config holds {model_version, spaces[], markers[], settings}.
A space carries plan-image fields, rooms[] (with ordered wall_ids),
wall_segments[], partitions[], wall_columns[], openings[], decor[],
stairs[] and settings. Coordinates keep the historical normalization
(1.0 = NORM_W = 1000 render units) on an unbounded plane with a ±5000 guard
(CANVAS.md); plan_aspect letterboxes the image and plan_x/y,
plan_scale_x/y, plan_angle transform it (DECOR-EDITOR.md §3).
Layout v2 maps device_id | rl_<roomId> to {s, x, y}. Plan files are
copy-on-write <config>/houseplan/plans/<space>.<token>.<ext>, never deleted for
age (SCOPE.md). Migrations: CONFIG-COMPATIBILITY.md.
Persisted colour boundary
Every stored colour is exactly #RRGGBB: src/color.ts resolves it and
validation.py::_COLOR guards writes. Render-time resolvers re-apply a safe
default because old, imported or hand-edited stores are not migrated on read.
HA rgb_color is live state: three finite channels, clamped/rounded to 0–255 and
emitted only as generated rgb(R, G, B). The final inline-style sink accepts
only those two forms; arbitrary CSS colour syntax would need a separate
product/security decision and must not be added to an individual sink.
Room and independent wall geometry
Model v10 (#282, #306, #478): wall_segments[] is the authoritative catalog of
atomic room-wall intervals with stable ids, rooms[].wall_ids[] orders each
contour, and room polygons plus walls[] are read-compatibility projections.
Every accepted Walls-chain edge is an ordinary partition; wall_columns are
square/circular columns; neither creates or implicitly splits a room or HA area.
cm:0 keeps a structural axis and identity without masonry; space.zero_wall_style
makes it dashed (transmits light) or solid (a zero-area barrier). Reads are
projection-only: a physical-geometry mutation builds a local candidate,
canonicalizes it, materialises the catalog (src/wall-segment-model.ts ↔
wall_segment_model.py, shared parity fixture), validates references and commits
one config transaction; ambiguity fails closed with no partial config, history or
revision. Model and lineage: WALL-THICKNESS.md §1;
migrations and stale-client guard: CONFIG-COMPATIBILITY.md
(model v8–v10); rationale: adr/282-wall-geometry-representation.md.
Rooms may not partially overlap (lying on a shared wall is legal, a fully nested
island is supported). Merge/Split use polyclip-ts (not polygon-clipping,
see DEVELOPMENT.md): Merge accepts a pair only when the union
is one hole-free outline; Split cuts wall-to-wall and the larger part keeps the
room identity (name, area, devices).
wallBodiesGeometry() is the single physical masonry for flat full/static
rendering, 2.5D, paper, clean floor and Glow/sun occlusion. Its exterior shell
comes from the union of room centrelines plus surviving outer atoms; junctions
follow the bounded mitre/bevel rules (#249, #271, #272, #275, #288, #302, #309,
#310); independent bodies join through physicalBodySet() while raw quads keep
editor identity. The result is a typed component set (ok, degraded-extra,
failed-core; #197, #278): rendering keeps every valid component, mutation
preflight rejects degraded-extra, failed-core fails dark. It is computed state
only — cached per structural geometry, never written back, never rebuilt by HA
state ticks. Contract: WALL-THICKNESS.md §2–§4, §9–§11.
Markup editor
Card state: _mode, _tool (select|draw|column|merge|split|resize|opening| stairs|wallthick|delroom), session _path on the GRID_N = 240 lattice
(_snap, CANVAS.md §9). Committed geometry enters the named 50-command
Undo/Redo stack shared with Background (DECOR-EDITOR.md §6, §9); it survives
the echo of its own writes and clears on a newer external revision. Every
physical-geometry writer crosses one fail-closed boundary (#278,
checkSpacePhysicalGeometry()) before history/save, rechecked at the deferred
write; presentation edits bypass it. Contracts: Walls chain, faces, room
deletion, partitions — WALL-THICKNESS.md §6, §9–11, CONFIG-COMPATIBILITY.md
(#478, #461), TOUCH-SUPPORT.md; Resize (#277, #300) — RESIZE.md; near-axis
(#290, src/near-axis.ts) — CANVAS.md §9.3. reconcileCoincidentPartitions
(#276/#296/#477) and normalizeWallIntervals (#299) run plan-wide only in
explicit Optimize, locally in chain finish/room deletion, never in render or
ordinary saves. Saves strip the legacy root space.segments.
Stairs (#663)
Separate Plan entity, not decor (STAIRS.md). Eager StairViewRuntime:
symbols, tooltip, guarded navigation; lazy StairEditorRuntime: drawing,
transforms, magnets, properties (a plan never loads the editor graph). The root
card keeps lifecycle, shared history/persistence and stage pointer terminals;
pure model, tread/trapezoid geometry and style resolution stay in stairs.ts.
The optional color/opacity and fill_color/fill_opacity fields are a
snapshot owned by the stair. Missing legacy fields resolve at render/dialog
time from the current decor default but are not written until the user saves
that stair. Screen renderers consume the colour fields; PDF deliberately uses
the same geometry with its existing monochrome ink palette.
Editor chrome and contextual controls
One stable .editbar per editor: .editbar-tools (persistent tools,
Undo/Redo) and pinned .editbar-end (Close); transient controls never enter
this measured row. Selection actions, tool parameters, hints, palettes and the
approved groups (Opening, Stairs) come from one EditorSecondaryModel in the
single light-DOM .editor-secondary-host inside .stage
(editor-secondary.ts): outside the _hdrH measurement, pointer events only
on its visible surface, empty in the Device editor (future marker quick
actions go there). Mutating actions revalidate a deterministic contextId;
Delete/Backspace never fall through from it. Product rule: UX-MODES.md.
Doors, windows, gates & passages
space.openings[] is plan geometry, not markers: {id, type: door|window|gate|passage, x, y, angle, length, host?, contact?, lock?, invert?, flip_h?, flip_v?}. host {kind: wall|partition, id, t} is authoritative and
x/y/angle its atomically refreshed projection; an absent host is the legacy
room-wall association, and no host ever falls back to a nearest wall
(CONFIG-COMPATIBILITY.md › #132/#157). One OpeningWallIndex feeds symbol,
cut, tunnel fill, Glow and the 2.5D face (WALL-THICKNESS.md §3–4,
ISOMETRIC.md, LIGHT.md). The Flat symbol follows easy-floorplan (MIT).
openingAmount() maps contact state to 0..1 — no sensor: door/gate open,
window closed; unknown/unavailable keep that default. Contact and lock are
opening-owned exact references: candidates follow HA binding status, render
reads the frozen active-registry projection (never raw hass), neither
consults marker tombstones. The .oplock badge never toggles a lock
(resolveToggleIntent → no-op, SCOPE.md); openings are edited only in Plan.
Integration WS API
auth.may_write() is the single writer policy for WS and HTTP: administrators
always write; admin_only (default true when unset) restricts writing to
them; with admin_only: false other users write unless they belong to
system-read-only. A missing entry or incomplete group data fails closed.
Reads are deliberately broader: every authenticated user receives the complete
config/layout (no per-entity projection), trails without source entity ids
(#626) and may toggle virtual lights. W = writer-only (else
unauthorized); runtime-backed commands answer not_ready before setup.
houseplan/… |
Parameters | Result · domain errors |
|---|---|---|
config/get |
space_id?, fields?, marker_fields? (#256; project only the document) |
{config, rev, virtual_lights:{rev,config_rev,off[]}, can_write, can_optimize_undo, undo_kind, integration_version, support_api, decor_assets_api, summary_panel_api, radar_stage1_api?} |
config/set W |
config, expected_rev |
{ok, rev} · conflict, too_large (2 MiB), invalid_format, missing_plan, semantic codes below |
layout/get |
space_id? |
{layout:{id:{x,y,s?}}, rev, can_optimize_undo, undo_kind} |
layout/set W |
layout, expected_rev |
{ok, rev} · conflict (external clients; the card writes points) |
layout/update W |
device_id, pos |
{ok, rev, ignored?: removed|missing_virtual} — a tombstoned owner's late drag is acknowledged, not stored |
layout/delete W |
device_id |
{ok, rev} (rev: null when nothing was stored) |
space/delete W |
space_id, expected_config_rev, expected_layout_rev |
{ok, config_rev, layout_rev, removed_layout} · space_in_use, space_not_found, invalid_space_id |
plan/optimize W |
config, layout, both expected revs |
{ok, config_rev, layout_rev, can_undo} — also the server-side wall-model migration barrier |
plan/optimize_undo W |
both expected revs | restores the one-deep Optimize/full-import backup · no_backup after any later edit |
geometry/repair W |
space_id, aspect, dry_run?, undo?, expected_rev? |
manual re-transform of one space's positions (HP-1500-01) with one-deep repair_backup · nothing_to_repair, no_backup |
export/create W |
kind: full|space, space_id?, plan_only?, card_version |
{document, filename} |
import/revalidate W |
token, duplicate_policy? |
refreshed preview and current revisions |
import/apply W |
token, both expected revs, duplicate_policy?, confirm_missing_content? |
paired commit; full import gets one-deep undo · conflict, preview_expired, content_confirmation_required, missing_plan, missing_content |
virtual_light/toggle |
marker_id |
{marker_id, on, rev} · not_toggleable |
trail/get / trail/delete W |
— / marker_id |
{trails:{marker:{current,previous}}} / {ok, removed} |
plans/list W / plans/delete W |
— / name |
newest 60 {name,url,size,modified,used_by} + total / {ok, removed} · in_use, invalid_name |
plan/set W |
space_id, ext, data (base64) |
{ok, url} — kept only for pre-#617 cards; current cards use HTTP |
files/migrate W |
from_id, to_id |
{ok, mapping, copied} — copies, never moves or overwrites |
files/cleanup W |
marker_id |
{ok, removed, kept} — removes only files the stored config does not reference |
assets/list W / assets/delete W |
— / asset_id |
catalog with authoritative used_by / {ok, removed} · in_use |
assets/resolve |
asset_ids[] (≤200) |
{assets, missing}; non-writers resolve only ids the saved config uses |
content/sign |
paths[] |
{urls} — 24 h authSig, only /api/houseplan/content/…, first 200 paths |
support/* W, radar/* |
— | SUPPORT-PRIVACY.md, RADAR.md |
Semantic write codes: invalid_config, invalid_passage_fields,
invalid_partition_opening_host, invalid_partition_opening_jamb_margin,
wall_model_client_outdated, wall_model_migration_blocked (Optimize),
junction_limit_<rule>, invalid_radar, invalid_light_entity,
invalid_vacuum_map_route, *marker_control*, invalid_value_badge*; paired
writers add commit_failed. Events: houseplan_config_updated,
houseplan_layout_updated ({rev}), houseplan_virtual_light_updated,
houseplan_trail_updated.
HTTP views (requires_auth, same policy): POST /api/houseplan/upload
(marker attachment), /plans/upload (#617), /assets/upload
(DECOR-EDITOR.md §7), /import/preview and GET /api/houseplan/content/{kind}/{sub}/{name} (nosniff; SVG only gets a
sandbox CSP, HP-1454-01). Only the manifest-gated bundle under
/houseplan_files/ is public; legacy /houseplan_files/plans|files URLs are
still recognised in stored config but no longer served
(CONFIG-COMPATIBILITY.md › Legacy content URLs).
Invariants
- Optimistic locking. Each config/layout writer takes
write_lock, resolves a pending pair, then checks revisions before validation, no-op detection or file collection.expected_revmay be omitted only atrev = 0(#340, #356). External writers readrevviaconfig/get/layout/get, send it asexpected_rev, and onconflictre-read and retry (#368). A canonical no-op keeps revision, events and the maintenance backup. - Paired writes (Optimize, Optimize Undo, full import, space delete) use the
durable
optimize_pendingintent and one-deepoptimize_backup(kind: optimize|import) —CONFIG-COMPATIBILITY.md› #491. Events fire only after both halves are durable; Store exceptions are resolved by reloading and comparing exact payloads; layout-store writes go throughasync_save_layout_stateso unknown metadata survives. - Validation is the server's. Schema/semantic checks run in the executor
under the lock; the browser never parses an import. Frontend preflight
(
src/plan-geometry-preflight.ts) only avoids doomed calls. - Files (
SCOPE.md› Standing rule): uploads claim a fresh name (reserve_filename,O_EXCL), plans are copy-on-write<space>.<token>.<ext>(a space id cannot contain.), andcheck_quotabounds bytes/files/free disk at upload.config/setcollects, under its lock, only what its own commit replaced; a daily pass runs the collectors with the stored config on both sides and sweeps.upload-temporaries. A newly referenced internal plan must exist (missing_plan; import also checks attachments). A usableContent-Lengthis checked before streaming, the staged size again underupload_lock, which also serialises image decoding. Collection classifies by owner, not by "is it referenced" (HP-1465-01); nothing is deleted for being old exceptup_*staging afterPLAN_ORPHAN_TTL_S(1 h), andplans/list/plans/deletemake "we never delete" livable:Case 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 (the image was imported and may be nowhere else) kept Space has a plan plus another file of its own (a rejected save) kept — ageing these out raced the retry Marker in both, attachment dropped from its list removed immediately Marker gone kept Attachment in up_*(a dialog never saved)removed after PLAN_ORPHAN_TTL_SMarker there, file it never listed (a rejected upload) kept - Import preview streams ≤8 MiB, rejects duplicate/prototype keys, non-finite numbers and future model versions, and keeps the candidate in memory for 10 min behind a token bound to the user, candidate digest and both revisions (global and per-user caps). Export deep-copies one coherent pair under the lock and builds outside it.
- Virtual-light state lives in its own Store (
CONFIG-COMPATIBILITY.md); toggles reply immediately and coalesce into one delayed durable write, flushed before config transitions and unload. A cached snapshot never authorizes an optimistic toggle.
Client side
_writeConfig()keeps oneconfig/setin flight, each carrying the previous reply's revision (HP-1454-03). A rejected physical transaction restores the earliest server-backed snapshot of every affected space and reloads (#314).src/config-adoption.ts(#500) owns config/layout body + revision + fingerprint. It changes only by authoritative adoption (adoptAuthoritativeGated, profilesreload/post-write), own-write acceptance (acceptConfigWrite,acceptPairWrite) or warm-cache restore; paired writers take revisions from the re-read. Local staging is limited to files pinned bytest/config-adoption-ownership.test.mjs. Ordinary debounced editor saves remain optimistic: a rejection that is neither a revision conflict (which reloads) nor a physical-geometry rollback keeps the local edit with a failure toast until the next authoritative reload. Writers that must undo on rejection capture anOptimisticAttempt(beginOptimistic/rollbackOptimistic), which restores the server-backed body only while that failed candidate is still current (#442, #500).src/config-reload-authority.ts(#543): each reload holds a generation-scoped claim; a superseded one ends with no side effect.ContentSigner(src/signing.ts) is the only signer for both cards:MAX_SIGN_PATHS(200) is shared withconst.py; the cache is age-aware and pruned to live URLs; queued/in-flight are distinct, failures back off and in-flight entries expire afterSIGN_INFLIGHT_MS.- Room climate is one
roomClimateMap()pass per hass snapshot (#317) shared by full and static cards; exactentity:placement beats its parentdevice:(no double vote); never call theareaClimate()wrapper in render. - Load, continuity and fixed-floor rules:
WARM-REMOUNT.md§ 5.
Second card: houseplan-space-card (read-only)
One bundle registers houseplan-card (interactive) and the read-only
houseplan-space-card (one space; src/houseplan-card.ts imports
./space-card). User options, fit and the deep link: USER-GUIDE.md §18,
CANVAS.md §4.4, LIGHT.md › Which surfaces render pools. Shared modules keep
the two views from diverging: space-geometry.ts (pure model/position math),
space-render.ts (renderSpaceStatic(): plan, rooms and markers through
buildDevices, ResolvedDevicePresentation and renderDeviceFace, no marker
handlers), glow-scene.ts (opt-in light_pools, per-card bounded caches) and
config-store.ts — one module-level {config, rev, configFingerprint, layout, layoutRev, layoutFingerprint} cache and one subscription for all embedded
cards, seeded from houseplan_card_cfg_v1 and refreshed on
houseplan_config_updated/houseplan_layout_updated without first clearing
the visible snapshot. .hp-static-stage and every descendant
(*, *::before, *::after) are pointer-events:none, overriding the markers'
44 px opt-in (#564, #664); only the footer button is interactive, asserted by
demo/smoke_space_card.mjs with elementFromPoint. A card with floor
ignores #space=.
Subsystems with their own canonical documents
- Decor, plan image, furniture, custom images —
space.decor[]is a purely visual layer with one selection/transform/history pipeline; image bytes live only in the asset store: DECOR-EDITOR, FURNITURE. - Light and Glow — one visibility region per source (#71); Glow is an overlay
independent of the data fill (#55): LIGHT. Room fill —
resolveEffectiveRoomFill()is the single projection for room floors, clean-floor holes and opening tunnels;room_colorstyles only borders and names; custom colour and legacy tokens (#56, #581): CONFIG-COMPATIBILITY. - Device state, light membership, action —
resolvedDeviceStateEntities,resolvedLightSourcesandsrc/device-toggle.tsare the only resolvers (#94, #251, #318, #381): DEVICE-PRESENTATION. - Zero-thickness walls, nested rooms —
resolveZeroWalls()feeds every renderer, Glow and sun (#306): WALL-THICKNESS. Kiosk, navigation — kiosk is a card flag, not a mode;LS_NAVstores only the space (#93, #210): UX-MODES.
Camera and mode transitions (#101, #82)
Two one-token/one-RAF controllers own every animated camera change and leave
no CSS timers or WAAPI animations behind. ModeTransitionController
(src/mode-transition.ts) is the only timeline for entering, leaving and
switching editors: it interpolates measured chrome height, stage geometry,
world-space camera centre, logarithmic pixels-per-unit, stage/paper colours,
day/night brightness and presentation weights together, deriving each viewBox
from the current stage aspect; the stage is inert meanwhile, header tabs stay
live for a retarget. src/viewport-transition.ts animates discrete zoom in a
settled mode with the same easing but only {zoom, viewBox} (no chrome,
background, layer opacity or CSS transform) and lives in the core View bundle.
The component stays the sole camera writer; ownership boundaries and timings:
CANVAS › View/editor camera handoff and §5.
Settings tiers (owner's principle, 2026-07-26)
Four levels: global (config.settings) → space (space.settings) → room
(room.settings) → device (marker.*). Duplicated options are deliberate:
the more specific tier wins and "unset" always means "inherit". Resolution
lives in pure helpers (spaceDisplayOf, roomFillModeOf, roomGlowOf,
roomTempRangeOf, sourceValue, resolveToggleIntent), never inline in render;
each tier keeps its own dialog (General settings, space, room, marker).
Schema as the source of truth (#33)
The Voluptuous schema in custom_components/houseplan/validation.py is the
single owner of the persisted config/layout shape. The generated manifest
scripts/config-schema.json, the enum parity test with its self-checking
allow-list, the decision registry scripts/config-field-registry.mjs and the
lifecycle fixtures keep every other world honest against it:
CONFIG-COMPATIBILITY › Schema manifest and parity.
No hidden discovery knobs (#44)
Every stored key that shapes device discovery is a visible, supported setting
or does not exist. settings.group_lights and settings.exclude_integrations
live in the device catalog's Discovery-filters section and are resolved only by
effectiveExcludedIntegrations(): FILTERING › Seeding.
Contextual Zigbee topology (#54, #457, #464)
The initial View graph holds only the fail-closed settings reader and a dynamic
overlay bridge; the overlay chunk loads only for a saved
settings.zigbee_topology.enabled === true, a real HA admin, full-card View and
a non-kiosk surface. General Settings loads provider transport only when an
enabled setting needs status or the admin presses a provider action.
zigbee-topology.ts normalises ZHA and Zigbee2MQTT into unordered edge pairs
with directional observations, maps IEEE nodes through exact registry ownership
and resolves only edges incident to the hovered marker, never inventing
neighbours. zigbee-topology-runtime.ts keeps a per-connection memory cache with
in-flight dedupe: ZHA reads zha/devices without a scan; Z2M checks the retained
bridge-info topic, sends one correlated raw routes:false request through
mqtt.publish, rejects retained/foreign/late replies and always unsubscribes.
The pointer-transparent overlay is a child of the .devlayer camera, projected
once with the markers by live-viewport.ts; only the source and drawable
neighbour markers are promoted above it, through transient attributes the
overlay owns and clears. Unknown-LQI links are a 4 px #2e2e2e casing under the
2 px grey core. Persistence, privacy: CONFIG-COMPATIBILITY.
Live viewport: a transform per frame, a viewBox on a budget (#531, #579)
Rewriting the SVG viewBox re-rasterises the whole scene, so per-frame writes
made panning crawl. paintLiveViewport keeps an anchor (the viewBox in the DOM
and when it was written) and moves scene nodes each frame with the HTML layers'
projective transform (liveLayerProjection, transform-origin: 0 0);
needsViewBoxRefresh rewrites the anchor only after LIVE_VIEWBOX_REFRESH_MS
(100 ms) or a LIVE_VIEWBOX_REFRESH_SHIFT (15 %) shift/scale change — module
constants, not settings. Scene nodes project from the anchor, HTML layers from
the last settled Lit frame; both land on the current view, keeping #451's
one-CSS-pixel marker contract on every frame. .stage stays the outer clip, but
a transformed scene SVG gets inline overflow: visible so rasterised content
covers the incoming edge, even with the pointer held still (#544); HTML layers
never do. The exposure is bounded by an inline clip-path: inset(-25%)
(LIVE_SCENE_EXPOSURE_CLIP) set and removed with it — beyond the 15 % refresh
threshold, but never the whole plan: unbounded, a promoted scene grew with
zoom² and at 800 % × DPR 2 exhausted GPU memory (white frames, #689). A scene
marked data-hp-live-overflow="clip" — the filtered day-cycle outline — is
projected but never exposed. From the first live paint to the terminal commit a scene SVG stays in
one compositor lifecycle: a refresh or Lit frame may replace its anchor but never
demote/re-promote it — HA Companion WebView shows that as a blank frame (#579).
The day-cycle paper outline joins these roots before the first camera move; the
static card uses its stage-sized form from the first frame (#582, Key decision 7).
Unchanged values are never rewritten, so idle frames stay byte-identical;
commitHouseplanViewport removes the transforms and forces the final viewBox.
English and Russian ship whole (#400)
en and ru are synchronous dictionaries in the initial chunk; de and fr
load lazily (src/i18n/registry.ts), editor-only strings included. At the
decision the 38 settings-help entries of #86 cost 2 654 B gzip (0.9 % of the
initial-View budget); splitting would need a second dictionary half, a runtime
merge, an extra request and a second source for the en.json key type (#391).
Budget planning assumes whole dictionaries; revisit only if editor text grows by
tens of kilobytes.
Backend quality gates (#42)
tests_backend/requirements.txt is the single source of backend CI dependencies;
ruff, strict mypy, the sys.modules guard, the coverage baseline and the
geometry_parity job: TESTING › Backend quality gates.
const.ERROR_CODES / ERROR_CODE_FAMILIES are THE stable error contract: every
emitted code is registered and localized (scanner test); invalid_passage_fields
and invalid_partition_opening_jamb_margin carry structured JSON details; the
frontend renders unknown codes localized, code first, raw messages to the console.
Summary panel boundary (#437)
settings.summary_panel is the only shared persistence of this read-only overlay;
prepare_ordinary_summary_candidate() is the common backend boundary of
config/set and plan/optimize (full import bypasses it), and
config/get.summary_panel_api, never cached config, grants editing. Persistence
and local keys: CONFIG-COMPATIBILITY › Summary panel namespace.
summary-panel.ts owns defaults, stable ids, fit predicates and local-key
encoding; summary-panel-picker.ts a non-DOM entity_id + friendly_name index
reused across state changes; summary-panel-runtime-loaded.ts the lazy View
controller — a screen-space, non-SVG sibling of the camera layer outside content
bounds, with one minute-aligned timer and a lifecycle generation binding dialog,
draft, picker and async work to one route/user/permission/kiosk identity.
summary-runtime-loader.ts shares code, never state, and attaches a warm runtime
before the first render (#506).
summary-panel-editor.ts loads on the settings button; its revision-checked
shared write precedes the local show choice. #505 styles and the 190 ms phases
stay lazy and never move the camera; editors and the space card never load the
panel. Device totals need an authoritative registry snapshot and dedupe parent
device ids before visual filters; clean area unions canonical room floors per
space with that space's cell_cm.
Private support boundary (#43)
Help & feedback is rendered by the lazy editor runtime even in View; form state
is component memory only. Report controls appear only when
config/get.support_api equals SUPPORT_API_VERSION (1); release versions are
diagnostic. houseplan/support/preview (may_write, bounded capability enums,
dialog-scoped id) loads one coherent config/layout pair under the shared write
lock and passes disposable validated copies to support_package.py, a strict
projection boundary: a new allowlisted object with package-local pseudonyms and
canonical sorted JSON, never raw storage redacted afterwards. Bytes, SHA-256 and
expiry stay in HouseplanData memory, bound to user and draft for ten minutes;
preview, download and submit use those bytes; preview/discard (idempotent) or a
confirmed submit consumes the token. houseplan/support/submit re-validates text,
resolves only an owned live token and calls support_transport.py: one
compile-time HTTPS URL, no redirects, bounded timeouts and response size, stable
local failure codes that never reflect the response. The relay
(scripts/support-relay/) deploys separately and is excluded from the HACS
artifact. Content and retention: SUPPORT-PRIVACY.
Mutation tooling boundaries (#558)
scripts/mutation-gate.mjs stays the stable CLI but is only an orchestrator and
compatibility export surface over mutation-registry.mjs (declarations),
mutation-selection.mjs (diff/guard-input selection), mutation-evidence.mjs
(witness fingerprints, caught ledger) and mutation-execution.mjs (worktree
runs); dependencies never point back to the CLI. Guard-input caching is
invocation-scoped (one resolver, one tracked-file snapshot); persisted success
exists only in the explicit caught-witness ledger. Usage: TESTING.