Files
houseplan-card/custom_components/houseplan/validation.py
Codex 49912ac989 fix(plan): legacy room drafts are healed, not a locked door
A plan that still carried a `room_drafts` key while already on the
current wall model could not be edited at all. The card mirrors the same
migration, so a structural edit was refused before the request ever left
the browser; the toast sent the user to "Optimize plans", which reports
that everything is already optimal because it looks at something else
entirely; and the export path calls the same migration, so the one way
out — take a backup, fix the file by hand — was shut too. An empty
`room_drafts: []`, carrying no data at all, was enough to do it.

The carrier is now removed the way the first migration removes it: an
empty key silently, drafts converted one for one into partitions. The
#478 protection against a stale client re-adding the carrier moves to
the layer that can actually tell the two apart —
`validate_wall_model_transition` sees both the submission and the stored
plan, and refuses when the drafts appear over a plan that does not have
them. It no longer keys on the submitted model number: a stale card
echoes back the number it was given, which is exactly how the outdated
client slipped past this guard and met "conflicting wall identifiers"
instead of "update the card and reload the page". The schema invariant
keeps refusing a non-empty carrier as the last line.

Both mirrors change together and stay identical; the parity fixture is
untouched.

Issue: #529
User-Visible: yes
2026-09-11 11:19:04 +03:00

2238 lines
101 KiB
Python

