feat(backend): LED strips are stored, normalised and transferred (#780)

Stage 1 of #780 — the data model. A space carries an optional
`led_strips: [{id, points, marker, active?}]` (custom_components/houseplan/
led_strips.py, pure, strict mypy).

- Type schema inside SPACE_SCHEMA: 2–50 finite numeric points (no strings,
  booleans, NaN or off-canvas values), ≤50 strips per space including hidden
  shapes, strict boolean `active`, marker = non-empty string or null.
- A config-level step after coordinate canonicalisation judges the shape
  (two distinct points, non-zero length, a closed strip needs three distinct
  vertices, a hidden shape needs a marker, unique ids per space) and the links
  in the order the spec fixes: duplicates are rejected before any
  normalisation (two links to the same missing id still conflict); a link to
  a marker that is not live becomes an unbound strip (marker null, active
  true, id and points kept), so a client that does not know strips can delete
  a bound marker without its save failing; a live marker with an empty space
  adopts the strip's space; a non-empty foreign space rejects the write.
- config/set answers with `led_strips: {unbound, space_adopted}` when the
  write was normalised, so a new client re-reads; old clients ignore it.
- Space import remaps links through the marker id map; a skipped or
  virtualised duplicate leaves the strip unbound; a coinciding old id never
  binds. Plan-only export keeps geometry and nulls every link. Import details
  report `unbound_led_strips`, computed by the server, never read from the file.
- Coordinates get JSON-noise cleanup only, like stairs (face contacts are
  off-lattice), in both canonicalisers with a shared fixture case.
- Support package: counters only (total/unbound/hidden), no coordinates or ids.

Tests: tests_backend/test_led_strips.py (41, pure), test_ha_import_export
(6 cases: full round trip with a hidden shape, orphan count, remap against a
coinciding id, skip and virtual duplicates, plan-only), test_ha_websocket
(old client deletes a bound marker → save stands, counters, foreign space
rejects without a new revision). Full backend with the HA harness: 969 passed.
Mutating the shape check, the duplicate check, the orphan normalisation or the
space adoption each turns the pure suite red.

Issue: #780
User-Visible: no
This commit is contained in:
claude[bot]
2026-10-02 20:54:49 +03:00
committed by Codex
parent 9fa5efbdac
commit 870237f3fb
13 changed files with 875 additions and 1 deletions
+27
View File
@@ -888,6 +888,33 @@
"required": true,
"type": "str"
},
"config.spaces[].led_strips": {
"required": false
},
"config.spaces[].led_strips[]": {
"allowExtra": true
},
"config.spaces[].led_strips[].active": {
"opaque": "<function _strict_bool>",
"required": false
},
"config.spaces[].led_strips[].id": {
"opaque": "<function _strip_id>",
"required": true
},
"config.spaces[].led_strips[].marker": {
"default": {
"factory": "NoneType"
},
"opaque": "<function _marker_ref>",
"required": false
},
"config.spaces[].led_strips[].points": {
"required": true
},
"config.spaces[].led_strips[].points[][]": {
"opaque": "<function _coordinate>"
},
"config.spaces[].open_spans": {
"required": false
},