mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Treat explicit empty route lists as authoritative, preserve them through single-space export, group deleted-space routes, and render vacuums from the immutable vacuum-only snapshot subset. Issue: #443 User-Visible: yes
213 lines
8.2 KiB
Python
213 lines
8.2 KiB
Python
"""Map-to-space routing for multi-floor vacuums (#162).
|
|
|
|
Python mirror of ``src/vacuum-routes.ts``. The recorder and the import/export
|
|
flows must answer "which floor is this map on?" exactly the way the card does,
|
|
so both sides are driven by the same shared fixtures in
|
|
``test/fixtures/vacuum-routes/``. Keep the two files in step: a divergence here
|
|
shows up as a robot drawn on the wrong floor, which is worse than no robot.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Any
|
|
|
|
VAC_ROUTE_LIMIT = 32
|
|
VAC_ROUTE_ID_MAX = 128
|
|
VAC_ROUTE_SOURCE_MAX = 255
|
|
VAC_ROUTE_MAP_ID_MAX = 255
|
|
VAC_ROUTE_ERROR = "invalid_vacuum_map_route"
|
|
|
|
_ENTITY_ID = re.compile(r"^[a-z_]+\.[a-zA-Z0-9_]+$")
|
|
|
|
|
|
def normalize_route_matrix(raw: Any) -> list[float] | None:
|
|
"""Six finite numbers, or nothing; a five-number matrix is not almost fine."""
|
|
if not isinstance(raw, (list, tuple)) or len(raw) != 6:
|
|
return None
|
|
out: list[float] = []
|
|
for value in raw:
|
|
# bool is an int in Python; a boolean is not a coordinate.
|
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
return None
|
|
number = float(value)
|
|
if number != number or number in (float("inf"), float("-inf")):
|
|
return None
|
|
out.append(number)
|
|
return out
|
|
|
|
|
|
def is_entity_id_like(value: Any) -> bool:
|
|
return isinstance(value, str) and bool(_ENTITY_ID.match(value))
|
|
|
|
|
|
def validate_marker_routes(
|
|
marker_id: str, routes: Any, space_ids: set[str] | None,
|
|
) -> list[dict[str, str]]:
|
|
"""Shape/uniqueness/reference issues for one marker's routes.
|
|
|
|
``space_ids`` None means the spaces are not known in this context (import
|
|
preview), so the referential check is skipped instead of failing every
|
|
route.
|
|
"""
|
|
if routes is None:
|
|
return []
|
|
|
|
def issue(route_id: str, reason: str) -> dict[str, str]:
|
|
return {
|
|
"code": VAC_ROUTE_ERROR, "markerId": marker_id,
|
|
"routeId": route_id, "reason": reason,
|
|
}
|
|
|
|
if not isinstance(routes, list):
|
|
return [issue("", "not_object")]
|
|
problems: list[dict[str, str]] = []
|
|
if len(routes) > VAC_ROUTE_LIMIT:
|
|
problems.append(issue("", "limit"))
|
|
seen_ids: set[str] = set()
|
|
seen_identity: set[str] = set()
|
|
for raw in routes:
|
|
if not isinstance(raw, dict):
|
|
problems.append(issue("", "not_object"))
|
|
continue
|
|
route_id = raw.get("id") if isinstance(raw.get("id"), str) else ""
|
|
if not route_id or len(route_id) > VAC_ROUTE_ID_MAX:
|
|
problems.append(issue(route_id or "", "id"))
|
|
continue
|
|
if route_id in seen_ids:
|
|
problems.append(issue(route_id, "duplicate_id"))
|
|
seen_ids.add(route_id)
|
|
source = raw.get("source")
|
|
if not is_entity_id_like(source) or len(source) > VAC_ROUTE_SOURCE_MAX:
|
|
problems.append(issue(route_id, "source"))
|
|
map_id = raw.get("map_id")
|
|
# An empty map id is a real id (resolve_map_id): type and length only.
|
|
if not isinstance(map_id, str) or len(map_id) > VAC_ROUTE_MAP_ID_MAX:
|
|
problems.append(issue(route_id, "map_id"))
|
|
space = raw.get("space")
|
|
if not isinstance(space, str) or not space:
|
|
problems.append(issue(route_id, "space"))
|
|
elif space_ids is not None and space not in space_ids:
|
|
problems.append(issue(route_id, "unknown_space"))
|
|
calibration = raw.get("calibration")
|
|
if calibration is not None and normalize_route_matrix(calibration) is None:
|
|
problems.append(issue(route_id, "calibration"))
|
|
if isinstance(source, str) and isinstance(map_id, str):
|
|
identity = source + " " + map_id
|
|
if identity in seen_identity:
|
|
problems.append(issue(route_id, "duplicate_identity"))
|
|
seen_identity.add(identity)
|
|
return problems
|
|
|
|
|
|
def legacy_route_id(marker_id: str, source: str, map_id: str) -> str:
|
|
return "legacy:" + marker_id + " " + source + " " + map_id
|
|
|
|
|
|
def effective_routes(
|
|
marker_id: str, vacuum: Any, dock_space: str, discovered_source: str | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""The routes a marker effectively has, legacy calibration included."""
|
|
if not isinstance(vacuum, dict):
|
|
return []
|
|
explicit = vacuum.get("map_routes")
|
|
if isinstance(explicit, list):
|
|
out = []
|
|
for route in explicit:
|
|
if not isinstance(route, dict) or not isinstance(route.get("id"), str):
|
|
continue
|
|
if not route["id"]:
|
|
continue
|
|
out.append({
|
|
"id": route["id"],
|
|
"source": str(route.get("source") or ""),
|
|
"map_id": str(route.get("map_id") or ""),
|
|
"space": str(route.get("space") or ""),
|
|
"calibration": normalize_route_matrix(route.get("calibration")),
|
|
})
|
|
return out
|
|
source = vacuum.get("source")
|
|
if not isinstance(source, str) or not source:
|
|
source = discovered_source or ""
|
|
if not source:
|
|
return []
|
|
calibration = vacuum.get("calibration")
|
|
if not isinstance(calibration, dict):
|
|
return []
|
|
out = []
|
|
for map_id, matrix in calibration.items():
|
|
normalized = normalize_route_matrix(matrix)
|
|
if normalized is None:
|
|
continue
|
|
out.append({
|
|
"id": legacy_route_id(marker_id, source, str(map_id)),
|
|
"source": source, "map_id": str(map_id), "space": dock_space,
|
|
"calibration": normalized,
|
|
})
|
|
return out
|
|
|
|
|
|
def resolve_route(
|
|
routes: list[dict[str, Any]], observed: dict[str, str], space_ids: set[str] | None,
|
|
) -> dict[str, Any]:
|
|
"""Pick the one route the robot is on right now — or refuse to pick.
|
|
|
|
List order must never decide a floor: two plausible routes are ambiguous,
|
|
not "the first one".
|
|
"""
|
|
matched: list[dict[str, Any]] = []
|
|
saw_telemetry = False
|
|
fallback_source = ""
|
|
fallback_map_id = ""
|
|
for route in routes:
|
|
source = route.get("source")
|
|
if source not in observed:
|
|
continue
|
|
seen = observed[source]
|
|
saw_telemetry = True
|
|
if not fallback_source:
|
|
fallback_source = source
|
|
fallback_map_id = seen
|
|
if seen == route.get("map_id"):
|
|
matched.append(route)
|
|
if len(matched) > 1:
|
|
return {"kind": "ambiguous", "routeIds": sorted(str(r.get("id")) for r in matched)}
|
|
if len(matched) == 1:
|
|
route = matched[0]
|
|
if space_ids is not None and route.get("space") not in space_ids:
|
|
return {"kind": "missing_space", "route": route}
|
|
matrix = normalize_route_matrix(route.get("calibration"))
|
|
if matrix is None:
|
|
return {"kind": "needs_calibration", "route": route}
|
|
return {"kind": "ready", "route": {**route, "calibration": matrix}}
|
|
if saw_telemetry:
|
|
return {"kind": "unmapped", "source": fallback_source, "mapId": fallback_map_id}
|
|
if observed:
|
|
source = sorted(observed)[0]
|
|
return {"kind": "unmapped", "source": source, "mapId": observed[source]}
|
|
return {"kind": "none"}
|
|
|
|
|
|
def adopt_legacy_run(
|
|
run: Any, routes: list[dict[str, Any]], root_source: str | None,
|
|
) -> dict[str, Any]:
|
|
"""Where a run recorded before #162 belongs (spec 11.3.1).
|
|
|
|
Such a run stores only ``{map_id, started, ended, points}``: it carries no
|
|
source at all. The one surviving witness of the subscription that wrote it
|
|
is the marker's root ``vacuum.source``, so that is what narrows the
|
|
candidates; without it only the map id is compared. Two candidates mean the
|
|
run is not drawn — not that the first one wins.
|
|
"""
|
|
if not isinstance(run, dict) or not isinstance(run.get("map_id"), str):
|
|
return {"kind": "orphan_run"}
|
|
map_id = run["map_id"]
|
|
root = root_source if isinstance(root_source, str) else ""
|
|
candidates = [route for route in routes if route.get("map_id") == map_id]
|
|
if root:
|
|
candidates = [route for route in candidates if route.get("source") == root]
|
|
if len(candidates) == 1:
|
|
return {"kind": "adopted", "route": candidates[0]}
|
|
if not candidates:
|
|
return {"kind": "orphan_run"}
|
|
return {"kind": "ambiguous_run", "routeIds": sorted(str(r.get("id")) for r in candidates)}
|