"""Pure House Plan validation and sanitizers — no Home Assistant dependencies.
Kept separate so it can be covered by unit tests (only voluptuous is needed).
"""
from __future__ import annotations
import copy
import json
import math
import re
from collections import Counter
import voluptuous as vol
from custom_components.houseplan.coordinate_canonicalization import (
canonicalize_config_geometry,
canonicalize_layout_geometry,
canonicalize_position,
)
from custom_components.houseplan.vacuum_routes import validate_marker_routes
# ---------- limits and extension sets ----------
PLAN_EXTENSIONS = {"svg": "image/svg+xml", "png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
MAX_PLAN_BYTES = 8 * 1024 * 1024
FILE_EXTENSIONS = {"pdf", "png", "jpg", "jpeg", "webp", "txt"}
MAX_FILE_BYTES = 50 * 1024 * 1024
SPACE_ID_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
# The name length the content view will accept back in a request. Anything a
# generated name must fit inside, collision tag included (HP-1460-01).
MAX_FILENAME = 120
MARKER_CONTROL_PREFIX = "marker:"
_CONTROL_ENTITY_ID_RE = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+\Z")
class MarkerControlError(ValueError):
"""Semantic marker-link error with a stable public code."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
class OpeningPassageError(ValueError):
"""Semantic open-passage error with a stable public code and payload."""
code = "invalid_passage_fields"
def __init__(self, space_id: str, opening_id: str, fields: list[str]) -> None:
self.space_id = space_id
self.opening_id = opening_id
self.fields = tuple(sorted(fields))
# #42: structured details as JSON — the frontend parses this instead
# of regexing an English sentence; the legacy "space=..;.." format is
# still accepted there for one beta.
super().__init__(json.dumps({
"space": space_id, "opening": opening_id, "fields": list(self.fields),
}, ensure_ascii=False))
class PartitionOpeningHostError(ValueError):
"""A write tried to strip explicit host identity from a surviving opening."""
code = "invalid_partition_opening_host"
class PartitionOpeningJambMarginError(ValueError):
"""A direct geometry write leaves no physical jamb at a wall endpoint."""
code = "invalid_partition_opening_jamb_margin"
def __init__(
self, space_id: str, opening_id: str, margin: float, margin_cm: float
) -> None:
self.space_id = space_id
self.opening_id = opening_id
self.margin = margin
self.margin_cm = margin_cm
# #42: structured details as JSON (see OpeningPassageError).
super().__init__(json.dumps({
"space": space_id, "opening": opening_id,
"margin": round(margin, 12), "margin_cm": round(margin_cm, 12),
}, ensure_ascii=False))
class WallModelClientOutdatedError(ValueError):
"""A stale client tried to replace an already-migrated v8 document."""
code = "wall_model_client_outdated"
def _legacy_wall_model_projection(config: dict) -> dict:
"""Structural view an older client can faithfully round-trip."""
projected = copy.deepcopy(config)
projected.pop("model_version", None)
for space in projected.get("spaces") or []:
space.pop("wall_segments", None)
for room in space.get("rooms") or []:
room.pop("wall_ids", None)
for draft in space.get("room_drafts") or []:
for segment in draft.get("segments") or []:
segment.pop("id", None)
for opening in space.get("openings") or []:
host = opening.get("host")
if isinstance(host, dict) and host.get("kind") == "wall":
opening.pop("host", None)
return projected
def _catalog_coupled_wall_geometry_projection(config: dict) -> list[dict]:
"""Legacy geometry whose edit must also update the v8 contour catalogue.
Drafts, partitions and columns carry their own ids. Openings carry an
explicit v8 host and are checked against it by CONFIG_SCHEMA. None of those
independent objects requires a different contour wall_segments catalogue.
"""
result: list[dict] = []
for space in config.get("spaces") or []:
rooms = []
for room in space.get("rooms") or []:
rooms.append({key: copy.deepcopy(room.get(key)) for key in (
"id", "poly", "x", "y", "w", "h", "open_to"
) if key in room})
result.append({
"id": space.get("id"),
"rooms": rooms,
"walls": copy.deepcopy(space.get("walls") or []),
"open_spans": copy.deepcopy(space.get("open_spans") or []),
})
return result
def _wall_catalog_projection(config: dict) -> list[dict]:
"""Authoritative part a current v8 structural writer must update."""
return [{
"id": space.get("id"),
"wall_segments": copy.deepcopy(space.get("wall_segments")),
} for space in config.get("spaces") or []]
def _restore_wall_model_fields(config: dict, previous: dict) -> None:
"""Hydrate only v8 identity fields after an exact legacy round-trip."""
config["model_version"] = previous["model_version"]
old_spaces = {str(space.get("id")): space for space in previous.get("spaces") or []}
for space in config.get("spaces") or []:
old_space = old_spaces.get(str(space.get("id")))
if old_space is None:
continue
space["wall_segments"] = copy.deepcopy(old_space.get("wall_segments") or [])
old_rooms = {str(room.get("id")): room for room in old_space.get("rooms") or []}
for room in space.get("rooms") or []:
old_room = old_rooms.get(str(room.get("id")))
if old_room is not None and "wall_ids" in old_room:
room["wall_ids"] = copy.deepcopy(old_room["wall_ids"])
old_drafts = {str(draft.get("id")): draft for draft in old_space.get("room_drafts") or []}
for draft in space.get("room_drafts") or []:
old_draft = old_drafts.get(str(draft.get("id")))
if old_draft is None:
continue
old_segments = old_draft.get("segments") or []
for index, segment in enumerate(draft.get("segments") or []):
if index < len(old_segments) and old_segments[index].get("id"):
segment["id"] = old_segments[index]["id"]
old_openings = {
str(opening.get("id")): opening for opening in old_space.get("openings") or []
}
for opening in space.get("openings") or []:
old_opening = old_openings.get(str(opening.get("id")))
old_host = (old_opening or {}).get("host")
if isinstance(old_host, dict) and old_host.get("kind") == "wall":
opening["host"] = copy.deepcopy(old_host)
def validate_wall_model_transition(config: dict, previous: dict | None) -> None:
"""Do not let omission of model_version bypass v8 semantic validation."""
try:
old_model = int((previous or {}).get("model_version", 0))
new_model = int(config.get("model_version", 0))
except (TypeError, ValueError):
return # CONFIG_SCHEMA owns malformed values.
previous = previous or {}
stored_drafts = any(
"room_drafts" in space for space in previous.get("spaces") or []
if isinstance(space, dict)
)
if old_model >= 10 and not stored_drafts and any(
"room_drafts" in space for space in config.get("spaces") or []
if isinstance(space, dict)
):
# A v9 card can echo a cached draft without changing the contour
# projection at all. The old catalogue guard therefore cannot see the
# destructive downgrade, but v10 must never persist the removed
# carrier again (#478 AC2).
#
# #529: the submitted model number is not the tell. A stale card
# echoes back the `model_version` it was given — the current one — so
# the old `new_model < 10` condition let exactly the outdated client
# through and the user met an unrelated "conflicting wall identifiers"
# instead of "update the card and reload the page". What identifies the
# stale writer is the carrier itself appearing over a stored plan that
# does not have it; legacy left in storage is not a stale writer and is
# healed by the migration instead (#529 K4).
raise WallModelClientOutdatedError(
f"stored model={old_model}; submitted legacy room_drafts model={new_model}"
)
contour_geometry_changed = (
_catalog_coupled_wall_geometry_projection(config)
!= _catalog_coupled_wall_geometry_projection(previous)
)
if old_model >= 8 and new_model < 8:
if not contour_geometry_changed:
_restore_wall_model_fields(config, previous or {})
# Re-run semantic parity after hydration; previous identity is
# accepted only when it still matches the submitted projections.
validated = CONFIG_SCHEMA(config)
config.clear()
config.update(validated)
return
raise WallModelClientOutdatedError(
f"stored model={old_model}; submitted model={new_model}"
)
if old_model >= 8 and 8 <= new_model <= old_model and contour_geometry_changed:
# The realistic stale-client case echoes model_version and the unknown
# catalogue verbatim while changing room/contour geometry. Let the
# frontend show the dedicated reload guidance instead of a generic
# schema error. Independent drafts, partitions, columns and explicitly
# hosted openings are validated by CONFIG_SCHEMA without requiring a
# contour catalogue change (#314).
#
# A submitted model ABOVE the stored one is excluded on purpose (#319):
# a stale client can only echo the stored version, never raise it. The
# first write of a newer client legitimately drops legacy projection
# fields (an orphan open_span/open_to) without touching the catalogue;
# treating that as "outdated" wedged every structural write forever.
if _wall_catalog_projection(config) == _wall_catalog_projection(previous):
raise WallModelClientOutdatedError(
f"stored model={old_model}; unchanged wall catalogue"
)
# One normalized canvas width contains this many physical grid cells. Keep in
# sync with GRID_STEP_N/NORM_W in the frontend; it is a geometry scale, not a
# user setting.
NORMALIZED_CANVAS_CELLS = 240.0
_DEFAULT_ROOM_WALL_CM = 15.0
_OPTIMIZE_REHOST_EPSILON = 1e-8
def _room_polygon(room: dict) -> list[list[float]]:
poly = room.get("poly")
if isinstance(poly, list) and len(poly) >= 3:
return poly
if all(key in room for key in ("x", "y", "w", "h")):
x, y = float(room["x"]), float(room["y"])
w, h = float(room["w"]), float(room["h"])
return [[x, y], [x + w, y], [x + w, y + h], [x, y + h]]
return []
def _segment_metrics(a: list, b: list) -> tuple[float, float, float]:
dx, dy = float(b[0]) - float(a[0]), float(b[1]) - float(a[1])
return dx, dy, math.hypot(dx, dy)
def _line_distance(point: list, a: list, b: list) -> float:
dx, dy, length = _segment_metrics(a, b)
if length <= _OPTIMIZE_REHOST_EPSILON:
return math.inf
return abs((float(point[0]) - float(a[0])) * dy
- (float(point[1]) - float(a[1])) * dx) / length
def _projection(point: list, a: list, b: list) -> float:
dx, dy, length = _segment_metrics(a, b)
if length <= _OPTIMIZE_REHOST_EPSILON:
return math.inf
return ((float(point[0]) - float(a[0])) * dx
+ (float(point[1]) - float(a[1])) * dy) / length
def _segment_covers(a: list, b: list, target_a: list, target_b: list) -> bool:
"""Return whether one exact collinear segment covers the target."""
_, _, length = _segment_metrics(a, b)
if length <= _OPTIMIZE_REHOST_EPSILON:
return False
if (_line_distance(target_a, a, b) > _OPTIMIZE_REHOST_EPSILON
or _line_distance(target_b, a, b) > _OPTIMIZE_REHOST_EPSILON):
return False
for point in (target_a, target_b):
along = _projection(point, a, b)
if along < -_OPTIMIZE_REHOST_EPSILON or along > length + _OPTIMIZE_REHOST_EPSILON:
return False
return True
def _segments_overlap_on_axis(a: list, b: list, other_a: list, other_b: list) -> bool:
if (_line_distance(other_a, a, b) > _OPTIMIZE_REHOST_EPSILON
or _line_distance(other_b, a, b) > _OPTIMIZE_REHOST_EPSILON):
return False
_, _, length = _segment_metrics(a, b)
lo, hi = sorted((_projection(other_a, a, b), _projection(other_b, a, b)))
return min(length, hi) - max(0.0, lo) > _OPTIMIZE_REHOST_EPSILON
def _angle_delta_mod_180(first: float, second: float) -> float:
return abs((first - second + 90.0) % 180.0 - 90.0)
def _segments_cover_target(segments: list[tuple[list, list]], target_a: list, target_b: list) -> bool:
"""Whether a collinear union covers one complete positive target span."""
dx, dy, length = _segment_metrics(target_a, target_b)
if length <= _OPTIMIZE_REHOST_EPSILON:
return False
ranges = []
for a, b in segments:
if (_line_distance(a, target_a, target_b) > _OPTIMIZE_REHOST_EPSILON
or _line_distance(b, target_a, target_b) > _OPTIMIZE_REHOST_EPSILON):
continue
lo, hi = sorted((_projection(a, target_a, target_b),
_projection(b, target_a, target_b)))
lo, hi = max(0.0, lo), min(length, hi)
if hi - lo > _OPTIMIZE_REHOST_EPSILON:
ranges.append((lo, hi))
reached = 0.0
for lo, hi in sorted(ranges):
if lo > reached + _OPTIMIZE_REHOST_EPSILON:
return False
reached = max(reached, hi)
if reached >= length - _OPTIMIZE_REHOST_EPSILON:
return True
return False
_OPTIMIZE_PARTITION_KEYS = {"id", "a", "b", "cm"}
def _known_optimize_partition(partition: dict) -> bool:
return isinstance(partition, dict) and set(partition).issubset(_OPTIMIZE_PARTITION_KEYS)
def _axis_range(a: list, b: list, axis_a: list, axis_b: list) -> tuple[float, float] | None:
"""Project one positive collinear overlap onto ``axis_a -> axis_b``."""
if not (isinstance(a, list) and len(a) == 2
and isinstance(b, list) and len(b) == 2):
return None
if (_line_distance(a, axis_a, axis_b) > _OPTIMIZE_REHOST_EPSILON
or _line_distance(b, axis_a, axis_b) > _OPTIMIZE_REHOST_EPSILON):
return None
_, _, length = _segment_metrics(axis_a, axis_b)
lo, hi = sorted((_projection(a, axis_a, axis_b),
_projection(b, axis_a, axis_b)))
lo, hi = max(0.0, lo), min(length, hi)
return (lo, hi) if hi - lo > _OPTIMIZE_REHOST_EPSILON else None
def _axis_point(a: list, b: list, along: float) -> list[float]:
dx, dy, length = _segment_metrics(a, b)
return [float(a[0]) + dx / length * along,
float(a[1]) + dy / length * along]
def _optimize_partition_needs_proof(old_partition: dict, space: dict) -> bool:
"""Select #296 removal/splitting, not ordinary Optimize grid alignment."""
partition_id = str(old_partition.get("id", ""))
current = next((item for item in space.get("partitions") or []
if str(item.get("id", "")) == partition_id), None)
if current == old_partition:
return False
if current is None:
return True
if not (_known_optimize_partition(old_partition)
and _known_optimize_partition(current)):
return False
try:
if float(current.get("cm")) != float(old_partition.get("cm")):
return False
old_a, old_b = old_partition["a"], old_partition["b"]
current_range = _axis_range(current["a"], current["b"], old_a, old_b)
_, _, old_length = _segment_metrics(old_a, old_b)
except (KeyError, TypeError, ValueError):
return False
return current_range is not None and (
current_range[0] > _OPTIMIZE_REHOST_EPSILON
or current_range[1] < old_length - _OPTIMIZE_REHOST_EPSILON
)
def _safe_optimize_partition_delta(space: dict, old_partition: dict) -> bool:
"""Independently prove every removed atom of one old partition axis."""
if not _known_optimize_partition(old_partition):
return False
try:
old_a, old_b = old_partition["a"], old_partition["b"]
old_cm = float(old_partition["cm"])
_, _, length = _segment_metrics(old_a, old_b)
except (KeyError, TypeError, ValueError):
return False
if length <= _OPTIMIZE_REHOST_EPSILON or old_cm <= 0:
return False
rooms: list[tuple[str, list[tuple[list, list]]]] = []
for room in space.get("rooms") or []:
poly = _room_polygon(room)
if len(poly) < 3:
return False
rooms.append((str(room.get("id", "")), [
(poly[index], poly[(index + 1) % len(poly)])
for index in range(len(poly))
]))
candidate_partitions = space.get("partitions") or []
retained: list[tuple[float, float]] = []
breakpoints = [0.0, length]
def note_segment(a: list, b: list) -> tuple[float, float] | None:
projected = _axis_range(a, b, old_a, old_b)
if projected:
breakpoints.extend(projected)
return projected
for partition in candidate_partitions:
try:
projected = note_segment(partition["a"], partition["b"])
candidate_cm = float(partition["cm"])
except (KeyError, TypeError, ValueError):
return False
if projected and _known_optimize_partition(partition) and candidate_cm == old_cm:
retained.append(projected)
for _, edges in rooms:
for edge_a, edge_b in edges:
note_segment(edge_a, edge_b)
for collection in (space.get("walls") or [], space.get("open_spans") or []):
for segment in collection:
try:
note_segment(segment["a"], segment["b"])
except (KeyError, TypeError, ValueError):
return False
points = sorted(set(round(point, 12) for point in breakpoints
if -_OPTIMIZE_REHOST_EPSILON <= point
<= length + _OPTIMIZE_REHOST_EPSILON))
for lo, hi in zip(points, points[1:], strict=False): # #42 B905: adjacent pairs, unequal length is the point
if hi - lo <= _OPTIMIZE_REHOST_EPSILON:
continue
if any(start <= lo + _OPTIMIZE_REHOST_EPSILON
and end >= hi - _OPTIMIZE_REHOST_EPSILON
for start, end in retained):
continue
target_a, target_b = _axis_point(old_a, old_b, lo), _axis_point(old_a, old_b, hi)
owners = {room_id for room_id, edges in rooms
if _segments_cover_target(edges, target_a, target_b)}
collinear = {room_id for room_id, edges in rooms
if any(_segments_overlap_on_axis(
target_a, target_b, edge_a, edge_b
) for edge_a, edge_b in edges)}
if len(owners) not in (1, 2) or collinear != owners:
return False
if any(_segments_overlap_on_axis(
target_a, target_b, span["a"], span["b"]
) for span in space.get("open_spans") or []):
return False
if any(_segments_overlap_on_axis(
target_a, target_b, partition["a"], partition["b"]
) for partition in candidate_partitions):
return False
covering_walls = []
for wall in space.get("walls") or []:
try:
wall_a, wall_b = wall["a"], wall["b"]
if _segment_covers(wall_a, wall_b, target_a, target_b):
covering_walls.append((
_segment_metrics(wall_a, wall_b)[2], float(wall["cm"]),
))
except (KeyError, TypeError, ValueError):
return False
effective_cm = (min(covering_walls, key=lambda item: item[0])[1]
if covering_walls else _DEFAULT_ROOM_WALL_CM)
if effective_cm + _OPTIMIZE_REHOST_EPSILON < old_cm:
return False
return True
def _hosted_opening_geometry(opening: dict, partition: dict) -> tuple[float, float, float, float] | None:
host = opening.get("host")
try:
if not isinstance(host, dict) or host.get("kind") != "partition":
return None
t = float(host["t"])
a, b = partition["a"], partition["b"]
dx, dy, length = _segment_metrics(a, b)
opening_length = float(opening["length"])
except (KeyError, TypeError, ValueError):
return None
if not 0 <= t <= 1 or length <= _OPTIMIZE_REHOST_EPSILON or opening_length <= 0:
return None
angle = math.degrees(math.atan2(dy, dx))
if angle >= 90:
angle -= 180
elif angle < -90:
angle += 180
return float(a[0]) + dx * t, float(a[1]) + dy * t, angle, opening_length
def _safe_optimize_residual_rehost(
space: dict, old_space: dict, opening: dict, old_opening: dict,
) -> bool:
"""Prove a hosted opening rebound to a newly-created residual id."""
old_host, new_host = old_opening.get("host"), opening.get("host")
if not (isinstance(old_host, dict) and isinstance(new_host, dict)
and old_host.get("kind") == new_host.get("kind") == "partition"):
return False
old_partition = next((item for item in old_space.get("partitions") or []
if str(item.get("id", "")) == str(old_host.get("id", ""))), None)
new_partition = next((item for item in space.get("partitions") or []
if str(item.get("id", "")) == str(new_host.get("id", ""))), None)
if old_partition is None or new_partition is None:
return False
if not _safe_optimize_partition_delta(space, old_partition):
return False
ignored = {"host", "x", "y", "angle"}
if ({key: value for key, value in old_opening.items() if key not in ignored}
!= {key: value for key, value in opening.items() if key not in ignored}):
return False
old_geometry = _hosted_opening_geometry(old_opening, old_partition)
new_geometry = _hosted_opening_geometry(opening, new_partition)
if old_geometry is None or new_geometry is None:
return False
old_x, old_y, old_angle, old_length = old_geometry
new_x, new_y, new_angle, new_length = new_geometry
try:
explicit_x, explicit_y = float(opening["x"]), float(opening["y"])
explicit_angle = float(opening["angle"])
except (KeyError, TypeError, ValueError):
return False
return (
abs(old_x - new_x) <= _OPTIMIZE_REHOST_EPSILON
and abs(old_y - new_y) <= _OPTIMIZE_REHOST_EPSILON
and abs(old_length - new_length) <= _OPTIMIZE_REHOST_EPSILON
and _angle_delta_mod_180(old_angle, new_angle) <= 1e-7
and abs(explicit_x - new_x) <= _OPTIMIZE_REHOST_EPSILON
and abs(explicit_y - new_y) <= _OPTIMIZE_REHOST_EPSILON
and _angle_delta_mod_180(explicit_angle, new_angle) <= 1e-7
)
def _safe_optimize_partition_rehost(
space: dict, old_space: dict, opening: dict, old_opening: dict,
) -> bool:
"""Independently prove the #276/#296 partition-to-room-wall transition."""
old_host = old_opening.get("host")
if not isinstance(old_host, dict) or old_host.get("kind") != "partition":
return False
partition_id = str(old_host.get("id", ""))
old_partition = next((item for item in old_space.get("partitions") or []
if str(item.get("id", "")) == partition_id), None)
if old_partition is None:
return False
if not _safe_optimize_partition_delta(space, old_partition):
return False
a, b = old_partition.get("a"), old_partition.get("b")
if not (isinstance(a, list) and len(a) == 2 and isinstance(b, list) and len(b) == 2):
return False
dx, dy, length = _segment_metrics(a, b)
if length <= _OPTIMIZE_REHOST_EPSILON:
return False
ignored = {"host", "x", "y", "angle"}
old_stable = {key: value for key, value in old_opening.items() if key not in ignored}
new_stable = {key: value for key, value in opening.items() if key not in ignored}
if old_stable != new_stable:
return False
try:
t = float(old_host["t"])
x, y = float(opening["x"]), float(opening["y"])
opening_angle = float(opening["angle"])
opening_length = float(opening["length"])
except (KeyError, TypeError, ValueError):
return False
if not (0 <= t <= 1 and opening_length > 0):
return False
expected_x, expected_y = float(a[0]) + dx * t, float(a[1]) + dy * t
expected_angle = math.degrees(math.atan2(dy, dx))
if expected_angle >= 90:
expected_angle -= 180
elif expected_angle < -90:
expected_angle += 180
if (abs(x - expected_x) > _OPTIMIZE_REHOST_EPSILON
or abs(y - expected_y) > _OPTIMIZE_REHOST_EPSILON
or _angle_delta_mod_180(opening_angle, expected_angle) > 1e-7):
return False
along = t * length
if (along - opening_length / 2 < -_OPTIMIZE_REHOST_EPSILON
or along + opening_length / 2 > length + _OPTIMIZE_REHOST_EPSILON):
return False
half_dx, half_dy = dx / length * opening_length / 2, dy / length * opening_length / 2
target_a = [expected_x - half_dx, expected_y - half_dy]
target_b = [expected_x + half_dx, expected_y + half_dy]
owners: set[str] = set()
collinear_rooms: set[str] = set()
for room in space.get("rooms") or []:
poly = _room_polygon(room)
room_id = str(room.get("id", ""))
edges = [(poly[index], poly[(index + 1) % len(poly)])
for index in range(len(poly))]
if _segments_cover_target(edges, target_a, target_b):
owners.add(room_id)
if any(_segments_overlap_on_axis(target_a, target_b, edge_a, edge_b)
for edge_a, edge_b in edges):
collinear_rooms.add(room_id)
if len(owners) not in (1, 2) or collinear_rooms != owners:
return False
if any(_segments_overlap_on_axis(target_a, target_b, span["a"], span["b"])
for span in space.get("open_spans") or []):
return False
if any(_segments_overlap_on_axis(target_a, target_b, item["a"], item["b"])
for item in space.get("partitions") or []):
return False
covering_walls = []
for wall in space.get("walls") or []:
wall_a, wall_b = wall.get("a"), wall.get("b")
if not (isinstance(wall_a, list) and isinstance(wall_b, list)):
continue
if _segment_covers(wall_a, wall_b, target_a, target_b):
covering_walls.append((_segment_metrics(wall_a, wall_b)[2], float(wall["cm"])))
effective_cm = min(covering_walls, key=lambda item: item[0])[1] \
if covering_walls else _DEFAULT_ROOM_WALL_CM
if effective_cm + _OPTIMIZE_REHOST_EPSILON < float(old_partition.get("cm", 0)):
return False
for other in space.get("openings") or []:
if other is opening or str(other.get("id", "")) == str(opening.get("id", "")):
continue
try:
center = [float(other["x"]), float(other["y"])]
other_angle = float(other["angle"])
other_length = float(other["length"])
except (KeyError, TypeError, ValueError):
continue
if (_line_distance(center, a, b) > _OPTIMIZE_REHOST_EPSILON
or _angle_delta_mod_180(other_angle, expected_angle) > 1e-7):
continue
other_along = _projection(center, a, b)
if (min(along + opening_length / 2, other_along + other_length / 2)
- max(along - opening_length / 2, other_along - other_length / 2)
> _OPTIMIZE_REHOST_EPSILON):
return False
return True
def validate_partition_opening_hosts(
config: dict, previous: dict | None = None, *, allow_optimize_rehost: bool = False
) -> None:
"""Validate hosted-opening write deltas without rejecting legacy reads.
Deleting the opening together with its partition remains valid. Surviving
records keep their host, while new/direct geometry changes reserve half the
actual wall depth at both endpoints. Rigid host translation and unrelated
edits round-trip existing near-end records unchanged.
"""
old_spaces = {
str(space.get("id")): space for space in (previous or {}).get("spaces") or []
}
for space in config.get("spaces") or []:
space_id = str(space.get("id", ""))
old_space = old_spaces.get(space_id)
old_openings = {
str(opening.get("id")): opening
for opening in (old_space or {}).get("openings") or []
}
partitions = {
str(partition.get("id")): partition
for partition in space.get("partitions") or []
}
old_partitions = {
str(partition.get("id")): partition
for partition in (old_space or {}).get("partitions") or []
}
for opening in space.get("openings") or []:
opening_id = str(opening.get("id", ""))
old = old_openings.get(opening_id)
old_host = (old or {}).get("host")
host = opening.get("host")
if (old and isinstance(old_host, dict)
and old_host.get("kind") == "partition"):
same_partition = (
isinstance(host, dict) and host.get("kind") == "partition"
and str(host.get("id", "")) == str(old_host.get("id", ""))
)
residual_rehost = (
isinstance(host, dict) and host.get("kind") == "partition"
and not same_partition
)
proved = same_partition or (
allow_optimize_rehost and old_space is not None
and (_safe_optimize_residual_rehost(
space, old_space, opening, old
) if residual_rehost else _safe_optimize_partition_rehost(
space, old_space, opening, old
))
)
if not proved:
raise PartitionOpeningHostError(
f"space={space_id}; opening={opening_id}; host changed"
)
if host is None:
continue
if host.get("kind") != "partition":
continue
partition = partitions.get(str(host.get("id", "")))
if partition is None:
# SPACE_SCHEMA owns missing-host diagnostics.
continue
old_partition = old_partitions.get(str((old_host or {}).get("id", "")))
ax, ay = partition["a"]
bx, by = partition["b"]
span = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5
old_span = None
if old_partition is not None:
old_ax, old_ay = old_partition["a"]
old_bx, old_by = old_partition["b"]
old_span = ((old_bx - old_ax) ** 2 + (old_by - old_ay) ** 2) ** 0.5
strict = (
old is None
or old_host is None
or old_host.get("id") != host.get("id")
or old_host.get("t") != host.get("t")
or old.get("length") != opening.get("length")
or old_partition is None
or old_partition.get("cm") != partition.get("cm")
or abs(old_span - span) > 1e-9
)
if not strict:
continue
cell_cm = float(space.get("cell_cm", 5))
margin_cm = float(partition["cm"]) / 2
margin = margin_cm / cell_cm / NORMALIZED_CANVAS_CELLS
along = float(host["t"]) * span
half = float(opening["length"]) / 2
if (along - half < margin - 1e-9
or along + half > span - margin + 1e-9):
raise PartitionOpeningJambMarginError(
space_id, opening_id, margin, margin_cm
)
if allow_optimize_rehost and old_space:
for old_partition in old_space.get("partitions") or []:
if (_optimize_partition_needs_proof(old_partition, space)
and not _safe_optimize_partition_delta(space, old_partition)):
raise PartitionOpeningHostError(
f"space={space_id}; partition={old_partition.get('id', '')}; "
"optimize delta unproved"
)
PASSAGE_FORBIDDEN_FIELDS = {"contact", "lock", "invert", "flip_h", "flip_v"}
def validate_opening_passages(
config: dict, previous: dict | None = None, *, validate_all: bool = False
) -> None:
"""Reject new/changed inapplicable fields while preserving dormant bad data.
A passage read from an older/future writer may already contain door-only
keys. Unrelated writes must remain possible, but imports and any write that
introduces or changes such a key are fail-closed.
"""
old_spaces = {
str(space.get("id")): space for space in (previous or {}).get("spaces") or []
}
for space in config.get("spaces") or []:
space_id = str(space.get("id", ""))
old_space = None if validate_all else old_spaces.get(space_id)
old_openings = {
str(opening.get("id")): opening
for opening in (old_space or {}).get("openings") or []
}
for opening in space.get("openings") or []:
if opening.get("type") != "passage":
continue
opening_id = str(opening.get("id", ""))
present = sorted(PASSAGE_FORBIDDEN_FIELDS & set(opening))
if not present:
continue
old_opening = None if validate_all else old_openings.get(opening_id)
if not old_opening or old_opening.get("type") != "passage":
raise OpeningPassageError(space_id, opening_id, present)
changed = [
field for field in present
if field not in old_opening or opening[field] != old_opening[field]
]
if changed:
raise OpeningPassageError(space_id, opening_id, changed)
VALUE_BADGE_ATTRIBUTES = {
"current_temperature", "temperature", "current_humidity", "humidity",
"current_position", "percentage", "brightness", "volume_level",
"battery_level", "fan_speed",
}
VALUE_BADGE_POSITIONS = {"right", "bottom", "left", "top"}
VALUE_BADGE_SOURCE_KINDS = {
"entity_state", "entity_attribute", "derived_lqi", "derived_marker_state",
}
_LIGHT_ENTITY_RE = re.compile(r"^(?:light|switch)\.[a-z0-9_]+\Z")
def _matching_previous_marker(
marker: dict, marker_id: str, old_by_id: dict[str, dict], old_markers: list[dict],
new_ids: set[str], consumed_old_ids: set[str], validate_all: bool,
) -> dict | None:
"""Match the previous marker across the binding-stable id rename path."""
old_marker = old_by_id.get(marker_id)
if old_marker is not None:
consumed_old_ids.add(marker_id)
return old_marker
if validate_all:
return None
binding = marker.get("binding")
matches = [
old for old in old_markers
if binding not in (None, "virtual")
and old.get("binding") == binding
and str(old.get("id")) not in new_ids
and str(old.get("id")) not in consumed_old_ids
]
if len(matches) == 1:
consumed_old_ids.add(str(matches[0].get("id")))
return matches[0]
return None
def validate_marker_value_badges(
config: dict, previous: dict | None = None, *, validate_all: bool = False
) -> None:
"""Validate changed badge/face sources; dormant future data round-trips."""
markers = config.get("markers") or []
by_id = {str(marker.get("id")): marker for marker in markers}
old_markers = (previous or {}).get("markers") or []
old_by_id = {str(marker.get("id")): marker for marker in old_markers}
new_ids = set(by_id)
consumed_old_ids: set[str] = set()
known_source_fields = {"kind", "entity_id", "attribute", "ref"}
def validate_source(source: object, field: str) -> None:
prefix = "value_badge" if field == "value_badge" else "value_source"
source_error = "invalid_value_badge_source" if field == "value_badge" \
else "invalid_value_source"
attribute_error = "invalid_value_badge_attribute" if field == "value_badge" \
else "invalid_value_source_attribute"
if not isinstance(source, dict) or source.get("kind") not in VALUE_BADGE_SOURCE_KINDS:
raise MarkerControlError(source_error, f"Invalid {field} source")
kind = source["kind"]
allowed_fields = {
"entity_state": {"kind", "entity_id"},
"entity_attribute": {"kind", "entity_id", "attribute"},
"derived_lqi": {"kind"},
"derived_marker_state": {"kind", "ref"},
}[kind]
if (known_source_fields & set(source)) - allowed_fields:
raise MarkerControlError(source_error, f"Inconsistent {field} source")
if kind in {"entity_state", "entity_attribute"}:
entity_id = source.get("entity_id")
if not isinstance(entity_id, str) or not _CONTROL_ENTITY_ID_RE.fullmatch(entity_id):
raise MarkerControlError(source_error, f"Invalid {field} entity id")
if kind == "entity_attribute" and source.get("attribute") not in VALUE_BADGE_ATTRIBUTES:
raise MarkerControlError(attribute_error, f"Invalid {field} attribute")
if kind == "derived_marker_state":
ref = source.get("ref")
if not isinstance(ref, str) or not ref.startswith(MARKER_CONTROL_PREFIX) \
or not ref[len(MARKER_CONTROL_PREFIX):]:
raise MarkerControlError(source_error, f"Invalid {field} target")
target = by_id.get(ref[len(MARKER_CONTROL_PREFIX):])
if target is None or target.get("removed") is True:
raise MarkerControlError(f"{prefix}_marker_missing", f"{field} target does not exist")
if target.get("is_light") is not True:
raise MarkerControlError(f"{prefix}_marker_not_light", f"{field} target is not a forced light")
for marker_id, marker in by_id.items():
old_marker = _matching_previous_marker(
marker, marker_id, old_by_id, old_markers, new_ids,
consumed_old_ids, validate_all,
)
badge = marker.get("value_badge")
old_badge = None if validate_all else (old_marker or {}).get("value_badge")
if validate_all or badge != old_badge:
if badge is not None:
if not isinstance(badge, dict):
raise MarkerControlError("invalid_value_badge", "Value badge must be an object")
if not isinstance(badge.get("enabled"), bool):
raise MarkerControlError("invalid_value_badge", "Value badge enabled must be boolean")
if badge.get("position") not in VALUE_BADGE_POSITIONS:
raise MarkerControlError("invalid_value_badge_position", "Invalid value badge position")
source = badge.get("source")
if badge["enabled"] and not isinstance(source, dict):
raise MarkerControlError(
"value_badge_source_required", "Enabled value badge needs a source"
)
if source is not None:
validate_source(source, "value_badge")
source = marker.get("value_source")
old_source = None if validate_all else (old_marker or {}).get("value_source")
if (validate_all or source != old_source) and source is not None:
validate_source(source, "value_source")
def validate_marker_vacuum_routes(
config: dict, previous: dict | None = None, *, validate_all: bool = False
) -> None:
"""Validate changed vacuum map routes; dormant future data round-trips.
Change-aware like the badge validator above (#162): an untouched legacy or
future-shaped block must not block an unrelated save, but any edit to
routing has to leave the marker in a state the resolver can answer for —
unique identities, six-number matrices and target spaces that exist.
"""
markers = config.get("markers") or []
spaces = config.get("spaces")
space_ids = None
if isinstance(spaces, list):
space_ids = {
str(space.get("id")) for space in spaces
if isinstance(space, dict) and isinstance(space.get("id"), str)
}
old_markers = (previous or {}).get("markers") or []
old_by_id = {str(marker.get("id")): marker for marker in old_markers}
by_id = {str(marker.get("id")): marker for marker in markers}
new_ids = set(by_id)
consumed_old_ids: set[str] = set()
for marker_id, marker in by_id.items():
old_marker = _matching_previous_marker(
marker, marker_id, old_by_id, old_markers, new_ids,
consumed_old_ids, validate_all,
)
routes = (marker.get("vacuum") or {}).get("map_routes")
old_routes = None if validate_all else ((old_marker or {}).get("vacuum") or {}).get("map_routes")
if not validate_all and routes == old_routes:
continue
problems = validate_marker_routes(marker_id, routes, space_ids)
if problems:
reasons = ", ".join(sorted({problem["reason"] for problem in problems}))
# Literal, not the constant: the #42 scanner proves the emitted
# code against ERROR_CODES by reading the source, and it cannot
# follow a name. The pair is pinned by a test below.
raise MarkerControlError(
"invalid_vacuum_map_route",
f"Invalid vacuum map routes on {marker_id}: {reasons}",
)
def validate_marker_light_entities(
config: dict, previous: dict | None = None, *, validate_all: bool = False
) -> None:
"""Validate new/changed light/switch choices without rejecting dormant data.
The top-level schema must stay lossless: an old or future literal that the
current frontend cannot edit may round-trip unchanged. Imports validate the
whole incoming document because every imported value is new to this plan.
"""
markers = config.get("markers") or []
old_markers = (previous or {}).get("markers") or []
old_by_id = {str(marker.get("id")): marker for marker in old_markers}
new_ids = {str(marker.get("id")) for marker in markers}
consumed_old_ids: set[str] = set()
for marker in markers:
marker_id = str(marker.get("id"))
old_marker = _matching_previous_marker(
marker, marker_id, old_by_id, old_markers, new_ids,
consumed_old_ids, validate_all,
)
for field, code, message in (
(
"light_entity",
"invalid_light_entity",
"Leading light entity must be light.* or switch.*",
),
(
"toggle_entity",
"invalid_toggle_entity",
"Toggle entity must be light.* or switch.*",
),
):
value = marker.get(field)
old_value = None if validate_all else (old_marker or {}).get(field)
if not validate_all and value == old_value:
continue
if value is None:
continue
if not isinstance(value, str) or not _LIGHT_ENTITY_RE.fullmatch(value):
raise MarkerControlError(code, message)
def validate_marker_controls(
config: dict, previous: dict | None = None, *, validate_all: bool = False
) -> None:
"""Validate newly introduced marker:* edges without rewriting old data.
Existing broken refs remain editable and round-trip losslessly. Imports use
validate_all because their complete candidate graph is new to this plan.
"""
markers = config.get("markers") or []
by_id = {str(marker.get("id")): marker for marker in markers}
old_markers = (previous or {}).get("markers") or []
old_by_id = {
str(marker.get("id")): marker for marker in (previous or {}).get("markers") or []
}
new_ids = set(by_id)
consumed_old_ids: set[str] = set()
graph: dict[str, list[str]] = {}
added: list[tuple[str, str]] = []
for marker_id, marker in by_id.items():
old_marker = _matching_previous_marker(
marker, marker_id, old_by_id, old_markers, new_ids,
consumed_old_ids, validate_all,
)
raw_controls = [
ref for ref in marker.get("controls") or [] if isinstance(ref, str)
]
old_controls = [] if validate_all else [
ref for ref in (old_marker or {}).get("controls") or [] if isinstance(ref, str)
]
refs = [
ref for ref in raw_controls
if isinstance(ref, str) and ref.startswith(MARKER_CONTROL_PREFIX)
]
graph[marker_id] = [ref[len(MARKER_CONTROL_PREFIX):] for ref in refs]
old_refs = [
ref for ref in old_controls
if isinstance(ref, str) and ref.startswith(MARKER_CONTROL_PREFIX)
]
remaining = list(old_refs)
for ref in refs:
if ref in remaining:
remaining.remove(ref)
else:
added.append((marker_id, ref[len(MARKER_CONTROL_PREFIX):]))
new_counts, old_counts = Counter(refs), Counter(old_refs)
if any(count > 1 and count > old_counts[ref] for ref, count in new_counts.items()):
raise MarkerControlError("duplicate_marker_control", "Duplicate marker light target")
remaining_controls = list(old_controls)
for ref in raw_controls:
if ref in remaining_controls:
remaining_controls.remove(ref)
elif not ref.startswith(MARKER_CONTROL_PREFIX) and not _CONTROL_ENTITY_ID_RE.fullmatch(ref):
raise MarkerControlError("invalid_marker_control", f"Invalid entity target: {ref}")
def reaches(start: str, wanted: str) -> bool:
stack, seen = [start], set()
while stack:
node = stack.pop()
if node == wanted:
return True
if node in seen:
continue
seen.add(node)
stack.extend(graph.get(node, []))
return False
for controller, target in added:
if not target:
raise MarkerControlError("invalid_marker_control", "Marker target id is empty")
if target == controller:
raise MarkerControlError("marker_control_self", "A marker cannot control itself")
target_marker = by_id.get(target)
if target_marker is None or target_marker.get("removed") is True:
raise MarkerControlError("marker_control_missing", f"Marker target does not exist: {target}")
if target_marker.get("is_light") is not True:
raise MarkerControlError("marker_control_not_light", f"Marker target is not a forced light: {target}")
if reaches(target, controller):
raise MarkerControlError("marker_control_cycle", "Marker light controls contain a cycle")
# ---------- sanitizers ----------
def sanitize_marker_id(value: str) -> str:
"""Safe marker identifier for a folder name.
Strips path separators and leading dots to rule out directory traversal
(e.g. '..', '../x'); an empty/dots-only result → 'misc'.
"""
cleaned = _SAFE_NAME_RE.sub("_", value).lstrip(".")[:64]
return cleaned or "misc"
def sanitize_filename(value: str) -> str:
"""Drop the path and leading dots, keep a safe file name."""
raw = value.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
return _SAFE_NAME_RE.sub("_", raw).lstrip(".")[:MAX_FILENAME] or "file"
def file_ext(filename: str) -> str:
"""Lowercase file extension ('' if none)."""
raw = filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
return raw.rsplit(".", 1)[-1].lower() if "." in raw else ""
def valid_space_id(value: str) -> bool:
return bool(SPACE_ID_RE.match(value))
# ---------- voluptuous schemas ----------
def _finite(value):
"""Coerce to float and reject NaN/Infinity (audit B5).
'NaN' and 'Infinity' pass Coerce(float) and serialize to null on write,
silently corrupting a stored position forever.
"""
f = float(value)
if f != f or f in (float("inf"), float("-inf")):
raise vol.Invalid("coordinate must be a finite number")
return f
def _finite_number_or_none(value):
"""A persisted optional number: do not coerce strings or booleans."""
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise vol.Invalid("temperature threshold must be a finite number or null")
return _finite(value)
# Persisted colours deliberately use one small, browser-independent format.
# Keep this exact contract in sync with src/color.ts.
# `^...$` accepts a trailing newline in Python. Persisted CSS tokens must
# match the whole string exactly, in parity with the frontend validator.
_COLOR = vol.Match(r"\A#[0-9a-fA-F]{6}\Z")
_CUSTOM_FILL = vol.Schema(
{
vol.Required("c"): _COLOR,
vol.Required("a"): vol.All(_finite, vol.Range(min=0.0, max=1.0)),
}
)
# generous caps: the product targets 20-200 devices and a handful of floors
MAX_SPACES = 50
MAX_ROOMS = 400
MAX_MARKERS = 2000
MAX_OPENINGS = 500
MAX_DECOR = 1000
# v8 atomises room boundaries. The 2 MiB wire cap is the practical bound; this
# structural cap mirrors MAX_ROOMS * MAX_POLY_POINTS without depending on the
# later constant declaration.
MAX_WALLS = 200_000
MAX_WALL_SEGMENTS = 200_000
_LEGACY_MAX_ROOM_DRAFTS = 200
_LEGACY_MAX_DRAFT_SEGMENTS = 2000
MAX_PARTITIONS = 2000
MAX_WALL_COLUMNS = 500
# Open (virtual) wall stretches, docs/superpowers/specs/2026-08-05-open-spans-delete-design.md.
# Every span is a piece of a shared boundary, so there can never be more of
# them than there are wall segments — the cap is the walls' one (AUD-159B6-03).
MAX_OPEN_SPANS = 500
MAX_LAYOUT = 5000
# Inner limits (HP-1454-05). The outer collections were capped, the collections
# INSIDE them were not: a 150 000-point polygon or a 100 000-entry known_devices
# list passed validation, then made the card build gigantic SVG attributes and
# walk them on every render. Any authenticated writer could store one, and with
# `admin_only` off that is every user. These are product limits, not guesses: a
# hand-drawn room does not need 500 vertices, and no home has 200 lights behind
# one switch.
MAX_POLY_POINTS = 500
MAX_OPEN_TO = 50
MAX_CONTROLS = 200
MAX_PDFS = 50
MAX_KNOWN_DEVICES = 20000
MAX_MARKER_AREA_SNAPSHOT = MAX_KNOWN_DEVICES
MAX_TEXT = 500 # names, models, ids
MAX_DESCRIPTION = 4000
MAX_URL = 2000
# Comfortably below the WebSocket frame limit (aiohttp's default is 4 MB): a
# payload larger than the frame never reaches the handler at all — the socket
# closes with 1009 and the user sees a dropped connection instead of an error
# they can act on. For scale, a real three-floor home with ~200 devices stores
# about 70 KB, so this is ~30x headroom.
MAX_CONFIG_BYTES = 2 * 1024 * 1024
CELL_CM_MIN = 0.1
CELL_CM_MAX = 1000.0
_TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
_NONEMPTY_TEXT = vol.All(str, vol.Length(min=1, max=MAX_TEXT))
_TEXT_OR_NONE = vol.Any(None, _TEXT)
_URL = vol.All(str, vol.Length(max=MAX_URL))
# The canvas is UNBOUNDED (docs/CANVAS.md). Coordinates are still normalised —
# 1.0 is still one canvas width — but there is no frame any more, so a plan may
# legitimately live at 2.7 or -1.4. The range below is GARBAGE INSURANCE, not a
# boundary: at the product's own scale (cell_cm=5, 240 cells across the unit
# width) 5000 is about 60 km of plan, unreachable in a home, while a stored
# 1e100 still cannot stretch every client's view until the plan is invisible
# (HP-1500-03 / HP-1501-01). Widened from +/-4 on 2026-08-03.
CANVAS_LIMIT = 5000.0
_COORD = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
POS_SCHEMA = vol.All(
vol.Schema(
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
),
canonicalize_position,
)
LAYOUT_SCHEMA = vol.All(
vol.Schema({str: POS_SCHEMA}),
vol.Length(max=MAX_LAYOUT),
canonicalize_layout_geometry,
)
# Room/opening geometry: same story, same range (docs/CANVAS.md). A vertex at
# 2.5 is a plan that grew past the old square, not corruption; 1e100 is
# corruption (HP-1501-01, the room-geometry twin of HP-1500-03).
_GEOM = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
# A SIZE is not a coordinate (HP-1502-01): SVG requires positive width/height,
# and the clients divide by these. `view_box: [0,0,0,0]` passed the shared
# validator and serialised into viewBox="0 0 0 0" — a blank plan on every
# client. The floor is one thousandth of the canvas (1 render unit): far below
# any real room, but keeps the maths finite. The CEILING follows the canvas
# (docs/CANVAS.md) — a room on an unbounded plane may legitimately be wider
# than the old unit square — while staying strictly positive.
_EXTENT = vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT))
# The backdrop's uniform scale (docs/BACKDROP.md). A MULTIPLIER, not a
# coordinate: strictly positive, and bounded by what a person could mean —
# a hundredth of the canvas is already a thumbnail, a hundred canvases is
# already absurd. Mirrored by PLAN_SCALE_MIN/MAX in src/space-geometry.ts.
PLAN_SCALE_MIN = 0.01
PLAN_SCALE_MAX = 100.0
def _view_box(value):
"""[x, y, w, h]: the first two are coordinates, the last two are sizes."""
if not isinstance(value, (list, tuple)) or len(value) != 4:
raise vol.Invalid("view_box must be [x, y, w, h]")
return [_GEOM(value[0]), _GEOM(value[1]), _EXTENT(value[2]), _EXTENT(value[3])]
POINT = vol.All([_GEOM], vol.Length(min=2, max=2))
# A virtual stretch shorter than this is a click, not a span. Mirrors
# OPEN_SPAN_MIN_UNITS in src/open-spans.ts (normalised units).
OPEN_SPAN_MIN_LEN = 1e-3
def _open_span(value):
"""One virtual stretch: exactly two distinct finite points a/b.
AUD-159B6-03: the field used to ride on `extra=ALLOW_EXTRA` — any shape
passed the backend and the card crashed reading `e.a[0]` on render, for
every reader of that space. A degenerate span (a == b) is not a stretch
either: it can never be hit, closed or drawn, so it is corruption, not data.
"""
if not isinstance(value, dict):
raise vol.Invalid("open_span must be an object with a/b points")
a = POINT(value.get("a"))
b = POINT(value.get("b"))
if abs(a[0] - b[0]) < OPEN_SPAN_MIN_LEN and abs(a[1] - b[1]) < OPEN_SPAN_MIN_LEN:
raise vol.Invalid("open_span: a and b must differ")
out = {k: v for k, v in value.items() if k not in ("a", "b")}
out["a"] = a
out["b"] = b
return out
def _dedupe_open_spans(value):
"""Drop repeats of the same stretch (either direction) — one wall, one span."""
seen = set()
out = []
for span in value:
a, b = span["a"], span["b"]
fwd = (round(a[0], 6), round(a[1], 6), round(b[0], 6), round(b[1], 6))
key = min(fwd, (fwd[2], fwd[3], fwd[0], fwd[1]))
if key in seen:
continue
seen.add(key)
out.append(span)
return out
def _require_geometry(room: dict) -> dict:
if "poly" in room or all(k in room for k in ("x", "y", "w", "h")):
return room
raise vol.Invalid("room: poly or x/y/w/h is required")
ROOM_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("id"): _TEXT,
vol.Required("name"): _TEXT,
vol.Optional("area"): _TEXT_OR_NONE,
vol.Optional("open_to"): vol.All([_TEXT], vol.Length(max=MAX_OPEN_TO)),
vol.Optional("settings"): vol.Any(
None,
vol.Schema(
{
vol.Optional("fill_mode"): vol.Any(None, vol.In(["none", "lqi", "light", "temp", "custom", "glow"])),
vol.Optional("custom_fill"): vol.Any(None, _CUSTOM_FILL),
vol.Optional("glow"): vol.Any(bool, None),
vol.Optional("temp_source"): vol.Any(str, None),
vol.Optional("hum_source"): vol.Any(str, None),
vol.Optional("temp_min"): _finite_number_or_none,
vol.Optional("temp_max"): _finite_number_or_none,
vol.Optional("name_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
vol.Optional("label_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
},
extra=vol.ALLOW_EXTRA,
),
),
vol.Optional("x"): _GEOM,
vol.Optional("y"): _GEOM,
vol.Optional("w"): _EXTENT,
vol.Optional("h"): _EXTENT,
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3, max=MAX_POLY_POINTS)),
vol.Optional("wall_ids"): vol.All(
[vol.All(str, vol.Length(min=1, max=64))],
vol.Length(min=3, max=MAX_POLY_POINTS),
),
},
extra=vol.ALLOW_EXTRA,
),
_require_geometry,
)
def _north_deg(value):
"""Compass (docs/SUN.md): strict integer degrees, 0..359.
Strict on purpose: Coerce(int) would take "90" and 1.5, and a bool is an
int in Python — none of those is a compass reading a client stored.
"""
if isinstance(value, bool) or not isinstance(value, int):
raise vol.Invalid("north_deg must be an integer in 0..359")
if not 0 <= value <= 359:
raise vol.Invalid("north_deg must be an integer in 0..359")
return value
_BG_MODE = vol.In(["static", "daynight"])
SPACE_DISPLAY_SCHEMA = vol.Schema(
{
vol.Optional("show_borders"): bool,
vol.Optional("show_names"): bool,
vol.Optional("room_color"): _COLOR,
# per-space background around the plan; absent = inherit the global one
vol.Optional("bg_color"): _COLOR,
vol.Optional("room_opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp", "custom", "glow"]),
vol.Optional("custom_fill"): vol.Any(None, _CUSTOM_FILL),
vol.Optional("glow_enabled"): bool,
vol.Optional("temp_min"): vol.Coerce(float),
vol.Optional("temp_max"): vol.Coerce(float),
vol.Optional("show_lqi"): bool,
# "draw less" switches; absent = False = everything is drawn as before
vol.Optional("hide_decor"): bool,
vol.Optional("hide_openings"): bool,
vol.Optional("label_temp"): bool,
vol.Optional("label_hum"): bool,
vol.Optional("label_lqi"): bool,
vol.Optional("label_light"): bool,
vol.Optional("card_font_scale"): vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3)),
# sun on the plan (docs/SUN.md): per-space overrides, absent = inherit
vol.Optional("north_deg"): vol.Any(None, _north_deg),
vol.Optional("bg_mode"): vol.Any(None, _BG_MODE),
vol.Optional("sun_rays"): vol.Any(None, bool),
},
extra=vol.ALLOW_EXTRA,
)
# Live text on a decor label (docs/LIVE-TEXT.md). An entity id is
# `<domain>.<object_id>`; HA itself allows only lowercase letters, digits and
# underscores in both halves. The bound is a sanity limit, not a policy.
MAX_ENTITY_ID = 255
_ENTITY_ID = vol.All(str, vol.Length(min=3, max=MAX_ENTITY_ID),
vol.Match(r"^[a-z0-9_]+\.[a-z0-9_]+$"))
# A caption is a caption: the inline-reference template is bounded. Attribute
# and unit bounds below apply only to legacy beta.9 link fields, which remain
# accepted so an older saved plan can reach the frontend and migrate on edit.
MAX_DECOR_TEXT = 200
MAX_DECOR_ATTR = 64
MAX_DECOR_UNIT = 16
# The text block is scaled by dragging its corners; the range is what a human
# could mean on a 1000-unit canvas, the rest is garbage insurance.
DECOR_TEXT_SCALE_MIN = 0.15
DECOR_TEXT_SCALE_MAX = 20.0
DECOR_TEXT_CM_MAX = 2000.0
# A furniture symbol id (docs/FURNITURE.md). Deliberately NOT the card's list:
# the backend must accept a plan written by a NEWER card, and a card that has
# learnt a new symbol must not have to wait for the integration to be updated
# before the user can save. What is enforced is the shape of the id — a flat
# lowercase name — and its length; an id this backend has never heard of simply
# renders as nothing in an older card.
MAX_FURN_SYMBOL = 32
_FURN_SYMBOL = vol.All(str, vol.Length(min=1, max=MAX_FURN_SYMBOL),
vol.Match(r"^[a-z0-9_]+$"))
# …and its size: strictly positive, capped by the same canvas insurance limit
# an opening's length is. A piece of furniture is a SIZE, not a coordinate.
_FURN_SIZE = vol.All(_finite, vol.Range(min=0.0000001, max=CANVAS_LIMIT))
_DECOR_ASSET_ID = vol.All(str, vol.Match(r"^[0-9a-f]{64}$"))
_DECOR_COMMON = {
vol.Required("id"): str,
vol.Optional("color"): _COLOR,
vol.Optional("opacity"): vol.All(_finite, vol.Range(min=0.0, max=1.0)),
# Physical centimetres are canonical. `width` remains accepted so plans
# written by older cards keep their exact appearance until edited.
vol.Optional("width_cm"): vol.All(_finite, vol.Range(min=0.1, max=100)),
vol.Optional("width"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=30)),
}
# Decor lives on the same unbounded canvas as everything else (docs/CANVAS.md):
# it used to be pinned to -1..2, i.e. "one canvas of slack around the square".
_NORM = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
DECOR_SCHEMA = vol.Any(
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "line",
vol.Required("x1"): _NORM, vol.Required("y1"): _NORM,
vol.Required("x2"): _NORM, vol.Required("y2"): _NORM,
vol.Optional("line_style"): vol.In(["solid", "dashed"])},
extra=vol.ALLOW_EXTRA),
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): vol.In(["rect", "ellipse"]),
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
# sizes are extents — negative/zero is garbage, not "canvas slack"
vol.Required("w"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
vol.Required("h"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
vol.Optional("fill"): bool,
vol.Optional("fill_color"): _COLOR,
vol.Optional("fill_opacity"): vol.All(_finite, vol.Range(min=0.0, max=1.0))},
extra=vol.ALLOW_EXTRA),
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "text",
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
# the template: newlines are the user's own line breaks and are
# kept verbatim (docs/LIVE-TEXT.md); the label never wraps itself
vol.Required("text"): vol.All(str, vol.Length(min=1, max=MAX_DECOR_TEXT)),
# legacy font size ('s'|'m'|'l'). The dialog no longer offers it
# — the block is scaled by its corner handles — but a plan
# written before that keeps it, and it is read as the scale it
# used to render at. Kept in the schema so it stays BOUNDED.
vol.Optional("size"): vol.In(["s", "m", "l"]),
# Canonical physical font size plus the legacy scale. Both are
# accepted so older plans remain pixel-identical until edited
# or explicitly optimized.
vol.Optional("size_cm"): vol.All(
_finite, vol.Range(min=0.1, max=DECOR_TEXT_CM_MAX)),
vol.Optional("scale"): vol.All(
_finite, vol.Range(min=DECOR_TEXT_SCALE_MIN, max=DECOR_TEXT_SCALE_MAX)),
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
# Legacy one-value link (beta.9 and earlier). New labels store
# every `{entity[:attribute]}` reference directly in `text`;
# these stay accepted solely for backward compatibility.
vol.Optional("entity"): vol.Any(None, _ENTITY_ID),
vol.Optional("attr"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DECOR_ATTR))),
vol.Optional("unit"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DECOR_UNIT)))},
extra=vol.ALLOW_EXTRA),
# A piece of furniture (docs/FURNITURE.md): a symbol id, a normalised box
# and an optional rotation. It is a NEW kind, so no existing plan carries
# it, nothing is migrated, and an integration that has this branch reads
# every older config byte-for-byte as before.
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "furniture",
vol.Required("symbol"): _FURN_SYMBOL,
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
vol.Required("w"): _FURN_SIZE, vol.Required("h"): _FURN_SIZE,
vol.Optional("flip_h"): bool, vol.Optional("flip_v"): bool,
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0))},
extra=vol.ALLOW_EXTRA),
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "image",
vol.Required("asset_id"): _DECOR_ASSET_ID,
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
vol.Required("w"): _FURN_SIZE, vol.Required("h"): _FURN_SIZE,
vol.Optional("flip_h"): bool, vol.Optional("flip_v"): bool,
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0))},
extra=vol.ALLOW_EXTRA),
)
def _wall_endpoints_pair(entry: dict) -> dict:
"""Exact wall endpoints are useful only as a complete a/b pair."""
if ("a" in entry) != ("b" in entry):
raise vol.Invalid("wall exact endpoints require both a and b")
return entry
WALL_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("key"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100)),
# New writes retain exact normalized interval endpoints. The old
# key remains the compatibility lookup; endpoints preserve a
# differing-thickness breakpoint after a virtual span is closed.
vol.Optional("a"): vol.All([_NORM], vol.Length(min=2, max=2)),
vol.Optional("b"): vol.All([_NORM], vol.Length(min=2, max=2)),
},
extra=vol.ALLOW_EXTRA,
),
_wall_endpoints_pair,
)
def _wall_segment_nonzero(value: dict) -> dict:
if value["a"] == value["b"]:
raise vol.Invalid("wall segment endpoints must differ")
return value
WALL_SEGMENT_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("a"): POINT,
vol.Required("b"): POINT,
vol.Required("cm"): vol.All(_finite, vol.Range(min=0, max=100)),
},
extra=vol.ALLOW_EXTRA,
),
_wall_segment_nonzero,
)
def _legacy_room_draft_segments(value: dict) -> dict:
"""An open draft has exactly one thickness per consecutive edge."""
if len(value.get("segments", [])) != max(0, len(value.get("points", [])) - 1):
raise vol.Invalid("room draft segments must match consecutive point pairs")
if any(a == b for a, b in zip(value.get("points", []), value.get("points", [])[1:], strict=False)): # #42 B905: adjacent pairs
raise vol.Invalid("room draft consecutive points must differ")
return value
_LEGACY_ROOM_DRAFT_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("points"): vol.All([POINT], vol.Length(min=2, max=500)),
vol.Required("segments"): vol.All(
[vol.Schema({
vol.Optional("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("cm"): vol.All(_finite, vol.Range(min=0, max=100)),
},
extra=vol.ALLOW_EXTRA)],
vol.Length(min=1, max=499),
),
},
extra=vol.ALLOW_EXTRA,
),
_legacy_room_draft_segments,
)
def _partition_nonzero(value: dict) -> dict:
if value["a"] == value["b"]:
raise vol.Invalid("partition endpoints must differ")
return value
PARTITION_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("a"): POINT,
vol.Required("b"): POINT,
vol.Required("cm"): vol.All(_finite, vol.Range(min=0, max=100)),
},
extra=vol.ALLOW_EXTRA,
),
_partition_nonzero,
)
def _strict_wall_column(value: dict) -> dict:
"""Reject shape-inapplicable or non-canonical column fields."""
if value["shape"] == "circle" and "angle" in value:
raise vol.Invalid("angle is allowed only for square wall columns")
if value["shape"] == "square" and value.get("angle", 0) >= 90:
raise vol.Invalid("square wall column angle must be in [0, 90)")
return value
WALL_COLUMN_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("shape"): vol.In(["square", "circle"]),
vol.Required("center"): POINT,
# Outer side for a square, outer diameter for a circle.
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=150)),
vol.Optional("angle"): vol.All(
_finite, vol.Range(min=0, max=90)
),
},
extra=vol.ALLOW_EXTRA,
),
_strict_wall_column,
)
PARTITION_OPENING_HOST_SCHEMA = vol.Schema(
{
vol.Required("kind"): vol.Equal("partition"),
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("t"): vol.All(_finite, vol.Range(min=0, max=1)),
},
extra=vol.PREVENT_EXTRA,
)
WALL_OPENING_HOST_SCHEMA = vol.Schema(
{
vol.Required("kind"): vol.Equal("wall"),
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("t"): vol.All(_finite, vol.Range(min=0, max=1)),
},
extra=vol.PREVENT_EXTRA,
)
OPENING_HOST_SCHEMA = vol.Any(PARTITION_OPENING_HOST_SCHEMA, WALL_OPENING_HOST_SCHEMA)
def _space_geometry_invariants(value: dict) -> dict:
"""All stored geometry shares ids; draft segments also have a space cap."""
seen: set[str] = set()
for key in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns", "wall_segments"):
for item in value.get(key, []):
item_id = item.get("id")
if not item_id:
continue
if item_id in seen:
raise vol.Invalid("geometry object ids must be unique within a space")
seen.add(item_id)
draft_segments = sum(
len(item.get("segments", [])) for item in value.get("room_drafts", [])
)
if draft_segments > _LEGACY_MAX_DRAFT_SEGMENTS:
raise vol.Invalid("too many saved room-draft segments")
for draft in value.get("room_drafts", []):
for segment in draft.get("segments", []):
segment_id = segment.get("id")
if not segment_id:
continue
if segment_id in seen:
raise vol.Invalid("geometry object ids must be unique within a space")
seen.add(segment_id)
partitions = {
item.get("id"): item for item in value.get("partitions", []) if item.get("id")
}
hosted_intervals: dict[str, list[tuple[float, float]]] = {}
for opening in value.get("openings", []):
host = opening.get("host")
if host is None:
continue
if host["kind"] != "partition":
continue
partition = partitions.get(host["id"])
if partition is None:
raise vol.Invalid("partition opening host must exist in the same space")
ax, ay = partition["a"]
bx, by = partition["b"]
span = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5
length = float(opening["length"])
along = float(host["t"]) * span
if length > span or along - length / 2 < -1e-9 or along + length / 2 > span + 1e-9:
raise vol.Invalid("partition opening must fit inside its host")
lo, hi = along - length / 2, along + length / 2
occupied = hosted_intervals.setdefault(host["id"], [])
if any(max(lo, old_lo) < min(hi, old_hi) - 1e-9 for old_lo, old_hi in occupied):
raise vol.Invalid("partition openings must not overlap")
occupied.append((lo, hi))
return value
SPACE_SCHEMA = vol.All(vol.Schema(
{
vol.Required("id"): vol.All(str, vol.Match(SPACE_ID_RE.pattern)),
vol.Required("title"): str,
# Physical grid scale. It feeds every px/cell -> centimetres migration,
# so NaN/Infinity or an absurd value must not be allowed to manufacture
# invalid decor sizes later.
vol.Optional("cell_cm"): vol.All(
_finite, vol.Range(min=CELL_CM_MIN, max=CELL_CM_MAX)
),
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
vol.Optional("plan_url"): vol.Any(str, None),
# The canvas is square since v1.48.0. What used to be the space's own
# `aspect` is gone; the background image keeps its own proportions and
# is centred, so only the IMAGE's ratio is stored. A stale tab may still
# send the old field — it is dropped rather than trusted, because the
# coordinates it comes with were normalised against a different box.
vol.Remove("aspect"): object,
vol.Optional("plan_aspect"): vol.Any(
None, vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20))
),
# Backdrop placement (docs/BACKDROP.md): the picture may be moved,
# resized per axis and rotated. Every transform field is optional; its
# complete absence is the pre-v1.58.0 behaviour exactly, and an old
# config validates unchanged. The offset is a normalised
# coordinate like every other one (the ±CANVAS_LIMIT garbage guard);
# the scale is a positive multiplier in a range a human could mean.
vol.Optional("plan_x"): vol.Any(None, _COORD),
vol.Optional("plan_y"): vol.Any(None, _COORD),
vol.Optional("plan_scale"): vol.Any(
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
),
# New writes may stretch each axis independently and rotate. The old
# uniform field remains a read-compatible fallback for both axes.
vol.Optional("plan_scale_x"): vol.Any(
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
),
vol.Optional("plan_scale_y"): vol.Any(
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
),
vol.Optional("plan_angle"): vol.Any(
None, vol.All(_finite, vol.Range(min=-360.0, max=360.0))
),
vol.Required("view_box"): _view_box,
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
vol.Optional("openings"): vol.All([
vol.Schema(
{
vol.Required("id"): str,
vol.Required("type"): vol.Any("door", "window", "gate", "passage"),
vol.Required("x"): _GEOM,
vol.Required("y"): _GEOM,
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
# a SIZE: strictly positive, capped by the canvas insurance
# limit rather than by the old unit square (docs/CANVAS.md)
vol.Required("length"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
vol.Optional("contact"): vol.Any(str, None),
vol.Optional("lock"): vol.Any(str, None),
vol.Optional("invert"): bool,
vol.Optional("flip_h"): bool,
vol.Optional("flip_v"): bool,
vol.Optional("host"): OPENING_HOST_SCHEMA,
},
extra=vol.ALLOW_EXTRA,
)
], vol.Length(max=MAX_OPENINGS)),
# Wall thickness (docs/WALL-THICKNESS.md): keyed by a segment identity
# (midpoint + direction), thickness always in centimetres. Optional —
# a space without `walls` validates and renders exactly as before.
vol.Optional("walls"): vol.All([WALL_SCHEMA], vol.Length(max=MAX_WALLS)),
vol.Optional("wall_segments"): vol.All(
[WALL_SEGMENT_SCHEMA], vol.Length(max=MAX_WALL_SEGMENTS)
),
vol.Optional("zero_wall_style"): vol.In(["dashed", "solid"]),
vol.Optional("room_drafts"): vol.All(
[_LEGACY_ROOM_DRAFT_SCHEMA], vol.Length(max=_LEGACY_MAX_ROOM_DRAFTS)
),
vol.Optional("partitions"): vol.All(
[PARTITION_SCHEMA], vol.Length(max=MAX_PARTITIONS)
),
vol.Optional("wall_columns"): vol.All(
[WALL_COLUMN_SCHEMA], vol.Length(max=MAX_WALL_COLUMNS)
),
# Open (virtual) wall stretches: a piece of a shared boundary that the
# user opened. Optional and bounded — a space without `open_spans`
# validates exactly as before, and the legacy `rooms[].open_to` index
# keeps working on its own (AUD-159B6-03).
vol.Optional("open_spans"): vol.All(
vol.Length(max=MAX_OPEN_SPANS), [_open_span], _dedupe_open_spans,
),
# Legacy: walls are derived from room outlines since v1.19.0 — a line has no
# independent existence. Still accepted so a stale browser tab cannot fail a save;
# the card strips the field on every write.
# Accepted so a stale browser tab cannot fail a save, then DROPPED here
# (HP-1454-05): relying on a modern client to strip an unbounded legacy
# list is not a limit, it is a hope. `Remove` returns the key stripped.
vol.Remove("segments"): object,
},
extra=vol.ALLOW_EXTRA,
), _space_geometry_invariants)
MARKER_SCHEMA = vol.Schema(
{
vol.Required("id"): str,
# 'device:<device_id>' | 'entity:<entity_id>' | 'virtual'
vol.Required("binding"): vol.All(
str,
vol.Length(min=1, max=MAX_TEXT),
vol.Match(r"^(device:.+|entity:.+|virtual)$"),
),
vol.Optional("space"): vol.Any(str, None),
vol.Optional("area"): vol.Any(str, None),
vol.Optional("hidden"): bool,
# A binding-level tombstone: not rendered or aggregated, but retained
# so automatic discovery does not put a deleted device straight back.
vol.Optional("removed"): bool,
vol.Optional("name"): _TEXT_OR_NONE,
vol.Optional("icon"): _TEXT_OR_NONE,
vol.Optional("model"): _TEXT_OR_NONE,
vol.Optional("link"): vol.Any(None, _URL),
vol.Optional("description"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DESCRIPTION))),
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", "run", "none", "cover", None),
# the 'run' target: only the runnable domains, nothing else is callable
vol.Optional("tap_target"): vol.Any(
None, vol.All(str, vol.Length(max=MAX_TEXT), vol.Match(r"^(automation|script|scene)\.[A-Za-z0-9_]+$"))
),
vol.Optional("tap_confirm"): vol.Any(bool, None),
# live robot vacuums (docs/VACUUM.md): everything optional so configs
# from older versions stay valid untouched
vol.Optional("vacuum"): vol.Any(
None,
vol.Schema({
vol.Optional("live"): vol.Any(bool, None),
vol.Optional("trail"): vol.Any(bool, None),
vol.Optional("trail_mode"): vol.Any(
None, vol.In(["never", "cleaning", "always"])
),
vol.Optional("room_highlight"): vol.Any(bool, None),
vol.Optional("source"): vol.Any(str, None),
# one 6-number affine per robot map; numbers must be finite
vol.Optional("calibration"): vol.Schema(
{str: vol.All([_finite], vol.Length(min=6, max=6))}
),
vol.Optional("segment_map"): vol.Schema({str: str}),
# #162: canonical map -> space routing. The schema checks the
# shape only; identity uniqueness and the referential space
# check live in validate_marker_vacuum_routes, which is
# change-aware and must not reject untouched legacy data.
vol.Optional("map_routes"): vol.Any(
None,
vol.All([vol.Schema({
vol.Required("id"): vol.All(str, vol.Length(min=1, max=128)),
vol.Required("source"): vol.All(str, vol.Length(min=1, max=255)),
vol.Required("map_id"): vol.All(str, vol.Length(max=255)),
vol.Required("space"): vol.All(str, vol.Length(min=1, max=64)),
vol.Optional("calibration"): vol.Any(
None, vol.All([_finite], vol.Length(min=6, max=6)),
),
}, extra=vol.ALLOW_EXTRA)], vol.Length(max=32)),
),
}),
),
# #485: kept structurally open here so untouched future versions and
# unknown siblings survive. validate_marker_radars performs the strict,
# change-aware semantic validation at every write/import seam.
vol.Optional("radar"): vol.Any(None, vol.Schema({}, extra=vol.ALLOW_EXTRA)),
vol.Optional("controls"): vol.Any(None, vol.All([_TEXT], vol.Length(max=MAX_CONTROLS))),
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
vol.Optional("glow_color"): vol.Any(
None,
vol.Schema(
{
vol.Required("c"): _COLOR,
vol.Optional("bri"): vol.Any(
None,
vol.All(_finite, vol.Range(min=0.01, max=1.0)),
),
}
),
),
vol.Optional("is_light"): vol.Any(bool, None),
# Explicit leading entity for composite Always sources. It is kept
# literally when temporarily absent; runtime falls back without
# deleting the user's choice.
# Semantic delta validation below the schema preserves unknown/future
# literals until that exact field is edited (lossless config doctrine).
vol.Optional("light_entity"): object,
# Exact own entity selected for Toggle. Delta validation preserves an
# untouched future literal while bounding every new/changed value.
vol.Optional("toggle_entity"): object,
vol.Optional("value_badge"): vol.Any(
None,
vol.Schema(
{
# Required semantically for changed/new data. Optional here
# keeps old/future configs readable until the user edits it.
vol.Optional("enabled"): object,
vol.Optional("position"): object,
vol.Optional("source"): vol.Any(
None,
vol.Schema({}, extra=vol.ALLOW_EXTRA),
),
},
extra=vol.ALLOW_EXTRA,
),
),
# Explicit source for display:value. Semantic delta validation shares
# the badge source contract while keeping untouched future literals.
vol.Optional("value_source"): vol.Any(
None,
vol.Schema({}, extra=vol.ALLOW_EXTRA),
),
# climate current_temperature: badge + room-average vote (off unless True)
vol.Optional("use_climate_temp"): vol.Any(bool, None),
vol.Optional("room_id"): vol.Any(str, None),
# Keep in sync with DISPLAY_MODES in src/logic.ts. `ripple` is no longer
# offered, but remains accepted while old stores migrate to icon_ripple.
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", "value", "static_icon", None),
vol.Optional("ripple_color"): vol.Any(None, _COLOR),
vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None),
vol.Optional("size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0.2, max=6)), None),
vol.Optional("angle"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-360, max=360)), None),
vol.Optional("pdfs"): vol.All(
[vol.Schema({vol.Required("name"): _TEXT, vol.Required("url"): _URL}, extra=vol.ALLOW_EXTRA)],
vol.Length(max=MAX_PDFS),
),
},
extra=vol.ALLOW_EXTRA,
)
def _canonical_segment_key(a: list, b: list) -> tuple:
"""Endpoint-order-independent exact key after the shared canonicalizer."""
pa = (round(float(a[0]), 12), round(float(a[1]), 12))
pb = (round(float(b[0]), 12), round(float(b[1]), 12))
return (pa, pb) if pa <= pb else (pb, pa)
def _config_wall_segment_invariants(value: dict) -> dict:
"""Fail closed when a v8+ writer sends stale ids or wall projections."""
try:
model = int(value.get("model_version", 0))
except (TypeError, ValueError):
raise vol.Invalid("model_version must be an integer") from None
if model < 8:
return value
for space in value.get("spaces", []):
if model >= 9:
if "open_spans" in space or any("open_to" in room for room in space.get("rooms", [])):
raise vol.Invalid("v9 config must not contain legacy open boundaries")
# #529: пустой ключ данных не несёт и записи не стоит. Непустой
# остаётся отказом — это последняя линия против устаревшего клиента,
# и к ней человек приходит уже с понятным сообщением от сторожа
# переходов.
if model >= 10 and space.get("room_drafts"):
raise vol.Invalid("v10 config must not contain room_drafts")
segments = space.get("wall_segments")
if segments is None:
raise vol.Invalid("v8+ space requires wall_segments")
by_id = {segment["id"]: segment for segment in segments}
if len(by_id) != len(segments):
raise vol.Invalid("wall segment ids must be unique")
owners: dict[str, set[str]] = {segment_id: set() for segment_id in by_id}
for room in space.get("rooms", []):
poly = room.get("poly")
wall_ids = room.get("wall_ids")
if not poly or not wall_ids or len(poly) != len(wall_ids):
raise vol.Invalid("v8+ room wall_ids must match poly edges")
room_id = room["id"]
for index, segment_id in enumerate(wall_ids):
segment = by_id.get(segment_id)
if segment is None:
raise vol.Invalid("room wall id must reference the same space")
edge_key = _canonical_segment_key(poly[index], poly[(index + 1) % len(poly)])
if edge_key != _canonical_segment_key(segment["a"], segment["b"]):
raise vol.Invalid("room wall reference geometry must match its edge")
owners[segment_id].add(room_id)
if any(len(room_ids) not in (1, 2) for room_ids in owners.values()):
raise vol.Invalid("wall segment must have one or two room owners")
expected_walls = {
_canonical_segment_key(segment["a"], segment["b"]): float(segment["cm"])
for segment in segments if float(segment["cm"]) > 0
}
actual_walls: dict[tuple, float] = {}
for wall in space.get("walls", []):
if "a" not in wall or "b" not in wall:
raise vol.Invalid("wall compatibility projection requires exact endpoints")
key = _canonical_segment_key(wall["a"], wall["b"])
if key in actual_walls:
raise vol.Invalid("wall compatibility projection must be unique")
actual_walls[key] = float(wall["cm"])
if actual_walls != expected_walls:
raise vol.Invalid("wall compatibility projection must match wall_segments")
if model < 10:
for draft in space.get("room_drafts", []):
if any(not segment.get("id") for segment in draft.get("segments", [])):
raise vol.Invalid("v8+ draft wall segments require ids")
partitions = {item["id"]: item for item in space.get("partitions", [])}
wall_opening_intervals: dict[str, list[tuple[float, float]]] = {}
for opening in space.get("openings", []):
host = opening.get("host")
if host is None:
# #316 §3.3: a degraded migration may leave a contour opening
# unhosted. It is inert (no body/tunnel/cut) and excluded from
# the interval checks; a later edit can re-place it.
continue
if host["kind"] == "partition":
if host["id"] not in partitions:
raise vol.Invalid("partition opening host must exist in the same space")
if float(partitions[host["id"]]["cm"]) <= 0:
raise vol.Invalid("opening host must have positive thickness")
continue
segment = by_id.get(host["id"])
if segment is None:
raise vol.Invalid("wall opening host must exist in the same space")
if float(segment["cm"]) <= 0:
raise vol.Invalid("opening host must have positive thickness")
t = float(host["t"])
x = float(segment["a"][0]) + (float(segment["b"][0]) - float(segment["a"][0])) * t
y = float(segment["a"][1]) + (float(segment["b"][1]) - float(segment["a"][1])) * t
if abs(x - float(opening["x"])) > 2e-8 or abs(y - float(opening["y"])) > 2e-8:
raise vol.Invalid("wall opening geometry must match its host")
expected_angle = math.degrees(math.atan2(
float(segment["b"][1]) - float(segment["a"][1]),
float(segment["b"][0]) - float(segment["a"][0]),
))
if _angle_delta_mod_180(float(opening["angle"]), expected_angle) > 8:
raise vol.Invalid("wall opening angle must match its host")
span = ((segment["b"][0] - segment["a"][0]) ** 2
+ (segment["b"][1] - segment["a"][1]) ** 2) ** 0.5
along = t * span
length = float(opening["length"])
if length > span or along - length / 2 < -1e-9 or along + length / 2 > span + 1e-9:
raise vol.Invalid("wall opening must fit inside its host")
lo, hi = along - length / 2, along + length / 2
occupied = wall_opening_intervals.setdefault(host["id"], [])
if any(max(lo, old_lo) < min(hi, old_hi) - 1e-9
for old_lo, old_hi in occupied):
raise vol.Invalid("wall openings must not overlap")
occupied.append((lo, hi))
return value
# #437: the summary is shared configuration, so its wire shape is validated by
# the integration rather than trusted to whichever browser happened to save it.
def _summary_text(max_length: int):
"""Trim user text and reject whitespace-only/raw oversized WS values."""
def validate(value: object) -> str:
if not isinstance(value, str):
raise vol.Invalid("summary text must be a string")
text = value.strip()
if not text or len(text) > max_length:
raise vol.Invalid("summary text length is invalid")
return text
return validate
_SUMMARY_ID = _summary_text(64)
_SUMMARY_TITLE = _summary_text(48)
_SUMMARY_LABEL = _summary_text(64)
_SUMMARY_SCOPE = vol.Any(
vol.Schema({vol.Required("type"): vol.Equal("all")}, extra=vol.ALLOW_EXTRA),
vol.Schema({
vol.Required("type"): vol.Equal("space"),
vol.Required("space_id"): _SUMMARY_ID,
}, extra=vol.ALLOW_EXTRA),
)
_SUMMARY_SOURCE = vol.Any(
vol.Schema({
vol.Required("type"): vol.Equal("system"),
vol.Required("key"): vol.In(["device_count", "total_area", "datetime"]),
}, extra=vol.ALLOW_EXTRA),
vol.Schema({
vol.Required("type"): vol.Equal("entity"),
vol.Required("entity_id"): _summary_text(500),
}, extra=vol.ALLOW_EXTRA),
)
_SUMMARY_VALUE_SCHEMA = vol.Schema({
vol.Required("id"): _SUMMARY_ID,
vol.Required("label"): _SUMMARY_LABEL,
vol.Required("source"): _SUMMARY_SOURCE,
}, extra=vol.ALLOW_EXTRA)
_SUMMARY_BLOCK_SCHEMA = vol.Schema({
vol.Required("id"): _SUMMARY_ID,
vol.Required("title"): _SUMMARY_TITLE,
vol.Required("visible"): bool,
vol.Required("scope"): _SUMMARY_SCOPE,
vol.Required("values"): vol.All([_SUMMARY_VALUE_SCHEMA], vol.Length(max=20)),
}, extra=vol.ALLOW_EXTRA)
def _summary_panel_invariants(value: dict) -> dict:
"""Stable block/value ids are the reference identity used by the editor."""
block_ids: set[str] = set()
value_ids: set[str] = set()
for block in value.get("blocks", []):
block_id = block["id"]
if block_id in block_ids:
raise vol.Invalid("summary block ids must be unique")
block_ids.add(block_id)
for item in block.get("values", []):
value_id = item["id"]
if value_id in value_ids:
raise vol.Invalid("summary value ids must be unique")
value_ids.add(value_id)
return value
SUMMARY_PANEL_SCHEMA = vol.All(vol.Schema({
vol.Required("version"): vol.Equal(1),
vol.Required("title"): _SUMMARY_TITLE,
vol.Required("show_on_mobile"): bool,
vol.Required("blocks"): vol.All([_SUMMARY_BLOCK_SCHEMA], vol.Length(max=10)),
}, extra=vol.ALLOW_EXTRA), _summary_panel_invariants)
def _future_summary_panel(value: object) -> dict:
"""Keep a newer opaque namespace losslessly, with a bounded data envelope."""
if not isinstance(value, dict):
raise vol.Invalid("summary panel must be an object")
version = value.get("version")
if not isinstance(version, int) or isinstance(version, bool) or version <= 1:
raise vol.Invalid("unsupported summary panel version")
if len(json.dumps(value, separators=(",", ":"), ensure_ascii=False)) > 128 * 1024:
raise vol.Invalid("summary panel is too large")
return value
SUMMARY_PANEL_WIRE_SCHEMA = vol.Any(SUMMARY_PANEL_SCHEMA, _future_summary_panel)
def preserve_summary_panel_namespace(config: dict, previous: dict | None) -> None:
"""A stale client that does not know #437 cannot erase the shared panel."""
old_settings = (previous or {}).get("settings")
new_settings = config.get("settings")
if not isinstance(old_settings, dict) or "summary_panel" not in old_settings:
return
if not isinstance(new_settings, dict):
new_settings = {}
config["settings"] = new_settings
if "summary_panel" not in new_settings:
new_settings["summary_panel"] = copy.deepcopy(old_settings["summary_panel"])
def validate_summary_panel_references(
config: dict, previous: dict | None, readable_entity_ids: set[str]
) -> None:
"""Only newly introduced broken refs fail; stable-id legacy refs warn in UI."""
panel = (config.get("settings") or {}).get("summary_panel")
if not isinstance(panel, dict) or panel.get("version") != 1:
return
old_panel = ((previous or {}).get("settings") or {}).get("summary_panel")
old_blocks = {
block.get("id"): block for block in (old_panel or {}).get("blocks", [])
if isinstance(block, dict) and block.get("id")
} if isinstance(old_panel, dict) and old_panel.get("version") == 1 else {}
old_values = {
item.get("id"): item
for block in old_blocks.values()
for item in block.get("values", [])
if isinstance(item, dict) and item.get("id")
}
spaces = {space.get("id") for space in config.get("spaces", []) if isinstance(space, dict)}
for block in panel.get("blocks", []):
scope = block.get("scope") or {}
if scope.get("type") == "space" and scope.get("space_id") not in spaces:
old_scope = (old_blocks.get(block.get("id"), {}).get("scope") or {})
if old_scope.get("type") != "space" or old_scope.get("space_id") != scope.get("space_id"):
raise vol.Invalid("new summary space reference must exist")
for item in block.get("values", []):
source = item.get("source") or {}
entity_id = source.get("entity_id") if source.get("type") == "entity" else None
if entity_id and entity_id not in readable_entity_ids:
old_source = (old_values.get(item.get("id"), {}).get("source") or {})
if old_source.get("type") != "entity" or old_source.get("entity_id") != entity_id:
raise vol.Invalid("new summary entity reference must be readable")
def prepare_ordinary_summary_candidate(
candidate: dict,
previous: dict | None,
readable_entity_ids: set[str],
normalize,
) -> dict:
"""Apply the one summary-panel contract shared by ordinary config writers.
`normalize` owns the writer-specific schema/migration sequence. Preservation
must happen before it; change-aware reference validation must happen after
it. Authoritative full import/restore intentionally does not use this helper.
"""
preserve_summary_panel_namespace(candidate, previous)
checked = normalize(candidate)
validate_summary_panel_references(checked, previous, readable_entity_ids)
return checked
CONFIG_SCHEMA = vol.All(
vol.Schema(
{
vol.Optional("model_version"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=1_000_000)
),
vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
vol.Optional("settings", default=dict): vol.Schema(
{
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
# background around the plan, all spaces (a space may override)
vol.Optional("bg_color"): _COLOR,
# #377: the Background editor's default style for new decor
# objects; absence of the key means the built-in default
vol.Optional("decor_default_style"): vol.Schema(
{
vol.Optional("color"): _COLOR,
vol.Optional("opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
vol.Optional("width_cm"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=100)),
vol.Optional("fill"): bool,
vol.Optional("fill_color"): _COLOR,
vol.Optional("fill_opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
}
),
# sun on the plan (docs/SUN.md): global defaults
vol.Optional("north_deg"): _north_deg,
vol.Optional("bg_mode"): _BG_MODE,
vol.Optional("sun_rays"): bool,
vol.Optional("show_room_tooltip"): bool,
vol.Optional("summary_panel"): SUMMARY_PANEL_WIRE_SCHEMA,
vol.Optional("radar"): vol.Schema({}, extra=vol.ALLOW_EXTRA),
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
# legacy field so an existing stored config can still load; the
# frontend ignores it and removes it on the next settings save.
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("marker_area_snapshot"): vol.All(
vol.Schema({
_NONEMPTY_TEXT: vol.Schema({
vol.Required("binding"): vol.All(
_NONEMPTY_TEXT,
vol.Match(r"^(?:device|entity):.+$"),
),
vol.Required("area"): _NONEMPTY_TEXT,
}),
}),
vol.Length(max=MAX_MARKER_AREA_SNAPSHOT),
),
vol.Optional("fill_colors"): vol.Schema(
{
str: vol.Schema(
{
vol.Required("c"): _COLOR,
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
}
)
}
),
},
extra=vol.ALLOW_EXTRA,
),
},
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
),
canonicalize_config_geometry,
_config_wall_segment_invariants,
)