mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
v1.46.4 and v1.46.5 documented that detaching a plan leaves the image on disk,
added guards for it, and shipped tests. The guards were never reached: they sit
behind 'not superseded', and a file that left the configuration was called
superseded. From old_refs - new_refs alone, replacing a plan, detaching one and
deleting its space are indistinguishable — so all three deleted the file, at the
moment of the save, before any scheduled pass ever ran.
Every test I wrote for this called collect_plans(d, cfg, cfg): old config equal
to new, i.e. only the scheduled pass. The transition that mattered was never
exercised. Codex reproduced it in four lines.
Classification is by owner now:
space in both, plan A -> plan B : the user picked another image -> removed
space in both, plan -> none : detached -> kept
space gone : kept (the image was imported; a thirty-day
grace measured from file age is meaningless
anyway, it was uploaded months ago)
space has a plan, other file : rejected upload -> 1 h
Attachments follow the same shape: dropped from a device that still exists ->
removed (a trash button promises nothing); device gone -> kept; staging folder
-> 1 h.
Tests: a matrix per rule in the pure module, and — the part that was missing —
test_detaching_a_plan_keeps_the_file, which goes through real config/set calls:
attach, detach, assert the file is there, restart, assert again, re-attach,
replace, assert the replaced one is gone, delete the space, assert the plan
survives. Also strengthened the sweep/save race test to assert the save actually
succeeded and the config points at the specific expected file, per the report.
368 lines
23 KiB
Markdown
368 lines
23 KiB
Markdown
# House Plan architecture
|
||
|
||
Updated: 2026-07-04 (v1.2.2). The repository = a HACS integration (category **Integration**)
|
||
that contains both the backend (`custom_components/houseplan`) and the Lovelace card (`src/` → `dist/`).
|
||
|
||
## Layout
|
||
|
||
```
|
||
houseplan-card/
|
||
├─ src/ # card sources (TypeScript + Lit 3)
|
||
│ ├─ houseplan-card.ts # the card: rendering, states, drag, tooltip, sticky header
|
||
│ ├─ editor.ts # GUI config editor (ha-form + selectors)
|
||
│ ├─ rules.ts # icon rules (iconFor), curation, groups, domain priority
|
||
│ └─ data/
|
||
│ ├─ house.ts # geometry: ROOMS (rooms→area), FLOOR_VB (viewBox), names
|
||
│ └─ backgrounds.ts # VECTOR plans (SVG base64) + FLOOR_BG_RECT (positioning)
|
||
├─ dist/houseplan-card.js # build (rollup+terser), ~290 KB, plans embedded
|
||
├─ custom_components/houseplan/ # the HA integration
|
||
│ ├─ __init__.py # setup: Store, WS commands, JS serving (add_extra_js_url)
|
||
│ ├─ websocket_api.py # houseplan/layout/get|set|update
|
||
│ ├─ config_flow.py # single entry; admin_only option (editing restricted to admins)
|
||
│ ├─ const.py # DOMAIN, STORAGE_KEY, VERSION, FRONTEND_URL
|
||
│ └─ frontend/houseplan-card.js # copy of dist, served as /houseplan_files/houseplan-card.js
|
||
├─ hacs.json # HACS manifest
|
||
└─ docs/ # this documentation
|
||
```
|
||
|
||
## Key decisions
|
||
|
||
1. **One repository — integration + card.** The integration serves the JS
|
||
(`async_register_static_paths`) and registers it as a **Lovelace resource** (module) —
|
||
the frontend waits for resources before rendering, so the card also works on a cold start
|
||
of the mobile app (unlike `add_extra_js_url`, which remains a fallback for
|
||
YAML mode). The user does not need to add the resource manually.
|
||
2. **Icon layout lives on the server.** `helpers.storage.Store(1, "houseplan.layout")` →
|
||
`.storage/houseplan.layout`. The card reads/writes via `hass.callWS`
|
||
(`houseplan/layout/get|set|update`). Fallback — localStorage (when the integration is absent).
|
||
3. **No token.** Everything comes from the frontend `hass` object: `hass.states` (reactive),
|
||
`hass.devices/entities/areas` (registries). No direct WS connections.
|
||
4. **Reactivity.** Every state change in HA leads to set hass → re-render.
|
||
Temperatures/LQI/on-off are live by definition (verified by substituting state).
|
||
|
||
## Coordinate system
|
||
|
||
- Base space: **1489×1053** ("pixels" of the old PNG render, 1 unit = 1 px).
|
||
All rooms, icon positions and floor viewBoxes live in it. DO NOT change without a layout migration.
|
||
- Vector plans are inserted as `<image href=svg>` into the `FLOOR_BG_RECT` rectangle:
|
||
- f1: scale **0.647**, offset **(490, 27)** → rect [490, 27, 774.2, 949.3]
|
||
- f2: scale **0.896**, offset **(351, 21)** → rect [351, 21, 1048.4, 961.4]
|
||
- computed via raster correlation (cv2.matchTemplate on binarized darkness maps) of the
|
||
SVG render against the reference PNG; accuracy ~1 px. The scripts are reproducible (docs/DEVELOPMENT.md).
|
||
- Rooms (`ROOMS`) are snapped to the inner faces of walls (semi-automatic: search for the nearest
|
||
"dark line" along the profile + manual fine-tuning against overlay renders).
|
||
|
||
## Card data model (runtime)
|
||
|
||
`DevItem`: id (device_id), name, model, area, floor, icon, entities[], primary (the entity used for
|
||
more-info by domain priority), temp, members[] (light group), link/linkPrimary (Z2M group).
|
||
|
||
Built from the registries (`_buildDevices`), rules carried over 1-to-1 from the prototype:
|
||
- only devices with an area from the room list are shown;
|
||
- hidden: entry_type=service, integrations from EXCLUDED_DOMAINS, model=Group, scenes, bridges,
|
||
myheat sub-devices, duplicates by "name|area";
|
||
- **a device with a `lock.*` entity always gets `mdi:lock`** (TTLock locks in the registry
|
||
are named "Dom"/"Terrasa"/"Kladovka" [House/Terrace/Storeroom] — unrecognizable by name);
|
||
- lamps (mdi:lightbulb) with ≥2 in a room collapse into a group `mdi:lightbulb-group`
|
||
(click → menu: the whole group + individual lamps).
|
||
|
||
## Live data
|
||
|
||
- Temperature: an entity with device_class=temperature / °C / `_temperature$` → label on the right.
|
||
- LQI (zigbee): the average over `*_linkquality` entities → label under the icon; color via
|
||
`lqiColor()`: ≤40 red → ≥180 green (hsl gradient). The room average is shown in the room tooltip.
|
||
- Icon state classes: on (yellow), open (orange: cover/valve/lock/binary_sensor
|
||
of problem classes), unavail (transparency).
|
||
|
||
## Sizes
|
||
|
||
`icon_size` in the config = **% of the visible plan area width** (default 2.5). Implementation:
|
||
`.stage { container-type: inline-size }` + sizes in `cqw`. Legacy px values (>8) are ignored.
|
||
|
||
## Sticky header
|
||
|
||
`.head { position: sticky; top: var(--header-height, 56px) }`; it is MANDATORY that
|
||
`ha-card { overflow: visible }` — `overflow: hidden` breaks sticky.
|
||
|
||
## Device markers (v1.6.0+)
|
||
|
||
Per-marker appearance (v1.22.0): `display: badge|ripple|icon_ripple` (+`ripple_color`,
|
||
`ripple_size`) draws presence-style pulsing rings gated by the pure `isActiveState` —
|
||
deliberately independent of the card-wide `live_states` toggle, with `unavailable` counting as
|
||
idle. `size` (icon multiplier via the `--dev-size` CSS var — value badges scale along) and
|
||
`angle` rotate/scale a single icon. Room drawing shows a live **ruler** (`segmentCm` +
|
||
`formatLength`, metres or feet+inches by `hass.config.unit_system`); the scale is per-space
|
||
`cell_cm` (default 5 cm per grid cell).
|
||
|
||
|
||
`config.markers[]`: `{id, binding:'device:<id>'|'entity:<eid>'|'virtual', space?, area?, hidden?,
|
||
name?, icon?, model?, link?, description?, pdfs:[{name,url}]}`. A hybrid: auto-discovered HA devices
|
||
appear on their own; a marker with `binding=device:<id>` overrides them (metadata/rebinding/hiding),
|
||
`entity:<eid>` — for groups/helpers, `virtual` — a manual icon without HA. The marker id = device_id /
|
||
`lg_<eid>` / `v_<rand>` (preserves the position in the layout). The binding picker excludes already-placed
|
||
references and duplicates by name|area. Manual files: `houseplan/file/set` → `/config/houseplan/files/<id>/`,
|
||
served from `/houseplan_files/files/`.
|
||
|
||
## Server-side configuration (v1.3.0+)
|
||
|
||
`.storage/houseplan.config` (Store):
|
||
```json
|
||
{ "spaces": [{ "id","title","plan_url","aspect","view_box":[4],"rooms":[{"id","name","area","x","y","w","h"}] }],
|
||
"device_overrides": {"<device_id>": {"hidden","icon","name"}},
|
||
"virtual_devices": [{"id","space","name","icon","x","y","note?","entity_id?"}],
|
||
"settings": {"exclude_integrations":[],"group_lights":true} }
|
||
```
|
||
All coordinates are **normalized (0..1 of the space plan)**; the render space is
|
||
1000 × 1000/aspect. Layout v2: `{device_id: {"s": space, "x", "y"}}` (normalized).
|
||
Plan files: `<config>/houseplan/plans/<space>.<ext>` → URL `/houseplan_files/plans/…`.
|
||
If the server config is empty, the card falls back to the legacy bundle (the dacha) and shows a
|
||
"To server" migration button in edit mode. The dacha was migrated on 2026-07-04.
|
||
|
||
## Room geometry rules (v1.19–v1.21)
|
||
|
||
A **line is never an entity of its own**: walls are *derived* from room outlines
|
||
(`roomEdges`, deduped by `segKey`), so an abandoned outline leaves nothing behind and deleting
|
||
a room keeps the walls its neighbours still contribute. Rooms may not overlap
|
||
(`pointStrictlyInside` + `roomsOverlap`; being ON a shared wall is legal — real neighbouring
|
||
walls overlap collinearly rather than match exactly). **Merge/Split** use boolean geometry from
|
||
**polyclip-ts** (chosen over `polygon-clipping`, whose ESM build exports only a default while
|
||
its types declare named exports — breaking either tsc or the runtime): merge accepts a pair only
|
||
when the union collapses into one hole-free outline; split cuts wall-to-wall with a chord, the
|
||
bigger part keeps the room identity (name/area/devices).
|
||
|
||
## Markup editor (v1.4.0+)
|
||
|
||
State inside the card: `_markup` (mode), `_tool` (draw/delroom), `_path` (the current outline,
|
||
vertices on the GRID_N=240 grid). Clicks on the stage → `_svgPoint`→`_snap`. The outline is closed
|
||
= a click on the first vertex → area select (hass.areas) + name → room {poly}. Polygon rooms and
|
||
rectangles are rendered uniformly (hit-test: point-in-polygon / rect).
|
||
|
||
**A line is never an entity of its own (v1.19.0).** Nothing is persisted while you draw: an
|
||
outline you never close leaves no trace. Walls are *derived* from the room outlines by
|
||
`roomEdges(rooms)` (logic.ts) and deduped by `segKey`, so a wall shared by two rooms is emitted
|
||
once — deleting a room therefore keeps the borders its neighbours still contribute and drops the
|
||
rest, with no bookkeeping. The legacy `space.segments` array is stripped on every save (validation
|
||
still tolerates it on read; see CHANGELOG v1.19.0).
|
||
|
||
While drawing, the length of the current segment follows the cursor (`_fmtLen` → `segmentCm`/
|
||
`formatLength`): metres, or feet+inches when `hass.config.unit_system` is imperial. The scale is
|
||
per-space `cell_cm` — cm represented by one grid cell (default 5, so 240 cells ≈ 12 m).
|
||
|
||
## Doors & windows (v1.23.0+)
|
||
|
||
`space.openings[]` — plan geometry, **not** markers: an opening needs an angle, a length and a
|
||
wall, while markers are free points whose positions live in the layout store. Model:
|
||
`{id, type: door|window, x, y, angle, length, contact?, lock?, invert?, flip_h?, flip_v?}`
|
||
(normalized coords; `length` normalized by plan width). Placement snaps onto the nearest
|
||
**derived** wall via `snapToWall` (logic.ts) — the angle is normalized to [-90, 90) because two
|
||
rooms share a wall with opposite edge directions, and without that a drag across segment
|
||
boundaries flips the hinge. The opening then keeps **absolute coordinates**, so editing, merging
|
||
or deleting rooms never breaks it.
|
||
|
||
Rendering (after easy-floorplan, MIT): SVG symbol at the origin (jambs + hinged leaf + a
|
||
quarter-circle arc revealed via `stroke-dashoffset`), translated/rotated onto the wall; windows
|
||
are two casement leaves. `openingAmount` (pure) maps the contact state to 0..1: no sensor →
|
||
door drawn open / window closed (static-plan convention); `unavailable`/`unknown` freeze that
|
||
default. The lock renders as an HTML padlock badge (`.oplock`) in the device layer; a lock is
|
||
**never** toggled from the plan (TOGGLE_FORBIDDEN_DOMAINS rule). View-mode UX: hover outline,
|
||
drag along walls (continuous re-snap, saved on release), click → status card (250 ms timer),
|
||
double click → properties dialog. In markup mode the "Opening" tool handles clicks instead.
|
||
|
||
## Integration WS API
|
||
|
||
| Command | Parameters | Response |
|
||
|---|---|---|
|
||
| `houseplan/layout/get` | — | `{layout: {device_id: {x,y}}, rev}` |
|
||
| `houseplan/layout/set` | `layout`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_layout_updated` |
|
||
| `houseplan/layout/update` | `device_id`, `pos` | `{ok, rev}`; event `houseplan_layout_updated` |
|
||
| `houseplan/config/get` | — | `{config, rev}` |
|
||
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
|
||
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` — writes `<space>.<token>.<ext>`, deletes nothing |
|
||
| `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) |
|
||
|
||
**User content is served inert** (HP-1454-01). An uploaded SVG is the only
|
||
thing here that a browser will happily treat as a *document* rather than an
|
||
image, and it would be a document of Home Assistant's own origin. Inside the
|
||
card that never matters — `<image>` does not run scripts — but the url is
|
||
reachable directly, and uploading needs only write access, which by default
|
||
every user has. `HouseplanContentView` therefore sends a `sandbox` CSP with SVG
|
||
and only with SVG: a CSP on a PDF response can break the browser's built-in
|
||
viewer, and a raster image has no execution model to disable.
|
||
|
||
**Attachments follow the same commit-scoped lifecycle as plans** (HP-1454-02).
|
||
An upload takes a free name and never overwrites, because the bytes under an
|
||
existing name may be referenced by the stored configuration and an upload is
|
||
not part of that transaction. `reserve_filename` *claims* the name as it picks
|
||
it (`O_CREAT | O_EXCL`) — asking `exists()` and returning a string let two
|
||
uploads agree on one name and quietly overwrite each other. It also budgets the
|
||
length so the result survives the sanitiser the content view applies to the
|
||
request, since a name the view rewrites is a file written and never served.
|
||
Streaming temporaries live in the files root under `.upload-`, are removed on
|
||
every exit path of the request (including cancellation, which is a
|
||
BaseException and slips past `except Exception`), and are swept at startup and
|
||
daily. That scheduled pass also runs the two collectors with the stored
|
||
configuration as *both* sides — nothing superseded, so every referenced file is
|
||
kept and only aged unreferenced ones go. Without it, collection would only ever
|
||
happen when somebody saves, and a file uploaded into a dialog that was then
|
||
cancelled would wait for a write that may never come. A new icon has no id yet, so its
|
||
files go to a per-dialog staging folder and move to the real id once the config
|
||
write is accepted — the same copy → save → cleanup order as a rebind.
|
||
`config/set` collects what its commit superseded — that much a commit knows for
|
||
certain. *Unreferenced* is a far weaker signal, and the policy follows from one
|
||
asymmetry: **a few unnecessary megabytes can always be removed by hand; a file
|
||
we should not have removed cannot be brought back.** When the evidence is weak,
|
||
keep the file. Owner's decision, 2026-07-28, after the one-hour rule applied to
|
||
every unreferenced file destroyed two detached plans.
|
||
|
||
The classification is by **owner**, not by "is it referenced". A file leaving
|
||
the configuration looks identical whether the plan was replaced, detached, or
|
||
its space deleted — and only the first is a deletion the user asked for. Reading
|
||
`old_refs - new_refs` and calling it "superseded" deleted a plan the moment it
|
||
was detached, under documentation promising the opposite (HP-1465-01).
|
||
|
||
| Case | What it means | Rule |
|
||
|---|---|---|
|
||
| Space in both, plan A → plan B | the user picked another image | removed immediately |
|
||
| Space in both, plan → none | detached; one click undoes it | **kept** |
|
||
| Space gone | deliberate, but the image was imported and may be nowhere else | **kept** |
|
||
| Space has a plan, plus another file of its own | an upload whose save was rejected | `PLAN_ORPHAN_TTL_S` (1 h) |
|
||
| Marker in both, attachment dropped from its list | a trash button, promising nothing | removed immediately |
|
||
| Marker gone | same call as a deleted space's plan | **kept** |
|
||
| Attachment in `up_*` | a dialog that was never saved; no device owns it | `PLAN_ORPHAN_TTL_S` (1 h) |
|
||
| Marker there, file it never listed | a rejected upload | `SCHEDULED_GRACE_S` (30 d) |
|
||
|
||
**Config writes are serialized** (HP-1454-03). `_writeConfig()` chains onto a
|
||
single promise: one `config/set` in flight, each carrying the revision the
|
||
previous one returned. The debounce still spaces out *when* a write starts;
|
||
what it cannot do — and used to be relied on for — is keep two writes from
|
||
overlapping, which produced a self-inflicted conflict and lost the newer edit.
|
||
|
||
**Plan uploads are copy-on-write, and collection belongs to the commit**
|
||
(reviews R2-1, R3-1). The file system is not part of the config's
|
||
optimistic-locking transaction, so nothing referenced may be overwritten or
|
||
deleted before the CAS succeeds: the upload writes a new versioned name and
|
||
removes nothing. Deciding what may then go is *not* a client's call — a cleanup
|
||
request cannot be ordered against another client's commit, and a delayed one
|
||
deletes a plan that was just saved. So `config/set` collects itself, inside its
|
||
write lock, from the pair of configurations that bracket the commit
|
||
(`plans.collect_plans`): superseded files go immediately, other unreferenced
|
||
uploads only once `PLAN_ORPHAN_TTL_S` has passed, since a fresh one may belong
|
||
to a transaction still in flight. The `.` between id and token is load-bearing —
|
||
a space id cannot contain one, so `<space>.<token>.<ext>` can never be confused
|
||
with the files of a space whose name merely starts the same way.
|
||
|
||
**Signed content urls are batched, aged and deduplicated** (reviews R2-2, R3-2, R4-2). `ContentSigner`
|
||
in `src/signing.ts` is the single implementation, used by both cards; the
|
||
duplicate inside houseplan-space-card signed correctly and never handed the
|
||
result to its renderer, which is the failure mode a second copy invites. `MAX_SIGN_PATHS`
|
||
(200) is a shared contract between `logic.ts` and `const.py`: the backend caps a
|
||
request there and says nothing about the rest, so the card must chunk. Cached
|
||
signatures carry the time they were issued — an aging one keeps rendering while
|
||
its replacement is fetched, an expired one is dropped rather than served (it
|
||
would 401 and raise a failed-login warning). The cache is pruned to the urls the
|
||
live config references, so it cannot grow past the cap through history alone.
|
||
Queued and in-flight are distinct states: a render happening while a request is
|
||
out must not queue the same url again, a failure backs off rather than retrying
|
||
on the next frame, and an in-flight entry expires after `SIGN_INFLIGHT_MS` so a
|
||
promise that never settles cannot block retries forever.
|
||
|
||
**Room climate is one pass per hass snapshot** (review R2-3). `areaClimateMap()`
|
||
classifies the whole registry once and returns `Map<area, {temp, hum}>`; the
|
||
card memoizes it on `hass` identity, which Home Assistant replaces on every
|
||
state change. Per-room lookups are O(1). `areaClimate()` survives as a
|
||
single-area wrapper for tests — using it in a render reintroduces the
|
||
O(rooms × entities) cost it was extracted from.
|
||
|
||
**File uploads go over HTTP** (not WS, which has a message-size limit): `POST /api/houseplan/upload`
|
||
(multipart: marker_id + file), HomeAssistantView, requires_auth. Served from `/houseplan_files/files/`.
|
||
|
||
|
||
## Second card: houseplan-space-card (read-only, v1.16.0)
|
||
|
||
The bundle registers **two** custom elements from one entry (`src/houseplan-card.ts`
|
||
imports `./space-card`):
|
||
|
||
- `houseplan-card` — the full interactive card.
|
||
- `houseplan-space-card` — a static, read-only schematic of ONE space for embedding.
|
||
|
||
Shared, framework-light modules keep the two views from diverging:
|
||
|
||
- `src/space-geometry.ts` — pure model/position math (`spaceModels`, `roomBounds`,
|
||
`roomCenter`, `defaultPositions`, `markerPos`, `labelPos`; no Lit import) — unit-tested,
|
||
mirrors the full card's private geometry.
|
||
- `src/space-render.ts` — `renderSpaceStatic()` draws the plan + configured room
|
||
borders/names + device markers (via `buildDevices`, same curation) with NO handlers,
|
||
NO live states, NO status/temperature fills. Uses the same CSS classes as the full card
|
||
(the space-card imports `cardStyles`) for visual parity.
|
||
- `src/config-store.ts` — module-level `{config, rev, layout}` cache shared by all embedded
|
||
cards (dedupes `houseplan/config/get`), seeded synchronously from the full card's
|
||
localStorage snapshot (`houseplan_card_cfg_v1`) and invalidated on `houseplan_config_updated`.
|
||
|
||
**Static contract:** the schematic layer (`.hp-static-stage`) is `pointer-events:none`; the
|
||
footer button lives outside it and stays clickable.
|
||
|
||
**Deep-link contract:** the footer button calls `navigate(button_target + "#space=<id>")`
|
||
(default target `/plan-doma`). The full card reads `#space=<id>` on load (a valid id wins over
|
||
`default_floor`) and on `hashchange`, without blocking manual space switching; an invalid/absent
|
||
hash falls back to the default.
|
||
|
||
|
||
## Additions v1.28–v1.41 (2026-07-24)
|
||
|
||
- **Decor layer** (`space.decor[]`, v1.33): purely visual shapes
|
||
(line/rect/ellipse/text, normalized coords, per-shape style) drawn in the
|
||
SVG right above the background image and BELOW rooms; pointer-events only
|
||
inside the Background editor. Validated by `DECOR_SCHEMA` (vol.Any of the
|
||
four kinds).
|
||
- **Glow fill** (v1.35+): `fill_mode: 'glow'` paints every room with
|
||
`fill_colors.glow_base` and renders per-source radial gradients clipped by
|
||
a per-light `clipPath` = zone polygons + doorway sectors, each contour a
|
||
SEPARATE clipPath child (children union; subpaths of one nonzero path
|
||
cancel on opposite windings — field bug v1.36.3). Radius: global
|
||
`settings.glow_radius_cm` + per-marker `glow_radius_cm`.
|
||
- **Open boundaries** (v1.37): `room.open_to: [roomId]` symmetric links;
|
||
light zones are connected components (`openZoneOf`); shared stretches
|
||
computed by collinear-overlap math (`sharedBoundary`) and drawn as a TRUE
|
||
dash — both room outlines and derived wall segments are trimmed
|
||
(`outlineWithout`/`cutSegments`).
|
||
- **Marker controls** (v1.36): `marker.controls[]` (lights/switches only)
|
||
toggled as one HA-group-semantics service call on the marker's EXPLICIT
|
||
tap_action=toggle; icon state/tint mirrors the targets. `primaryEntity`
|
||
picks in tiers so domain priority beats registry `hidden` (grouped lamps).
|
||
- **Island rooms** (v1.34): full nesting is legal (`polyContainsPoly`);
|
||
parents render as evenodd paths with holes (`islandsOf`).
|
||
- **Kiosk mode** (v1.41): a card-config flag, not a mode — `_setMode` is
|
||
hard-blocked, header hidden, swipe/carousel handled in the stage pointer
|
||
pipeline (`swipeTarget`), per-screen multipliers in `LS_KIOSK`.
|
||
- **Nav persistence** (v1.38.2): `LS_NAV` stores {space, mode}; hash
|
||
deep-link > saved > default_floor; stale-cache retry after the live load.
|
||
|
||
|
||
## 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 overrides the more general one; "unset" always means
|
||
"inherit". Resolution lives in pure helpers (`spaceDisplayOf`,
|
||
`roomFillModeOf`, `sourceValue`, `resolveTapAction`) — never inline in render.
|
||
The UI will later be unified around this model; until then each tier keeps its
|
||
own dialog (general settings gear / space gear / room-card gear / marker
|
||
dialog).
|
||
|
||
|
||
## Audit follow-ups (2026-07-27)
|
||
|
||
- **Content is authenticated.** `/houseplan_files/…` now serves ONLY the card
|
||
bundle (a Lovelace resource must be public). Plans and marker files go
|
||
through `HouseplanContentView` (`/api/houseplan/content/<plans|files>/…`,
|
||
`requires_auth`). `contentUrl()` rewrites legacy stored URLs on read, so no
|
||
storage migration is needed. Static paths cannot be unregistered — the old
|
||
routes survive until the next HA restart.
|
||
- **Optimistic UI, stated explicitly (audit L7).** `_serverCfg` is mutated in
|
||
place before a fallible save in ~22 places and there is no rollback: after a
|
||
rejected save the UI shows the edit until the next reload. This is a
|
||
deliberate optimistic-UI choice, not drift. Paths where it is unacceptable
|
||
need their own rollback.
|
||
- **Split invariant.** `splitRoomPath` guarantees a partition: the two parts'
|
||
areas sum to the original (within epsilon) or the cut is rejected.
|