Files
houseplan-card/custom_components/houseplan/coordinate_canonicalization.py
T
claude[bot]andCodex 870237f3fb 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
2026-10-02 20:54:49 +03:00

205 lines
6.8 KiB
Python

"""Lossless, allow-listed canonicalisation of persisted geometry.
The frontend mirrors this module in src/coordinate-canonicalization.ts.
Keep the precision, lattice formula and field allow-list in lockstep; a shared
fixture is exercised by both runtimes.
"""
from __future__ import annotations
import copy
import math
from typing import Any
COORDINATE_DECIMALS = 9
COORDINATE_FACTOR = 10**COORDINATE_DECIMALS
LATTICE_GRID_N = 240
LATTICE_NOISE_STEPS = 1e-4
DECOR_BOX_KINDS = ("rect", "ellipse", "furniture", "image")
def canonicalize_number(value: Any) -> Any:
"""Return one stable IEEE-754 representation for an allow-listed scalar."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return value
number = float(value)
if not math.isfinite(number):
return value
sign = -1.0 if math.copysign(1.0, number) < 0 else 1.0
result = sign * (
math.floor(abs(number) * COORDINATE_FACTOR + 0.5)
/ COORDINATE_FACTOR
)
if result == 0:
return 0.0
return result
def canonicalize_lattice_coordinate(value: Any) -> Any:
"""Collapse near-node noise while preserving authored off-grid values."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return value
number = float(value)
if not math.isfinite(number):
return value
scaled = number * LATTICE_GRID_N
# JavaScript Math.round: ties go toward +infinity, unlike Python round().
nearest = math.floor(scaled + 0.5)
if abs(scaled - nearest) < LATTICE_NOISE_STEPS:
result = nearest / LATTICE_GRID_N
return 0.0 if result == 0 else result
return canonicalize_number(number)
def _record(value: Any) -> dict[str, Any] | None:
return value if isinstance(value, dict) else None
def _records(value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list):
return []
return [item for item in value if isinstance(item, dict)]
def _scalar_fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
for name in names:
if name in record:
record[name] = canonicalize_number(record[name])
def _lattice_fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
for name in names:
if name in record:
record[name] = canonicalize_lattice_coordinate(record[name])
def _lattice_point(value: Any) -> None:
if not isinstance(value, list):
return
for index in range(min(2, len(value))):
value[index] = canonicalize_lattice_coordinate(value[index])
def _lattice_points(value: Any) -> None:
if not isinstance(value, list):
return
for point in value:
_lattice_point(point)
def _scalar_points(value: Any) -> None:
if not isinstance(value, list):
return
for point in value:
if not isinstance(point, list):
continue
for index in range(min(2, len(point))):
point[index] = canonicalize_number(point[index])
def canonicalize_position(position: Any) -> Any:
"""Canonicalise lattice x/y in one layout record, preserving metadata."""
result = copy.deepcopy(position)
record = _record(result)
if record is not None:
_lattice_fields(record, ("x", "y"))
return result
def canonicalize_layout_geometry(layout: Any) -> Any:
"""Canonicalise lattice x/y in every layout record."""
result = copy.deepcopy(layout)
record = _record(result)
if record is None:
return result
for position in record.values():
item = _record(position)
if item is not None:
_lattice_fields(item, ("x", "y"))
return result
def canonicalize_config_geometry(config: Any) -> Any:
"""Canonicalise only the named persisted geometry fields."""
result = copy.deepcopy(config)
root = _record(result)
if root is None:
return result
for space in _records(root.get("spaces")):
_scalar_fields(
space,
(
"plan_x",
"plan_y",
"plan_scale",
"plan_scale_x",
"plan_scale_y",
"plan_angle",
),
)
for room in _records(space.get("rooms")):
_lattice_fields(room, ("x", "y", "w", "h"))
_lattice_points(room.get("poly"))
for wall in _records(space.get("walls")):
_lattice_point(wall.get("a"))
_lattice_point(wall.get("b"))
for segment in _records(space.get("wall_segments")):
_lattice_point(segment.get("a"))
_lattice_point(segment.get("b"))
for opening in _records(space.get("openings")):
_lattice_fields(opening, ("x", "y"))
_scalar_fields(opening, ("angle", "length"))
host = _record(opening.get("host"))
if host is not None:
_scalar_fields(host, ("t",))
for decor in _records(space.get("decor")):
kind = decor.get("kind")
if kind == "line":
_lattice_fields(decor, ("x1", "y1", "x2", "y2"))
elif kind in DECOR_BOX_KINDS:
_lattice_fields(decor, ("x", "y", "w", "h"))
_scalar_fields(decor, ("angle",))
elif kind == "text":
_lattice_fields(decor, ("x", "y"))
_scalar_fields(decor, ("scale", "angle"))
for stair in _records(space.get("stairs")):
# #663: wall/stair magnet placement is continuous like furniture.
# Scalar cleanup removes JSON tails without moving an authored
# physical-face contact onto the plan grid.
_scalar_fields(stair, ("x", "y", "length", "width", "radius", "angle"))
# Legacy-only read boundary: v9 drafts are canonicalized before the
# wall-model migration turns their edges into current partitions.
for draft in _records(space.get("room_drafts")):
_lattice_points(draft.get("points"))
for partition in _records(space.get("partitions")):
_lattice_point(partition.get("a"))
_lattice_point(partition.get("b"))
for column in _records(space.get("wall_columns")):
_lattice_point(column.get("center"))
if column.get("shape") == "square":
_scalar_fields(column, ("angle",))
for span in _records(space.get("open_spans")):
_lattice_point(span.get("a"))
_lattice_point(span.get("b"))
for strip in _records(space.get("led_strips")):
# #780: LED strips snap to physical wall faces, like stairs and
# furniture: remove JSON noise only, never pull a face contact
# onto a lattice node.
_scalar_points(strip.get("points"))
for marker in _records(root.get("markers")):
_scalar_fields(marker, ("angle",))
return result