Source code for scene_service.map_binding

# SPDX-License-Identifier: MulanPSL-2.0
"""Map identity binding — how scene learns WHICH map it is scoped to.

The mapping service broadcasts its current identity {map_id, mode,
generation} latched on the `robonix/service/map/lifecycle` topic (see
capabilities/service/map/lifecycle.v1.toml). At startup scene probes that
broadcast once and, when present, binds to it — the broadcast is
authoritative, so the deploy no longer needs to hand-copy the same map_id
into two config blocks. Static fallbacks (manifest config / SCENE_MAP_ID
env / "default") remain for deployments where mapping is not up yet when
scene starts (the normal full-boot order) or does not broadcast at all.

`generation` is mapping's map-frame epoch: it bumps whenever the map
origin may have changed (mapping-mode session start, reset_map) and stays
put across localization round-trips. P2 records it and warns on runtime
change; acting on it (flush + re-anchor) is the P3 lifecycle linkage.

Split from service.py so the pure precedence rule and the one-shot ROS
read are unit-testable without atlas.
"""
from __future__ import annotations

import logging
import re
import time
from dataclasses import dataclass
from typing import Optional

log = logging.getLogger("scene.map_binding")

# map_id is used as a milvus filter literal and as a filename, so restrict
# it to a safe identifier charset (no quotes / spaces / separators) — keeps
# the predicate injection-free and the path escape-free. Anything else is
# squashed to "_"; empty falls back to "default". Every map_id-partitioned
# store (objects, scene-graph cache, annotations) MUST key on this one rule
# so the partitions always agree.
_MAP_ID_UNSAFE = re.compile(r"[^A-Za-z0-9._\-]")


[docs] def sanitize_map_id(raw: Optional[str]) -> str: cleaned = _MAP_ID_UNSAFE.sub("_", (raw or "").strip()) return cleaned or "default"
[docs] @dataclass(frozen=True) class MapBinding: """The resolved binding plus where it came from (for the startup log and for the runtime mismatch watcher).""" map_id: str source: str # "lifecycle" | "config" | "env" | "default" mode: str = "" # only set when source == "lifecycle" generation: Optional[int] = None # only set when source == "lifecycle"
[docs] def choose_map_binding( broadcast: Optional[dict], config_map_id: object, env_map_id: Optional[str], ) -> MapBinding: """Precedence: mapping's lifecycle broadcast (authoritative source of map identity) > manifest config.map_id > SCENE_MAP_ID env > "default". A broadcast with an empty map_id means mapping runs ephemeral (no named map) — treated as "no broadcast", scene falls back to static binding.""" if broadcast and str(broadcast.get("map_id") or ""): return MapBinding( map_id=str(broadcast["map_id"]), source="lifecycle", mode=str(broadcast.get("mode") or ""), generation=int(broadcast.get("generation") or 0), ) if config_map_id: return MapBinding(map_id=str(config_map_id), source="config") if env_map_id: return MapBinding(map_id=str(env_map_id), source="env") return MapBinding(map_id="default", source="default")
[docs] def read_latched_lifecycle(topic: str, timeout_s: float) -> Optional[dict]: """One-shot read of the latched MapLifecycle sample on `topic`. Runs on a PRIVATE rclpy context + executor so it neither initialises nor disturbs the default context the ingest hub owns (the hub calls rclpy.init() later and would raise on a double init). Returns {map_id, mode, generation} or None on timeout. Degrades to None with one warning when rclpy or the generated `map` interface package is missing (ros2_idl overlay not built) — binding then falls back to config/env, scene itself keeps working. """ try: import rclpy # type: ignore from rclpy.executors import SingleThreadedExecutor # type: ignore from rclpy.qos import ( # type: ignore DurabilityPolicy, HistoryPolicy, QoSProfile, ReliabilityPolicy, ) from map.msg import MapLifecycle # type: ignore # ros2_idl overlay except ImportError as e: log.warning( "[scene] lifecycle probe unavailable (%s) — falling back to " "static map binding (is the ros2_idl overlay built + sourced?)", e, ) return None ctx = None try: # Context/init INSIDE the guard: a broken RMW env must degrade to # None per the docstring promise, not escape and kill scene startup. ctx = rclpy.Context() rclpy.init(context=ctx, args=None) node = rclpy.create_node("scene_map_binding_probe", context=ctx) executor = SingleThreadedExecutor(context=ctx) executor.add_node(node) got: list = [] qos = QoSProfile( reliability=ReliabilityPolicy.RELIABLE, # TRANSIENT_LOCAL: the broadcast is latched; a late-joining # probe must receive the cached sample. durability=DurabilityPolicy.TRANSIENT_LOCAL, history=HistoryPolicy.KEEP_LAST, depth=1, ) node.create_subscription(MapLifecycle, topic, got.append, qos) deadline = time.monotonic() + timeout_s while not got and time.monotonic() < deadline: executor.spin_once(timeout_sec=0.2) if not got: return None msg = got[0] return { "map_id": str(msg.map_id), "mode": str(msg.mode), "generation": int(msg.generation), } except Exception as e: # noqa: BLE001 log.warning("[scene] lifecycle probe failed: %s", e) return None finally: if ctx is not None: try: rclpy.shutdown(context=ctx) except Exception: # noqa: BLE001 pass