mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
1097 lines
64 KiB
Markdown
1097 lines
64 KiB
Markdown
# Config compatibility registry
|
||
|
||
House Plan accepts some fields that are no longer written by the current UI.
|
||
They are not all equivalent: some preserve an old visual exactly, some are
|
||
losslessly migrated on an explicit edit, some are immediately discarded by
|
||
backend validation, and a small number still need a product decision.
|
||
|
||
The machine-readable source of truth is
|
||
`scripts/config-field-registry.mjs`. Every entry records:
|
||
|
||
- persisted path and type;
|
||
- inheritance level and default;
|
||
- whether the current UI exposes it;
|
||
- the runtime consumer (or the fact that no supported consumer was found);
|
||
- migration behaviour;
|
||
- the read-compatibility decision.
|
||
|
||
The registry started from the compatibility and internal-field debt identified
|
||
by HP-DATA-01 and has grown with every model change since. The schema itself is
|
||
machine-checked by `test/config-schema-parity.test.mjs` (#33): backend and
|
||
frontend enums agree except through an explicit allow-list, and every entry of
|
||
this registry resolves to a path of the generated schema manifest or to an
|
||
explicit passport. A field missing here is therefore a documentation gap, not
|
||
an unknown schema.
|
||
|
||
## Schema manifest and parity (#33)
|
||
|
||
The Voluptuous schema in `custom_components/houseplan/validation.py` is the only
|
||
owner of the persisted config/layout shape. `scripts/dump-config-schema.py`
|
||
walks it into the deterministic `scripts/config-schema.json`;
|
||
`tests_backend/test_config_schema_manifest.py` regenerates the manifest and fails
|
||
on uncommitted drift. `test/config-schema-parity.test.mjs` compares manifest
|
||
enums with the exported frontend lists (`DISPLAY_MODES`, `TAP_ACTIONS`,
|
||
`SPACE_FILL_MODES`/`ROOM_FILL_MODES`, `OPENING_TYPES`, `VACUUM_TRAIL_MODES`,
|
||
`ZERO_WALL_STYLES`, `BG_MODES`, `SUN_RAY_ORIGINS`). Every divergence is blessed
|
||
in `scripts/schema-compat-allowlist.mjs` with a reason and an owning issue; an
|
||
entry that no longer matches a real divergence fails the test, so the list
|
||
cannot rot. Registry entries resolve to a manifest path or carry an explicit
|
||
`schema: 'allow-extra' | 'lovelace-card'` passport; implemented mechanisms cite
|
||
their code point in `enforcedBy`. `test/fixtures/config-lifecycle/`
|
||
(`oldest-supported`, `current`, `future-fields`) pins the load contract: each
|
||
passes the schema losslessly and future fields round-trip exactly.
|
||
|
||
## Offline inventory
|
||
|
||
Exported JSON can be inspected without uploading it or changing it:
|
||
|
||
```bash
|
||
npm run audit:config -- path/to/houseplan-config.json
|
||
npm run audit:config -- --json path/to/houseplan-config.json > findings.json
|
||
```
|
||
|
||
The auditor accepts either the config object itself or an export wrapper with a
|
||
top-level `config` object. It reports only fields known to the registry, shows at
|
||
most three example paths in human-readable mode and always exits read-only. With
|
||
no file it prints the current registry.
|
||
|
||
## Status meanings
|
||
|
||
| Status | Meaning |
|
||
|---|---|
|
||
| `decision-required` | Preserve the field until its supported UI/runtime fate is explicitly decided |
|
||
| `deprecated-read` | Current writes use another representation; reads preserve old data/visuals |
|
||
| `migrate-on-write` | A lossless current representation is materialised during the documented write path |
|
||
| `migrate-on-settings-save` | Removed from current settings semantics and dropped only when those settings are explicitly saved |
|
||
| `drop-on-validation` | A stale client may submit it, but backend validation removes it safely |
|
||
|
||
Unknown future fields remain outside this report and continue to follow the
|
||
backend's forward-compatibility policy. Absence from the report is therefore
|
||
not permission to delete a field.
|
||
|
||
## Revision-less config writers (#340)
|
||
|
||
The current frontend has sent `expected_rev` with every `config/set` since
|
||
v1.4.4. A client without that field may initialise an empty configuration store
|
||
at revision zero, where no newer work exists to overwrite. Once a document has
|
||
been saved, omission is a `conflict` and leaves the config, revision, backup and
|
||
events unchanged — including when the submitted document would otherwise be a
|
||
semantic no-op.
|
||
|
||
There is no version-based compatibility window for writes over a non-zero
|
||
revision. Without a server-issued token, an old client and a stale concurrent
|
||
writer produce the same request; accepting either would reopen last-writer-wins
|
||
data loss. This changes only the WebSocket write contract. Stored config,
|
||
model/store versions, exports and read compatibility are unchanged.
|
||
The same rule applies to `layout/set` (#356).
|
||
|
||
## Crash-resumable config/layout pairs (#491)
|
||
|
||
`optimize_pending` is the durable authority for every paired config/layout
|
||
write: Optimize, Optimize Undo, full import and space deletion. Current intents
|
||
carry canonical target documents, their exact target revisions and exact final
|
||
layout-store metadata. The intent is written before either visible half and is
|
||
removed only by the final layout write after config and layout both match it.
|
||
On a persistent target failure, an explicit rollback intent restores the exact
|
||
before-pair, including its revisions and unknown metadata.
|
||
|
||
Before every runtime config/layout writer reads revisions or validates its own
|
||
candidate, the common write fence resolves a valid pending intent and reloads
|
||
both stores. Old CAS revisions conflict normally; point layout writes then
|
||
change only their named entry on the recovered layout. If recovery cannot yet
|
||
finish, the new write is rejected without deleting the intent or backup.
|
||
Startup invokes the same resolver before storage migrations. Legacy valid
|
||
intents without `final_metadata` retain their previous compatibility fallback;
|
||
there are no new persisted fields, store/model version bumps, export fields or
|
||
read-side repair semantics.
|
||
|
||
## Room hover information preference (#426)
|
||
|
||
`settings.show_room_tooltip` is an optional global boolean. Absence and any
|
||
invalid legacy/future value read as the historical enabled default; only exact
|
||
`false` hides the floating room information window. Saving the enabled value
|
||
removes the key. The field does not change room highlighting or device
|
||
tooltips, and does not require a model/store version migration.
|
||
|
||
An older frontend ignores the field and temporarily shows the room window. An
|
||
older backend preserves it through the existing unknown-settings policy, so a
|
||
new frontend restores the disabled behavior after upgrade. Full backup/import
|
||
preserves the setting and the privacy-safe support projection includes only a
|
||
validated boolean.
|
||
|
||
## 2.5D View setting (#649)
|
||
|
||
`settings.volumetric_view` is an optional global boolean for the whole
|
||
installation — every space, user, device and kiosk. Only exact `true` turns the
|
||
View into 2.5D; absence, `false` or anything else read as Flat. Saving `false`
|
||
removes the key. The backend accepts only a boolean
|
||
(`vol.Optional("volumetric_view"): bool`), and the privacy-safe support
|
||
projection copies only a validated boolean. There is no migration: the former
|
||
per-device, per-space alpha choice (`houseplan_card_view_v1` in the browser)
|
||
was never part of the config and is no longer read.
|
||
|
||
An older frontend ignores the field and shows Flat. An older backend preserves
|
||
it through the unknown-settings policy. Full backup/import carries `settings`
|
||
whole, so the value survives.
|
||
|
||
## Stairs (#663)
|
||
|
||
`spaces[].stairs[]` is an optional bounded (250 records per space)
|
||
discriminated collection. `kind: "straight"` stores positive `length` and
|
||
`width` plus `direction: "forward" | "backward"`; `kind: "spiral"` stores a
|
||
positive `radius` plus `direction: "clockwise" | "counterclockwise"`. Both
|
||
variants carry stable `id`, continuous normalized centre `x/y`, scalar
|
||
`angle`, and an optional nullable `target_space_id`. Optional `color`,
|
||
`opacity`, `fill_color` and `fill_opacity` store the stair-owned screen style.
|
||
Their absence is the legacy form: renderers/dialogs resolve the current decor
|
||
default without rewriting the config, and the first successful Properties save
|
||
materialises all four fields. Changing the global decor default later does not
|
||
recolour a stair that already owns the fields.
|
||
|
||
Stair transforms follow furniture's continuous contract. Config writes apply
|
||
only the nine-decimal scalar cleanup to position, size and angle; they do not
|
||
use near-lattice snapping, and Optimize does not move them. Full backup,
|
||
support data and diagnostics retain the bounded records. Full import remaps a
|
||
known target space id and clears a target that was not imported; single-space
|
||
transfer cannot invent a link to an external floor. Deleting a target space
|
||
clears incoming links without deleting the source stair.
|
||
|
||
The collection is additive and requires no model/store version migration. A
|
||
current frontend renders and edits it; a legacy frontend ignores the objects.
|
||
The current backend preserves valid records through unrelated writes, while a
|
||
backend predating the field must not be used to edit a newer configuration.
|
||
|
||
## Sun-ray window face (#577)
|
||
|
||
`settings.sun_ray_origin` is an optional global enum: `inner` or `outer`.
|
||
Absence and an unknown read-side value resolve to `inner`, preserving the exact
|
||
pre-#577 source geometry without rewriting an existing configuration. Saving
|
||
General settings materialises a valid value; new installations start with
|
||
`inner`. Backend writes reject every other value. There is deliberately no
|
||
per-space override and no model/store version bump.
|
||
|
||
Full backup/import preserves the enum. The privacy-safe support projection
|
||
includes only a validated `inner`/`outer` scalar. Older frontends ignore a
|
||
preserved value and temporarily render the historical inner mode; a current
|
||
frontend restores the selected mode after upgrade.
|
||
|
||
## Summary panel namespace (#437)
|
||
|
||
`settings.summary_panel` is an optional shared versioned object. Absence means
|
||
that the frontend derives one localized default block without writing the
|
||
server; an explicit `blocks: []` remains intentionally empty. Version 1 bounds
|
||
the panel to 10 blocks and 20 values per block, uses stable block/value ids,
|
||
and accepts only entity state sources or the three built-in read-only values.
|
||
New entity and space references are checked identically at `config/set` and
|
||
`plan/optimize`; an unchanged old broken reference remains editable and is
|
||
shown as a warning.
|
||
|
||
An old ordinary client that omits an existing namespace cannot erase it. A
|
||
bounded future version is preserved losslessly but stays inert in an older
|
||
frontend. Full backup/restore remains authoritative. Per-user/per-card show and
|
||
View scale preferences, HA states, registry snapshots, computed totals and
|
||
clock values are browser runtime data and never enter server config, exports or
|
||
support packages. `config/get.summary_panel_api === 1` is a runtime capability,
|
||
not persisted user configuration and not a store/model version bump.
|
||
|
||
Browser-local summary preferences are keyed by HA user, route, host and logical
|
||
card slot; the resolved summary-local key is the scale authority and the legacy
|
||
kiosk key only seeds its first load. A native Masonry slot is the top-level
|
||
card's index in `hui-masonry-view.cards` (dashboard config order, stable while
|
||
HA moves cards between responsive columns); a nested stack/conditional appends
|
||
only its composed descendant path. The `masonry-v2` marker separates these keys
|
||
from the older ambiguous DOM-path keys (#561), so earlier per-card Masonry
|
||
choices may need to be set again. While the canonical array or matching ancestor
|
||
is not available yet, preferences stay session-only and no guessed persistent
|
||
key is read or written (`summary-panel-identity.ts`).
|
||
|
||
The writer authority is explicit:
|
||
|
||
| Writer | Summary-panel authority |
|
||
|---|---|
|
||
| `config/set`, Optimize | Candidate; omission preserves the exact stored namespace, while explicit `blocks: []` remains empty |
|
||
| Full import | Archive is authoritative, including an absent namespace |
|
||
| Space/plan-only import | Exact global settings of the target installation |
|
||
| Optimize/Import Undo | Exact configuration from the corresponding backup |
|
||
| Create/copy/delete space | Ordinary candidate built over current global settings; no scope is silently remapped or removed |
|
||
|
||
Every row also preserves unrelated known and future settings namespaces. Only
|
||
the two ordinary WebSocket writers apply the caller's current entity-read
|
||
permissions to newly selected version-1 references; an authoritative restore
|
||
may legitimately carry a broken transfer reference for later replacement.
|
||
|
||
## Contextual Zigbee topology (#54)
|
||
|
||
`settings.zigbee_topology` is an optional global object. Only exact
|
||
`enabled: true` activates the admin-only full-card View layer; absence,
|
||
malformed values and older configs are off. `z2m_base_topics` is a bounded list
|
||
of exact MQTT base topics: empty entries, wildcards, duplicates and control
|
||
characters are dropped. Disabling the feature retains valid topics; resetting
|
||
General settings removes the object.
|
||
|
||
Provider snapshots, IEEE addresses, links, errors and timestamps are runtime
|
||
memory only. They are not config fields, do not enter backup/export or support
|
||
diagnostics, and disappear with the HA connection/page. Older frontends ignore
|
||
the settings object; the backend's unknown-settings policy preserves it. No
|
||
model or store version migration is required.
|
||
|
||
## Vacuum map routes (#162)
|
||
|
||
`marker.vacuum.map_routes` is an optional array of
|
||
`{ id, source, map_id, space, calibration? }`, at most 32 per marker, with `id`
|
||
and the pair `(source, map_id)` unique inside the marker. It requires no model
|
||
or store version bump: absence reads as the historical behaviour.
|
||
|
||
Reading is lossless in both directions. When `map_routes` is absent or `null`,
|
||
every valid `calibration[map_id]` is an effective route into the dock's space,
|
||
so nothing is migrated on load and an ordinary save of other marker fields
|
||
leaves the vacuum block untouched. Any array is explicit authority: an empty
|
||
`map_routes: []` means that no route is configured and suppresses every
|
||
retained legacy matrix. The first explicit routing edit converts the whole
|
||
legacy dictionary at once and needs an exact source to do it; partial
|
||
conversion is refused, and `calibration` is removed only after the config write
|
||
succeeds.
|
||
|
||
Semantic validation is change-aware: an untouched legacy or future-shaped block
|
||
never blocks an unrelated save, while an edited one must be valid or the write
|
||
is refused atomically with `invalid_vacuum_map_route`. A full export/import
|
||
round-trips routes verbatim. A single-space export drops routes that point at
|
||
other spaces and counts them in `dropped_marker_links`, because their target
|
||
would not exist in the imported document. If that removes the last explicit
|
||
route, the export keeps `map_routes: []` rather than writing `null`, so a later
|
||
import cannot make legacy calibration authoritative again.
|
||
|
||
Downgrade: an older frontend ignores `map_routes` and falls back to the legacy
|
||
`calibration` dictionary, which the conversion removed — such a plan shows the
|
||
robot only where a matrix still exists, and re-upgrading restores routing
|
||
without data loss because the routes themselves are preserved by the
|
||
unknown-fields policy. Server trails written by a newer backend carry
|
||
`route_id`/`source`; an older frontend ignores both and matches runs by
|
||
`map_id` exactly as before.
|
||
|
||
## Stable wall identity — model v8 (#282)
|
||
|
||
Model v8 adds `space.wall_segments[]`, ordered `rooms[].wall_ids[]`, IDs on
|
||
`room_drafts[].segments[]`, and tagged wall hosts on room-wall openings. These
|
||
fields are authoritative for wall identity, thickness and opening ownership.
|
||
The historical `rooms[].poly`, `space.walls[]` and opening coordinates remain
|
||
materialised compatibility projections.
|
||
|
||
Reading a v7 store is side-effect free. A v8 wall catalog is materialised only
|
||
before a structural write, through **Optimize plans**, or when a v7 candidate is
|
||
imported into a v8 target. A v7-to-v7 import remains v7. Full and space-only
|
||
export/import preserve IDs; copy/merge deterministically remaps IDs together
|
||
with all references. There is no automatic downgrade from v8 to v7.
|
||
|
||
An older frontend may read the compatibility projection of a v8 config. Its
|
||
byte-equivalent legacy round-trip is accepted and the backend restores the v8
|
||
fields. If the legacy-visible structural projection changed, the backend
|
||
rejects the write instead of allowing thickness, draft identity or an opening
|
||
host to detach. A migration or transition conflict is fail-closed: the previous
|
||
config and revision remain unchanged.
|
||
|
||
The stale-client comparison is deliberately limited to contour-coupled legacy
|
||
geometry: rooms, compatibility `walls` and `open_spans`. Self-identifying
|
||
`room_drafts`, `partitions`, `wall_columns` and explicitly hosted `openings` may
|
||
change without rewriting the room-wall catalog, but still pass the complete v8
|
||
schema. Write-time sanitation and Undo preserve every surviving draft-segment
|
||
ID. If a physical `config/set` is rejected, the frontend restores the earliest
|
||
snapshot in that pending batch before attempting a best-effort authoritative
|
||
reload; rejected draft geometry cannot be promoted by a later gesture (#314).
|
||
|
||
## Canonical zero-thickness walls — model v9 (#306)
|
||
|
||
Model v9 removed the separate virtual-boundary representation. A contour atom,
|
||
independent partition or then-current draft segment with `cm:0` became the only form of
|
||
a wall axis without masonry. `space.zero_wall_style` is optional and accepts
|
||
`dashed | solid`; missing or unknown values read as `dashed`. Dashed zero walls
|
||
transmit Glow and sunlight, while solid zero walls are exact line barriers.
|
||
|
||
`space.open_spans[]` and `rooms[].open_to[]` remain compatibility reads for v8
|
||
documents only. Explicit valid spans win; `open_to` expands to the full proven
|
||
shared boundary only when spans are absent. The first structural write,
|
||
confirmed **Optimize plans**, or import into a current target atomizes that
|
||
geometry into stable `wall_segments[].cm=0`, preserves surviving IDs, and then
|
||
removes both legacy fields in one transaction. An opening never blocks that
|
||
migration (#316): the atom carrying an existing opening keeps its positive
|
||
thickness (the zero run continues on both sides), an ambiguous carrier is
|
||
resolved deterministically (current host, then distance, thicker cm, smaller
|
||
id), and an opening with no usable carrier at all persists **unhosted** — a
|
||
valid degraded v9 state that is inert in the physics (no body, tunnel or cut),
|
||
renders by its own `x/y`, survives later writes untouched and may be re-placed
|
||
in the editor. Post-migration structural writes keep the fail-closed refusal
|
||
for an opening that LOST its carrier. Presentation,
|
||
marker and ordinary space-settings writes do not trigger the migration.
|
||
|
||
There is deliberately no provenance flag. Existing v8 `cm:0` and atoms derived
|
||
from legacy virtual spans are identical. Consequently some old plans may change
|
||
line style or light transmission after upgrade; this is the accepted migration
|
||
trade-off. Canonical v9 export/import never recreates `open_spans/open_to`, and
|
||
downgrade after the first v9 structural write is unsupported.
|
||
|
||
## Ordinary wall chains — model v10 (#478)
|
||
|
||
Model v10 removes persisted `space.room_drafts`. Each accepted segment of the
|
||
Walls tool is written immediately as one ordinary `space.partitions[]` record;
|
||
the active chain id, ordered partition ids, path and per-edge thickness list are
|
||
session-only. Switching tool/editor/space, leaving the card or reloading only
|
||
ends that session. Accepted segments remain independently selectable walls and
|
||
are not resumed as a chain.
|
||
|
||
The v9→v10 migration converts every valid draft edge one-for-one into a
|
||
partition. A unique existing segment id is retained; a missing, empty or
|
||
colliding id is replaced deterministically from the space, draft and edge
|
||
index, with a stable numeric suffix when needed. Draft order and each edge's
|
||
`cm` are preserved, existing partitions are not merged, and the carrier record
|
||
is removed atomically. Malformed geometry or invalid thickness fails closed.
|
||
|
||
A current v10 document that still carries `room_drafts` is **healed, not
|
||
refused** (#529). The key is removed the same way the first migration removes
|
||
it: an empty carrier silently, drafts converted one for one into partitions.
|
||
Refusing it locked the plan completely — the card runs the same migration, so
|
||
structural edits were rejected before the request left the browser, and export
|
||
calls it too, so no backup could be taken to repair the file by hand.
|
||
|
||
A stale writer is identified where it can actually be identified — in
|
||
`validate_wall_model_transition`, which sees both the submission and the stored
|
||
plan: drafts appearing over a stored plan that does not have them are refused
|
||
with the outdated-client error, regardless of the `model_version` the client
|
||
submitted (a stale card echoes back the number it was given). The schema
|
||
invariant still refuses a non-empty carrier as the last line.
|
||
|
||
Import preview, full/space backup restore and Optimize materialize this legacy
|
||
shape before current processing, report converted draft/segment counts, and
|
||
never export `room_drafts` from a current configuration. A room accepted from a
|
||
closed active chain consumes only exactly coincident chain partitions in the
|
||
same room transaction. Cancel/Keep-as-walls leaves all accepted partitions
|
||
unchanged. Downgrade after the first v10 structural write cannot restore the
|
||
old resumable-chain behaviour.
|
||
|
||
## Canonical geometry on write (#224, #291)
|
||
|
||
Config and layout schemas canonicalize only named persisted numbers. Lattice
|
||
coordinate/size components use the exact nearest `k / 240` double only when
|
||
their deviation is below `1e-4` of one grid step; authored values farther from
|
||
a node keep the nine-decimal scalar storage contract. Backdrop transforms,
|
||
angles, lengths and normalized ratios always use that scalar contract. Common
|
||
storage helpers repeat the same idempotent operation for internal writers,
|
||
while the frontend adopts the exact candidate it sends. This removes ULP noise
|
||
without changing the schema, JSON number type, model/store version or visible
|
||
placement.
|
||
|
||
Decor uses an explicit box-geometry catalog shared by the frontend contract and
|
||
mirrored by the integration: `rect`, `ellipse`, `furniture` and `image`.
|
||
Their `x/y/w/h` fields follow the lattice rule above and `angle` follows the
|
||
scalar rule; image asset, opacity, mirror flags and unknown fields are not
|
||
geometry and remain unchanged.
|
||
|
||
The operation is lossless at the product scale and intentionally narrow.
|
||
`view_box`, `cell_cm`, `plan_aspect`, physical centimetre values,
|
||
presentation settings, colours, opacity/brightness/temperature, vacuum
|
||
calibration and unknown/future numeric fields retain their exact input values.
|
||
No recursive “round every number” migration is allowed.
|
||
|
||
Existing stores remain byte-for-byte untouched on read. Their geometry becomes
|
||
canonical on the next config/layout write; Optimize Plans is the immediate bulk
|
||
path. Optimize/Import/repair Undo restores the previous semantic geometry and
|
||
unknown fields in canonical representation, not the invisible noisy IEEE-754
|
||
tail. A repeated canonical Save with the current revision is a no-op and does
|
||
not invalidate the one-deep maintenance backup.
|
||
|
||
Optimize itself removes measured near-node tails before visible grid alignment,
|
||
then returns and compares the same storage-canonical config/layout pair as the
|
||
writers. Consequently the normal commit, durable pending recovery, update-event
|
||
reload and a cold read all converge on one JSON value set; feeding any of them
|
||
back to Optimize is a no-op (#248). Its separate lattice report counts cleaned
|
||
coordinate components and untouched authored off-grid values without mixing
|
||
their sub-pixel maximum with visible grid movement.
|
||
|
||
Wall-thickness compatibility keys use the same boundary without depending on
|
||
which side of it produced the key (#258). A `wallKey` endpoint already within
|
||
`max(pitch · 10⁻⁶, 10⁻⁹)` of a grid node is treated as that exact node before
|
||
midpoint quantisation, so `83/240` and persisted `0.345833333` identify one
|
||
stretch. Existing entries whose old midpoint key landed one grid step away are
|
||
read immediately by strict equality of their lossless `a/b` endpoint pair;
|
||
read does not rewrite config and does not broaden parent/child matching.
|
||
Explicit Optimize rewrites the compatibility key, retains `cm`, endpoints and
|
||
unknown siblings, and its next in-memory or backend storage round-trip is a
|
||
no-op. Legacy key-only records continue through the previous midpoint fallback.
|
||
|
||
Explicit Optimize also has one deliberately lossy wall-thickness repair. A
|
||
positive interval shorter than half a grid step may inherit its two equal
|
||
positive neighbours only when all three belong to one original straight room
|
||
edge and exact owners are unambiguous. One endpoint may be a room T-node: only
|
||
the interval `cm` changes, so that node and its perpendicular incident geometry
|
||
remain intact. An opening/open-span endpoint, two room topology endpoints,
|
||
unequal neighbours, a half-step-or-longer interval or conflicting owners always
|
||
block the repair. Normal read, render, Save and editor paths remain lossless;
|
||
only confirmed Optimize applies it, with the ordinary preview and server Undo
|
||
(#198, #273).
|
||
|
||
Explicit Optimize may also straighten a stored wall whose slope is non-zero
|
||
but no more than `0.25°` from an axis (#290). This is a confirmed lossy repair,
|
||
not a read migration or schema change. All coincident room-owner endpoints move
|
||
together, wall/opening identities are reprojected by the canonical pipeline,
|
||
and true diagonals survive byte-equivalent. Older clients continue reading the
|
||
result as ordinary polygon geometry; reverting code does not require a storage
|
||
migration.
|
||
|
||
## Open-passage opening type (#157)
|
||
|
||
`space.openings[].type` additionally accepts the literal `passage`. Its
|
||
canonical record contains only `id`, `type`, `x`, `y`, `angle`, `length` and
|
||
unknown extension siblings. The door-only keys `contact`, `lock`, `invert`,
|
||
`flip_h` and `flip_v` are inapplicable; their presence is invalid even when the
|
||
value is `null` or `false`.
|
||
|
||
New/changed records and every full/space import are validated fail-closed with
|
||
`invalid_passage_fields`. An already stored broken passage may survive an
|
||
unrelated write unchanged so legacy data cannot lock the whole plan; removing
|
||
bad keys is allowed, while adding or changing one is rejected. An explicit UI
|
||
save of a passage deletes all five known keys and preserves unknown siblings.
|
||
Stale binding values remain inert at runtime and create no entity subscription.
|
||
|
||
Older v1.64.x frontends do not know the literal. Downgrade is read-only
|
||
best-effort: do not edit an open passage with an old frontend, because its
|
||
fallback may show or rewrite it as a door. Before a permanent rollback, convert
|
||
saved passages deliberately in a current version; automatic conversion is not
|
||
performed because it would invent a leaf and binding semantics.
|
||
|
||
## Furniture mirror flags (#383)
|
||
|
||
`space.decor[]` furniture records may contain optional boolean `flip_h` and
|
||
`flip_v`. Their `w` and `h` remain strictly positive; absent flags mean the
|
||
historical unmirrored orientation, so no migration is required. Full, space
|
||
and plan-only transfers preserve both flags, while coordinate canonicalization
|
||
and Optimize leave them untouched.
|
||
|
||
The same field names already exist on door/window/gate opening records, where
|
||
they describe leaf direction. This is not a shared semantic field: openings
|
||
and furniture are validated, canonicalized and exported through separate
|
||
object schemas and allowlists. Older renderers ignore furniture flags and show
|
||
the original orientation; older plan-only exporters may omit them.
|
||
|
||
## Custom decor images and export v2 (#51)
|
||
|
||
`space.decor[]` accepts an additive `kind:'image'` variant with a lowercase
|
||
64-character SHA-256 `asset_id`, positive `x/y/w/h`, optional `angle`,
|
||
`flip_h`, `flip_v` and `opacity`. File bytes are not config fields and live in
|
||
`config/houseplan/assets`. New backend + old frontend is read-only for configs
|
||
that already contain this unknown kind: a current frontend exposes the tool
|
||
only after `houseplan/config/get` advertises `decor_assets_api:1`.
|
||
|
||
Portable export format v2 adds extension-neutral `decor_asset` manifest rows.
|
||
It records content hash and source availability but never embeds file bytes or
|
||
signed URLs. The importer continues to accept v1. A matching verified local
|
||
hash is reused; otherwise import requires confirmation and preserves the image
|
||
record as an editor repair placeholder instead of removing its geometry.
|
||
When the source blob and metadata are already absent, the canonical row has
|
||
`exists_at_export:false` and may have `mime:null`; that exact missing state is
|
||
importable in full, single-space and plan-only documents. Missing MIME is not a
|
||
general validation bypass: the availability flag must be a literal boolean,
|
||
identity/hash remain exact, and every supplied non-null MIME must be supported.
|
||
Before a permanent downgrade, remove all image objects with a current card and
|
||
then explicitly delete their now-unused files from the palette.
|
||
|
||
The #432 backend hardening does not change that schema, URL shape, export format
|
||
or `decor_assets_api:1` capability. A read-only user still resolves images used
|
||
by the saved config; only arbitrary unreferenced ids are now returned as
|
||
`missing`. Writers keep the full catalog contract. Authenticated and signed
|
||
exact content URLs remain valid, while integrity results are shared in a bounded
|
||
memory-only cache. Old and new cards therefore remain rolling-compatible with
|
||
the hardened integration; the cache is discarded on restart and needs no data
|
||
migration or downgrade step.
|
||
|
||
#434 keeps the same capability version and wire formats while tightening the
|
||
rolling boundary. Embedded `houseplan-space-card` instances call
|
||
`houseplan/assets/resolve` only after a fresh `config/get` returns exact
|
||
`decor_assets_api:1`; cached config from localStorage starts unverified, and a
|
||
later missing or malformed capability revokes a previously learned value even
|
||
when config content is identical. Positive and missing resolve results are
|
||
cached only for the same connection, config revision and id set. No persisted
|
||
field, schema migration, export-version change or downgrade action is added.
|
||
|
||
Decor quota now follows physical allow-listed hash blobs rather than trusted
|
||
catalog metadata. An exact re-upload may restore a missing or broken sidecar at
|
||
full quota because it adds no blob; the response uses `reused:false` to state
|
||
that the catalog entry was created by this request. Older cards can continue to
|
||
list and resolve valid rows and ignore this response distinction.
|
||
|
||
## Independent-wall opening host (#132)
|
||
|
||
`space.openings[].host` is an optional discriminated object
|
||
`{kind:'partition', id:string, t:number}`. Its absence preserves the historical
|
||
room-wall association. When present, the referenced partition in the same
|
||
space and normalized `t` are authoritative; the legacy `x/y/angle` siblings
|
||
remain a materialized compatibility projection for older readers. Full export,
|
||
plan-only export, merge and optimization preserve the host object.
|
||
|
||
Current writes validate the reference, fit and non-overlap. They also reject a
|
||
stale writer that keeps an existing opening but silently drops its host; this
|
||
prevents a downgrade from converting it into a nearby room-wall opening. Old
|
||
frontends may display only the materialized projection, so opening or editing a
|
||
hosted opening with an old bundle is unsupported. A missing/invalid host is not
|
||
re-associated automatically: current renderers fail dark and Plan offers an
|
||
explicit rebind.
|
||
|
||
The sole host-removal exception is the explicit Optimize reconciliation from
|
||
#276/#280. The server does not trust a client counter: it independently proves
|
||
that the old partition was removed, its complete segment is either one solid
|
||
outer boundary owned by exactly one room or one solid shared boundary owned by
|
||
exactly two rooms, the replacement wall envelope is not narrower, the
|
||
materialized centre/angle and every unrelated opening field are unchanged, and
|
||
no new slot overlaps. This capability is enabled only by
|
||
`houseplan/plan/optimize`; ordinary config writes and crafted candidates keep
|
||
the fail-closed `invalid_partition_opening_host` result.
|
||
|
||
New hosted openings and direct changes to `host.id`, `host.t`, `length`, host
|
||
span or host thickness reserve a jamb at both endpoints equal to half the
|
||
actual partition thickness. This is semantic delta validation, not a schema or
|
||
migration: an unchanged legacy near-end opening, an unrelated edit and a rigid
|
||
translation of its host remain valid and are never silently clamped. Full
|
||
backup restore intentionally uses the structural zero-margin fit boundary even
|
||
without a trusted previous config, so older backups remain restorable; the next
|
||
direct geometry edit must satisfy the current jamb rule.
|
||
|
||
## Four-phase background default and transfer (#146)
|
||
|
||
The schema remains `settings.bg_mode: static | daynight` globally and per
|
||
space; absence is still accepted for older files and the runtime's final
|
||
fallback remains `static`. New semantics are materialized instead of changing
|
||
that fallback:
|
||
|
||
- new integration config uses explicit global `daynight`;
|
||
- manual and Floors/Areas space creation writes explicit per-space `daynight`;
|
||
- storage minor v1.2 migrates a missing or invalid legacy global token to
|
||
`static` once, while preserving valid global/per-space values, unknown
|
||
siblings, revisions, and the other stores;
|
||
- full export/import always carries an explicit global mode, with legacy
|
||
missing mode becoming `static` before preview/apply;
|
||
- a space export copies its effective mode into the exported space; a legacy
|
||
space import without a mode becomes `static` before merge, regardless of the
|
||
target installation's global setting.
|
||
|
||
Import preview and apply therefore operate on the same normalized candidate.
|
||
Explicit `static` and `daynight` survive same-instance and foreign transfer.
|
||
|
||
## Additive plan-only space transfer (#167)
|
||
|
||
`houseplan/export/create` accepts `plan_only: true` only for a one-space
|
||
export. The resulting version-1 envelope adds `transfer.plan_only: true`,
|
||
contains no markers and retains only canonical `rl_<room_id>` room-label
|
||
layout. Normal full/space exports never write `plan_only: false`, so their
|
||
existing document shape and lossless compatibility remain unchanged; an
|
||
absent field still means an ordinary export.
|
||
|
||
Plan-only data is a fail-closed allowlist projection of supported geometry,
|
||
presentation and content references. Known Area, temperature/humidity,
|
||
opening and decor bindings are removed and recognized live-text references are
|
||
frozen as `—`. Import rejects a true flag on a full export, non-boolean values,
|
||
or any document whose projected config, layout, placement or content owner no
|
||
longer satisfies that privacy contract. There is no persisted config/layout
|
||
migration: the new field exists only in the portable envelope.
|
||
|
||
## Legacy device tap action
|
||
|
||
The canonical marker token `tap_action: none` is an explicit saved no-op. It is
|
||
different from an absent, `null` or empty action: those values retain the
|
||
domain default (Toggle for a primary `light.*`, Device card otherwise). The
|
||
current frontend consumes short click/tap and keyboard activation before any
|
||
capability, UI or HA dispatch, while hold and context-menu paths are unchanged;
|
||
the current backend accepts the literal. Generic full/space transfer preserves
|
||
it without migration, while virtual duplication continues to omit tap action
|
||
with the other device-specific behaviour. On downgrade, an older frontend
|
||
safely projects the unknown token to Device card, but an older backend rejects
|
||
a subsequent config write containing it. Therefore rollback must keep backend
|
||
read/write acceptance until stored `none` values have been migrated to `info`.
|
||
|
||
The historical marker token `tap_action: cover` remains accepted indefinitely.
|
||
It is projected in the current UI as the universal **Toggle state** action and
|
||
keeps cover-first target priority at runtime. Merely opening and saving an
|
||
unrelated marker field preserves the literal `cover` token; once the user edits
|
||
the action selector, the current canonical `toggle` token is written. The UI
|
||
never creates new `cover` values. Unknown or unavailable cover capabilities
|
||
remain a safe no-op and are never replaced by a guessed service call.
|
||
An absent action on a primary `light.*` likewise stays absent on an untouched
|
||
Open → Save.
|
||
|
||
The universal `toggle` resolver uses the current HA registry as its capability
|
||
boundary. A disabled, orphaned or not-yet-verified device target is therefore
|
||
a visible/explained safe no-op; it is not silently retargeted to a sibling
|
||
entity and does not fall back to opening the info card. Entity bindings that
|
||
still have a live, enabled service target may continue to work while registry
|
||
metadata is refreshing. Persisted actions are preserved in both cases so a
|
||
temporarily unavailable binding recovers without a config rewrite.
|
||
|
||
## Independent Glow compatibility
|
||
|
||
The historical space and room token `fill_mode: glow` remains accepted on read
|
||
indefinitely. Runtime projects it into an ordinary inherited data fill plus an
|
||
enabled Glow overlay; explicit `glow_enabled` / room `glow` booleans always win.
|
||
A normal edit that replaces the legacy token writes the resolved boolean in the
|
||
same operation. Optimize Plans applies the same lossless, idempotent model-v7
|
||
migration while preserving unknown sibling settings.
|
||
|
||
## Space reference repair (model v7)
|
||
|
||
Missing `marker.space`, `marker.room_id`, `vacuum.segment_map` and layout
|
||
ownership remain readable by the permissive persisted schemas. They are never
|
||
rewritten during load or an unrelated Save. Explicit Optimize may map an
|
||
untruncated exact `space_<old>_<8 hex>` / `room_<old>_<8 hex>` import signature,
|
||
or use the production HA Area placement for an active real marker. Without a
|
||
valid target it removes the marker's missing placement but preserves its old
|
||
position for the owner-aware cleanup decision.
|
||
|
||
The same explicit Optimize candidate automatically removes layout entries only
|
||
for owners proven absent: missing room labels, removed marker tombstones, and
|
||
known devices or `lg_` entities absent from an authoritative HA registry/state
|
||
roster. A live owner in a deleted space is preserved unless the administrator
|
||
explicitly opts into removing its old position. A limited or unavailable
|
||
registry and an unknown/future layout namespace always preserve the entry;
|
||
nested vacuum mappings likewise remain stored and reported. This is a runtime
|
||
read-model decision, not a persisted migration: schemas, store/model versions
|
||
and the layout shape are unchanged. The pass is data-driven, undoable,
|
||
idempotent and runs even when `model_version` is already 7.
|
||
|
||
A one-space import uses its known id map (not a heuristic) to repair matching
|
||
orphan target references when the original space id is absent. Full restore is
|
||
unchanged. Space deletion now uses a revision-guarded config/layout transaction
|
||
and refuses active marker dependencies while another space remains; removed
|
||
tombstones keep their metadata and lose only placement fields owned by the
|
||
deleted space. Deleting the sole remaining space is the intentional exception:
|
||
all affected active and removed marker records survive with only `space` and
|
||
`room_id` cleared, preserving the empty-state contract. Older clients can read
|
||
every repaired candidate because the schemas and field shapes did not change.
|
||
|
||
Current `fill_mode` additionally accepts `custom`. Its optional color is stored
|
||
as `{c:'#RRGGBB',a:0..1}` in `space.settings.custom_fill` and, for an explicit
|
||
room override, `room.settings.custom_fill`. Missing or invalid historical data
|
||
is projected at render time through room → space → `#607d8b`/`0.18`; merely
|
||
reading it never rewrites the config.
|
||
|
||
Since #581 the room override is read only together with the room's **own**
|
||
`fill_mode: 'custom'`. A `room.settings.custom_fill` stored without that mode —
|
||
older editors wrote it when a room was switched back to "as the space" — is
|
||
projected as the space colour on every surface (card, space-card, PDF) without
|
||
being rewritten; the next save of that room through its dialog removes the
|
||
field. New saves never write `custom_fill` without `fill_mode: 'custom'`. The
|
||
field shapes and the backend schema are unchanged, so older clients read
|
||
configs saved by newer ones as "a room without its own colour" — the same
|
||
picture the newer client shows. Backend writes keep the strict shared
|
||
hex/finite-alpha contract. An explicit `null` is accepted at either level and
|
||
has the same projection semantics as a missing override.
|
||
|
||
The current space editor presents `custom` as the ordinary/default room fill
|
||
instead of offering a separate `none` choice. A historical space-level `none`
|
||
is still rendered losslessly until edited, then the dialog projects it to
|
||
`custom` with the existing/default color at zero opacity and materializes that
|
||
visually equivalent choice on Save. Newly created spaces use the same
|
||
zero-opacity custom value, so the UI change does not introduce a visible floor
|
||
or remove the Glow base by default. A zero-opacity resolved fill still receives
|
||
the Glow base. `none` remains accepted by the model and exposed at room level,
|
||
where it is still required to suppress an inherited
|
||
LQI/light/temperature/custom fill for one room.
|
||
|
||
Rooms may also store optional `settings.temp_min` and `settings.temp_max`
|
||
finite numbers. Each absent or explicit `null` side independently inherits
|
||
`space.settings.temp_min` / `temp_max` (and then the product default). The
|
||
effective pair is sorted only at the read boundary, so a partial override is
|
||
not materialised merely because its inherited counterpart crosses it. Current
|
||
writers sort two explicitly entered values and omit cleared keys. The fields
|
||
affect only temperature room/tunnel fills. Older cards ignore them and may
|
||
erase them if they rebuild that room's settings after a downgrade.
|
||
|
||
## Per-marker light role and Glow appearance
|
||
|
||
`marker.is_light` is tri-state. Missing/null means automatic device-role
|
||
discovery, `true` forces the marker's own controllable entity to be a spatial
|
||
source, and `false` suppresses that own source. External `controls` are not
|
||
suppressed: they continue to contribute to room light state and counts without
|
||
placing a Glow pool at the controller. This intentionally changes the read
|
||
semantics of hand-written legacy `is_light: false`: older frontends treated it
|
||
like Auto, while current frontends treat it as Never. The historical writer
|
||
only emitted `true` or `null`, so ordinary UI-authored configs are unaffected.
|
||
|
||
`marker.glow_color` is optional and strict: `{c:'#RRGGBB'}` fixes colour while
|
||
keeping live brightness; `{c:'#RRGGBB',bri:0.01..1}` fixes both. Missing/null
|
||
uses the live source. Invalid objects fall back atomically to live values and
|
||
never partially reach SVG. `{c,bri:null}` is accepted for compatibility,
|
||
projects like `{c}`, and is canonicalised to `{c}` by the next marker save.
|
||
Older frontends ignore this field at render time and may erase it when they
|
||
rebuild the same marker after a downgrade; this limitation cannot be repaired
|
||
retroactively.
|
||
|
||
`marker.light_entity` optionally stores the leading `light.*`/`switch.*` for an
|
||
Always source with several controllable entities. It is copied literally by
|
||
full and space transfer: entity ids are instance-specific and are never
|
||
remapped. Missing/invalid selections remain stored, produce a dialog warning
|
||
and temporarily fall back to the normal deterministic selection; merely
|
||
opening or saving another field does not erase them. Older frontends ignore the
|
||
unknown field and may erase it only if they reconstruct that marker.
|
||
|
||
`marker.toggle_entity` optionally stores the exact own `light.*`/`switch.*`
|
||
operated by Toggle. Absence/null keeps the historical single-target resolver
|
||
and external-only controls groups bit-for-bit. A present active choice is exact:
|
||
temporary missing/unavailable/secure state never retargets it to a sibling. A
|
||
choice no longer belonging to the marker remains stored, warns in the dialog
|
||
and temporarily uses the historical fallback. New/changed values are
|
||
domain-bounded by lossless delta validation; an untouched future literal can
|
||
round-trip. Full and space transfer copy the entity id literally, while a
|
||
duplicate marker virtualised during space import drops the HA-dependent field.
|
||
Older frontends ignore it and may erase it if they reconstruct the marker.
|
||
`light_entity`, `toggle_entity` and `tap_target` are independent.
|
||
|
||
`marker.controls[]` additionally accepts `marker:<marker_id>` links to forced
|
||
plan sources. Runtime and old frontends continue to filter those strings out of
|
||
HA service calls. New writes validate target existence, forced-source role,
|
||
self-reference, duplicates and cycles; an already stored broken legacy link is
|
||
allowed to round-trip so unrelated edits cannot lock the plan. Deleting or
|
||
rebinding a target removes or rewrites references atomically. A space export
|
||
remaps links whose two ends are inside the exported space and reports/drops
|
||
links leaving it; full export preserves them literally.
|
||
|
||
`marker.value_badge` is an optional explicit value satellite. Its absence is
|
||
the legacy compatibility state: the historical temperature/humidity heuristic
|
||
and global `show_temperature` gate remain in force. An explicit
|
||
`{enabled:false,...}` suppresses that heuristic; an enabled badge stores one
|
||
discriminated source and one of four stable positions. Unknown sibling keys in
|
||
the badge and source objects are preserved. New/changed records are validated,
|
||
while an untouched old broken source remains readable and round-trippable.
|
||
`derived_marker_state.ref` uses the same `marker:<id>` namespace as controls:
|
||
space transfer remaps an internal target and disables/counts a link whose
|
||
target is outside the transfer. Older clients ignore the field and may erase
|
||
it if they reconstruct the same marker after a downgrade.
|
||
|
||
`marker.display` gained a fifth accepted token, `value_static_icon` (#588),
|
||
beside `badge`, `icon_ripple`, `value` and `static_icon` (plus the read-only
|
||
legacy `ripple`). The shape of the field does not change and there is no
|
||
migration. A client older than the mode meets an unknown token at its existing
|
||
read boundary (`normalizeDeviceDisplay`) and projects it to `badge`: the marker
|
||
appears as an ordinary coloured icon. The degradation is visible but safe — the
|
||
stored value is not rewritten, and only an explicit re-save of that marker by
|
||
the user would replace it. The backend accepts the new token on write and keeps
|
||
accepting every previous one.
|
||
|
||
`marker.value_source` is an optional explicit source for the inner face of a
|
||
`display: value` or `display: value_static_icon` marker. Absence or `null` preserves the historical automatic
|
||
entity-state choice; an object uses exactly the same discriminated source
|
||
contract and formatter as `marker.value_badge.source`. A missing explicit
|
||
source stays selected and renders `—` rather than silently falling back. The
|
||
top-level schema remains lossless: unchanged future literals round-trip, while
|
||
new or changed values receive strict delta validation. Marker-id rewrites and
|
||
full/space transfer preserve, remap or report/drop `derived_marker_state.ref`
|
||
through the same reference seam as controls and value badges. Older clients
|
||
ignore the field and may erase it if they reconstruct the marker.
|
||
|
||
## Active marker ID uniqueness (#625)
|
||
|
||
At every write boundary, a marker `id` may identify at most one active record
|
||
(`removed` is not `true`). A tombstone and one active marker with the same id
|
||
remain valid: the tombstone records lifecycle history and does not suppress the
|
||
live marker's layout update.
|
||
|
||
The structural `CONFIG_SCHEMA` remains permissive so an installation that
|
||
already contains two active legacy records is still readable. The semantic
|
||
validator compares the candidate with the previously stored document: an
|
||
unchanged duplicate group may survive an unrelated save, but a new duplicate
|
||
or any edit that leaves the group ambiguous is rejected as `invalid_config`
|
||
with the stable detail `duplicate active marker id`. Removing/tombstoning enough
|
||
records to leave one active marker is the supported repair. A full import is authoritative and therefore
|
||
strict even when its source document contains legacy duplicates; there is no
|
||
automatic deletion or migration.
|
||
|
||
## Atomic marker writes (#442)
|
||
|
||
The Device editor builds a separate complete config candidate and treats a
|
||
successful `houseplan/config/set` response as the durable boundary. If semantic
|
||
validation, transport or schema validation rejects that request, the card
|
||
restores the preceding server-confirmed config and content fingerprint, rebuilds
|
||
the visible marker set, and keeps the independent dialog draft available for
|
||
Retry. A revision loaded after a conflict or newer local content always wins;
|
||
an older rejection cannot overwrite either one.
|
||
|
||
Layout placement, obsolete-layout cleanup and copied-file cleanup happen only
|
||
after config acceptance. Their failure can report an error and keep the dialog
|
||
open, but cannot roll back a marker already accepted by the server. This is a
|
||
frontend transaction contract only: marker schema, validators, revision wire
|
||
format and downgrade behaviour are unchanged.
|
||
|
||
## Persistent manual virtual-light state
|
||
|
||
The exact `virtual` + `is_light:true` + `tap_action:toggle` combination has a
|
||
shared operational on/off state. It is not a Marker/ServerConfig field: the
|
||
integration stores `{rev, config_rev, off[]}` under a separate versioned Store
|
||
key and exposes an optional `virtual_lights` snapshot in `houseplan/config/get`.
|
||
It is excluded from layout, full/space export, import and HA entities. Missing
|
||
Store data or a missing wire field projects to `on`.
|
||
|
||
Compatibility matrix:
|
||
|
||
| Frontend | Backend | Behaviour |
|
||
|---|---|---|
|
||
| old | new | Extra snapshot/event are ignored; the marker keeps historical #84/#94 behaviour and config remains intact |
|
||
| new | old | Missing snapshot starts `on`; an unsupported toggle command reports an error and creates no optimistic local state |
|
||
| new | new | Server snapshot is authoritative; revisioned events synchronize full and static cards |
|
||
|
||
Every current config writer reconciles the Store. Rename, move, hide and
|
||
unrelated edits preserve off bits for still-eligible stable marker ids;
|
||
binding/role/action changes, tombstones and deletion prune them. Re-entering the
|
||
triple therefore starts on. If `config_rev` skips the revision recorded by the
|
||
operational Store — for example after downgrade, an old writer or an interrupted
|
||
pair — all off bits are cleared rather than resurrected against unknown marker
|
||
lifecycle history. Older integrations safely ignore the separate Store on
|
||
downgrade.
|
||
|
||
## Marker Area provenance (#126)
|
||
|
||
`settings.marker_area_snapshot` is optional internal lifecycle metadata. Each
|
||
entry records one exact `device:*` or `entity:*` binding and its last accepted
|
||
non-empty HA Area. The map is capped at 20,000 entries and remains subject to
|
||
the 2 MiB config limit. Absence triggers a conservative one-time backfill:
|
||
positions move only when another Area-bound room or another space proves that
|
||
the saved point is stale; boundary, outside and ambiguous points are preserved.
|
||
|
||
Same-source full backups preserve the map. Full imports from another source
|
||
drop it together with discovery lifecycle lists, and space-only imports never
|
||
carry this global metadata. Old frontends ignore the field; defensive reads in
|
||
new frontends skip malformed entries independently. Rebinding and marker
|
||
deletion remove the obsolete entry, and provenance advances only after the
|
||
corresponding stale layout position has been deleted successfully.
|
||
|
||
Automatic orphan cleanup is fail-safe. It uses the full Device/Entity Registry,
|
||
exact live entity states and saved live markers rather than the filtered list
|
||
of icons that can currently be drawn. A completely empty relevant registry
|
||
namespace never deletes provenance. A binding missing from a non-empty full
|
||
registry is removed only after the same absence is observed in two distinct
|
||
authoritative revisions; the first observation requests one shared registry
|
||
refresh. This confirmation is runtime-only and restarts after a card remount.
|
||
Explicit marker deletion, rebinding and leaving registry-following placement
|
||
keep their immediate lifecycle cleanup.
|
||
|
||
## Wall junction limits (#329)
|
||
|
||
Junction limits (minimum 15° between neighbouring walls of one node, at most
|
||
six walls per node, a wall at least 20 cm long and never shorter than its own
|
||
thickness, 5 cm between non-incident nodes and node to foreign wall, at least
|
||
25 cm² of room interior left after the masonry) are a WRITE contract, not a
|
||
document contract. An existing plan that violates them stays valid and stays
|
||
readable: migration to the current model, JSON import, backup restore and full/space
|
||
transfer never run the check, and an edit that does not touch the offending
|
||
element still saves.
|
||
|
||
The gate compares the candidate against the pre-edit document **after both
|
||
have gone through the same wall-segment migration** — `commitWallSegmentModel`
|
||
on the card, `commit_wall_segment_model` in
|
||
`custom_components/houseplan/junction_limits.py` — and counts violations **per
|
||
rule**, not per subject id: a structural write re-keys contour atoms, so
|
||
subject identity is not stable across the barrier. Only a rule whose violation
|
||
count grows is a refusal.
|
||
|
||
Migrating the baseline is not a detail. The limits read `wall_segments`, so a
|
||
document older than the catalogue reports no walls at all and therefore no
|
||
violations, whatever its geometry. Judged raw, such a baseline turns every
|
||
inherited violation of a real plan into a "new" one on the first structural
|
||
write after the card is updated, and an unrelated edit is refused.
|
||
|
||
Compatibility matrix:
|
||
|
||
| Frontend | Backend | Behaviour |
|
||
|---|---|---|
|
||
| old | new | No change: the limits live in the card's write barrier, the backend contract is untouched |
|
||
| new | old | No change: an inherited violation is never re-judged, so an old backend's documents keep loading and editing |
|
||
| new | new | A write that ADDS a violation is refused in the surface where it was made — a toast naming the rule for drawing and Thickness, a stopped wall for Resize |
|
||
|
||
Intermediate wall-chain clicks in a current model-v10 document use the bounded
|
||
local write proof from #461. This is runtime-only: each accepted
|
||
`partitions[]` record and the WebSocket payload remain the same full config,
|
||
including unknown fields. A pre-v10 first structural write, any diff not proven
|
||
to be one active-chain partition append, and room creation from a closed chain
|
||
retain the full-space migration, physical and junction barrier. Old frontends
|
||
and old backends therefore see no new field or protocol, and a backend rejection
|
||
still rolls the complete pending physical transaction back to its earliest
|
||
snapshot.
|
||
|
||
## Optimize plans: explicit whole-plan maintenance («Оптимизировать планы»)
|
||
|
||
Existing and imported plans may still hold grid-bound coordinates between the
|
||
nodes. Ordinary grid-bound editor operations do not create more; explicitly
|
||
continuous objects are exempt (the snap contract itself is `CANVAS.md` §9). General settings contain
|
||
a **Plan maintenance** group whose action previews and then repairs old
|
||
data through all current passes: model upgrades, mandatory grid
|
||
alignment, exact open-span canonicalisation and wall-interval compaction.
|
||
Unlike live snapping, the explicit maintenance pass also replaces a stored
|
||
coordinate which is only one or several ULPs away from its node with the exact
|
||
computed node. That has no visible displacement but removes topology noise at
|
||
its persisted source.
|
||
|
||
Why an action rather than a silent migration:
|
||
|
||
1. It moves the user's data without asking. A house plan is a drawing;
|
||
the card has no mandate to redraw it on a version bump.
|
||
2. A silent migration is unattributable. When a room looks 3 cm wrong
|
||
the owner cannot tell whether the card did it or they did.
|
||
3. An update that touches stored geometry cannot be rolled back by
|
||
downgrading the card. The explicit action has a one-deep snapshot and
|
||
can also simply not be pressed.
|
||
|
||
`optimizePlans(config, layout)` (`src/plan-optimizer.ts`) is the pure
|
||
orchestrator. It converts legacy fields with an exact mapping, projects
|
||
`open_spans` (or the `open_to` fallback) into stable zero-thickness wall atoms,
|
||
calls the grid projection, rekeys exact wall endpoints onto moved rooms,
|
||
compacts consecutive atoms only when thickness and physical ownership both
|
||
match, and stamps `model_version`. Outer/shared transitions and changes of
|
||
shared-room pair remain exact breakpoints even at equal thickness. Unknown
|
||
fields are preserved and every pass is idempotent.
|
||
|
||
The explicit pass also repairs pre-existing near-axis room walls, saved wall
|
||
chains and independent walls after ordinary grid alignment (#290). Coincident
|
||
room-owner copies count as one physical wall and move as one endpoint
|
||
equivalence class. The preview reports the unique count, maximum physical
|
||
movement and unsafe skipped candidates; only Confirm writes, and Undo restores
|
||
the prior geometry. Exact axes and true diagonals are not candidates.
|
||
|
||
The optimizer deliberately does **not** alter backdrop calibration or saved
|
||
view boxes, deduplicate markers, or delete files. It may delete an unattached
|
||
layout entry only after classifying its owner against current rooms, marker
|
||
tombstones and an authoritative HA device/entity roster. Proven-absent room
|
||
labels, devices and group markers are cleaned; live owners are preserved unless
|
||
the administrator explicitly opts into removing their old positions, and an
|
||
incomplete registry or unknown namespace always fails closed. The cleanup is
|
||
part of the pure candidate, Undo and idempotence contract. File collection
|
||
remains the backend's reference-aware scheduled job.
|
||
|
||
`alignAllToGrid(spaces, layout)` (`src/align-grid.ts`) is pure: it
|
||
copies its input, never mutates it, and returns the new spaces, the new
|
||
layout and the report. The dialog therefore measures and commits the
|
||
**same object** — the numbers it promises cannot differ from what it
|
||
does. The resulting config+layout pair is sent to
|
||
`houseplan/plan/optimize`; the backend persists a durable intent before
|
||
either store changes, commits both revisions, and retains one snapshot.
|
||
`houseplan/plan/optimize_undo` restores it only while neither revision
|
||
has changed since the optimization. A crash between store writes is
|
||
completed from the intent on the next integration setup.
|
||
|
||
The grid pass deliberately excludes the complete transform of `furniture`,
|
||
uploaded `image` decor and `spaces[].stairs[]`. Their position, size and
|
||
rotation are continuously authored values (#383, #663), so changing even one of those fields would make
|
||
Optimize create debt from a normal editor operation. Other decor kinds and
|
||
storage-level numeric canonicalization keep their existing grid contract
|
||
(#477).
|
||
|
||
The pair returned by `optimizePlans` passes the same lattice-aware boundary as
|
||
the storage writers **before** visible Align and before `changed` is computed.
|
||
This boundary is required because the normalized grid step `1 / 240` has no
|
||
finite decimal representation: an exact node and a nine-decimal JSON echo may
|
||
be visually identical but not `===`. Update-event reload and a cold read
|
||
therefore receive exactly the pair retained by the preview, and a second run
|
||
cannot manufacture fresh coordinate noise (#248, #291).
|
||
|
||
Model v8 adds a second, identity-preserving stage at this write boundary
|
||
(#282). `materializeWallSegmentModel()` atomizes canonical room contours into
|
||
`wall_segments[]`, keeps the deterministic parent ID on one split child, emits
|
||
UUIDs only for genuinely new v8 atoms, and refreshes `rooms[].wall_ids[]`,
|
||
draft IDs and tagged opening hosts together. The historical `walls[]` entries
|
||
are regenerated from this catalog as a compatibility view. Reading or fitting
|
||
the canvas never runs this migration; only physical edits, Optimize and a
|
||
v7-to-v8 import may materialise it. Failure keeps the previous view, history
|
||
and persisted revision intact.
|
||
|
||
Guarantees are covered by `test/align-grid.test.mjs` and the orchestration/
|
||
idempotence case in `test/plan-optimizer.test.mjs`:
|
||
|
||
* every grid-bound element ends on a node; a rect's FAR corner too (a
|
||
snapped *size* on an off-grid origin leaves the other side between
|
||
the nodes);
|
||
* an opening ends on its wall, at whole steps along it, inside it, and
|
||
**with the wall's own angle** — the angle is written, so it is part of
|
||
the diff (AUD-158B1-02: an opening already on its wall with a wrong
|
||
angle used to be returned changed inside `changed: false`, which made
|
||
it unfixable);
|
||
* a stray opening with no wall within 6 steps is left exactly where it
|
||
is rather than teleported;
|
||
* **idempotent across storage**: a second run in memory, after the lattice-aware
|
||
writer round-trip, after update-event reload or after a cold read reports
|
||
`moved: 0`, `changed: false`, and `latticeCoordinatesCanonicalized: 0`, and returns
|
||
objects deep-equal to the first persisted result;
|
||
* the report is an **upper bound**, not a sample (AUD-158B1-01).
|
||
|
||
Before a changed preview can expose Apply, `checkOptimizeGeometry(config)`
|
||
(`src/plan-geometry-preflight.ts`) runs the exact candidate through the shared
|
||
production input projection and canonical wall/floor boolean builders for every
|
||
space. `failed-core`, `degraded-extra` or an exception is a structural failure;
|
||
an empty successful geometry and an empty/image-only space are not. One failure
|
||
blocks the whole operation and the endpoint is not called. The dialog retains
|
||
only bounded statuses plus `contentFingerprint(candidate.config)`: unchanged
|
||
Apply reuses that result, while a changed fingerprint is checked again and
|
||
fails closed.
|
||
This frontend barrier does not replace backend permission, schema, revision or
|
||
crash-recovery checks and is not a security attestation from an untrusted
|
||
client.
|
||
|
||
The same projection has a one-space transaction entry point for ordinary
|
||
physical edits (#278). Room/wall/open-span/opening/partition/column
|
||
candidates are validated before entering Undo or the save queue. A physical
|
||
fingerprint is rechecked immediately before the deferred config write; failure
|
||
restores the saved geometry and produces no WebSocket call. Presentation-only
|
||
edits deliberately do not invoke this barrier, so a legacy degraded plan can
|
||
still be renamed, exported and inspected.
|
||
|
||
### The report is a promise
|
||
|
||
The confirmation is the decision gate in front of a geometry rewrite, so
|
||
`maxShift`/`maxShiftCm` must never be smaller than what the run does:
|
||
|
||
* displacement is measured on the geometry **actually written back** —
|
||
all FOUR corners of a rect, minimum-size correction included. The two
|
||
corners nobody used to measure are exactly the two that can be worst:
|
||
they carry the X error of one side together with the Y error of the
|
||
other, which is √2 of either;
|
||
* an opening is measured on its **ends**, flip-invariantly, so turning
|
||
it in place costs what it really costs and a 180° rewrite costs
|
||
nothing;
|
||
* the maximum is accumulated in **centimetres**, each space through its
|
||
own `cell_cm`, and the report names the space it belongs to. One
|
||
normalised maximum converted through the *first* space's cell size
|
||
promised 2.5 cm for a vertex that moved 50 cm on a 100 cm floor;
|
||
* the dialog rounds the last tenth **up** and, on a multi-space plan,
|
||
says which space the maximum is in; openings corrected in angle alone
|
||
are counted on a line of their own.
|
||
|
||
`latticeCoordinatesCanonicalized` counts individual near-node coordinate
|
||
components actually rewritten by the storage boundary. Its maximum is measured
|
||
in each value's own `cell_cm`, displayed with three significant digits and kept
|
||
separate from visible `moved/maxShift*`. Only touched spaces receive a detail
|
||
line; each line also states how many authored off-grid components were observed
|
||
and left unchanged. Layout values without a named space contribute only to the
|
||
summary. The older `coordsCanonicalized` remains an internal Align counter and
|
||
does not absorb this storage-only work.
|
||
|
||
One undo is available until the next config or layout edit. It restores
|
||
the stored snapshot; re-running optimization itself is never treated as
|
||
undo because a grid projection is not invertible.
|
||
|
||
## Square canvas and legacy `aspect` (v1.48)
|
||
|
||
Until v1.48 a space stored `aspect` and coordinates were normalised x by width,
|
||
y by height. The render space is now `NORM_W × NORM_W`; a plan image is fitted
|
||
by its own ratio (`fitInSquare`) and that ratio is stored as `plan_aspect` so
|
||
the layout does not jump before the file loads. Setup still upgrades a legacy
|
||
`aspect` once through `geometry_migration`: the box is padded to a square and
|
||
every coordinate re-expressed as one uniform scale plus offset in render units
|
||
(angles and proportions exact), with `cell_cm` scaled for tall plans. A durable
|
||
`geom_pending` intent is written to the layout store before the config half
|
||
removes `aspect`; the next start finishes whichever half is missing
|
||
(HP-1490-01), and update events fire only after both halves are durable.
|
||
Installations stranded before that intent existed are repaired only explicitly
|
||
with `houseplan/geometry/repair` (HP-1500-01).
|
||
|
||
## Legacy content URLs
|
||
|
||
`/houseplan_files/…` is a public static path for frontend code only (card,
|
||
panel, lazy chunks), because a Lovelace resource must load without
|
||
authentication. Plans and marker files are served only by the authenticated
|
||
`HouseplanContentView` (`/api/houseplan/content/<plans|files>/…`, signed URLs).
|
||
Stored configs may still hold older `/houseplan_files/plans|files/…` URLs:
|
||
`contentUrl()` rewrites them on every read and portable import accepts both
|
||
prefixes, so there is no storage migration.
|