Files
houseplan-card/docs/ARCHITECTURE.md
T
Matysh 65268a7985 feat v1.19.0: a line is never an entity of its own — walls are derived from rooms
A wall can only exist as an edge of a closed room:
- roomEdges(rooms) derives walls from room outlines, deduping shared ones, so deleting a
  room keeps the borders its neighbours still contribute and drops the rest — the rule
  falls out of the model instead of needing bookkeeping.
- Nothing is persisted while drawing: an outline you never close leaves no lines behind
  (previously every click pair was written to space.segments immediately).
- The 'Erase line' tool is gone; space.segments is stripped on every save (validation
  still tolerates it on read so a stale tab cannot fail a save).
- Dead code out: _addSegment/_removeSegmentByKey/_distToSeg/_pathSegs/_segKey.
Docs (ARCHITECTURE/TESTING/CHANGELOG) updated in the same commit. +2 tests (63).
2026-07-16 07:27:23 +03:00

10 KiB
Raw Permalink Blame History

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+)

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):

{ "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.

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 (_fmtLensegmentCm/ 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).

Integration WS API

Command Parameters Response
houseplan/layout/get {layout: {device_id: {x,y}}}
houseplan/layout/set layout {ok} (admin_only optional)
houseplan/layout/update device_id, pos {ok}
houseplan/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}
houseplan/file/set marker_id, filename, data (b64) {ok,url,name} (legacy, WS limit)

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.tsrenderSpaceStatic() 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.