""" The contract every robot bridge implements. Two bridges exist: bridge_ros2 (real rclpy over the network) and bridge_mock (a physics-lite simulation). The server and the whole frontend only ever talk to this interface, so the UI is identical whether or not a robot is present. """ from __future__ import annotations import time from dataclasses import dataclass, field, asdict from typing import Any from . import x2_spec @dataclass class CommandResult: ok: bool message: str = "" detail: Any = None def as_dict(self) -> dict: return {"ok": self.ok, "message": self.message, "detail": self.detail} @classmethod def failure(cls, message: str, detail: Any = None) -> "CommandResult": return cls(False, message, detail) @classmethod def success(cls, message: str = "OK", detail: Any = None) -> "CommandResult": return cls(True, message, detail) @dataclass class Connection: online: bool = False transport: str = "none" # "ros2" | "mock" | "none" host: str = "" ros_domain_id: int = 0 since: float | None = None error: str = "" simulated: bool = True @property def uptime_s(self) -> float: return (time.time() - self.since) if self.since else 0.0 @dataclass class RobotState: """Everything the dashboard knows about the robot right now.""" connection: Connection = field(default_factory=Connection) mode: str = "UNKNOWN" mode_desc: str = "" mode_status: str = "unknown" # Power (from /aima/hal/pmu/state) battery_pct: float | None = None battery_voltage: float | None = None battery_current: float | None = None battery_temp: float | None = None battery_cycles: int | None = None charging: bool = False pmu_temp: float | None = None fan_rpm: float | None = None fan_pct: float | None = None rails: dict[str, dict] = field(default_factory=dict) # IMU (from /aima/hal/imu/*/state) imu: dict[str, dict] = field(default_factory=dict) # Joints, keyed by group ("head" | "waist" | "arm" | "leg") joints: dict[str, list] = field(default_factory=dict) # End effector hand_type: str = "unknown" hand_state: dict[str, list] = field(default_factory=dict) # Sensors touch_head: dict = field(default_factory=dict) # Motion velocity: dict = field(default_factory=lambda: {"forward": 0.0, "lateral": 0.0, "angular": 0.0}) velocity_command: dict = field(default_factory=lambda: {"forward": 0.0, "lateral": 0.0, "angular": 0.0}) odom: dict = field(default_factory=lambda: {"x": 0.0, "y": 0.0, "yaw": 0.0}) # Interaction volume: int = 60 muted: bool = False emoji_id: int | None = None led: dict = field(default_factory=lambda: {"mode": 0, "r": 0, "g": 0, "b": 0}) # Arbitration input_source: str = "" input_sources: list = field(default_factory=list) source_registered: bool = False # Topic liveness, keyed by topic name topic_stats: dict[str, dict] = field(default_factory=dict) # Anything a plugin wants to publish into the shared state custom: dict[str, Any] = field(default_factory=dict) updated: float = field(default_factory=time.time) def as_dict(self) -> dict: data = asdict(self) data["connection"]["uptime_s"] = round(self.connection.uptime_s, 1) return data def mark_topic(self, topic: str, expected_hz: float | None = None) -> None: now = time.time() stat = self.topic_stats.get(topic) if stat is None: stat = {"topic": topic, "count": 0, "last": now, "hz": 0.0, "expected_hz": expected_hz, "first": now} self.topic_stats[topic] = stat gap = now - stat["last"] if gap > 0: # Exponential moving average keeps the rate readable rather than jittery. instant = 1.0 / gap stat["hz"] = round(instant if stat["count"] == 0 else stat["hz"] * 0.8 + instant * 0.2, 2) stat["count"] += 1 stat["last"] = now if expected_hz is not None: stat["expected_hz"] = expected_hz class Bridge: """Abstract robot bridge. Subclasses override what they can support.""" name = "base" simulated = True def __init__(self, hub, config: dict): self.hub = hub self.config = config self.state = RobotState() # -- lifecycle ---------------------------------------------------------- async def start(self) -> None: raise NotImplementedError async def stop(self) -> None: raise NotImplementedError async def reconfigure(self, config: dict) -> None: self.config = config # -- introspection ------------------------------------------------------ def snapshot(self) -> dict: return self.state.as_dict() async def list_topics(self) -> list[dict]: return list(self.state.topic_stats.values()) # -- commands ----------------------------------------------------------- async def set_mode(self, mode_id: str) -> CommandResult: return CommandResult.failure("set_mode not supported by this bridge") async def get_mode(self) -> CommandResult: return CommandResult.success(detail={"mode": self.state.mode}) async def set_velocity(self, forward: float, lateral: float, angular: float) -> CommandResult: return CommandResult.failure("set_velocity not supported by this bridge") async def stop_motion(self) -> CommandResult: """ Zero the velocity command unconditionally. Deliberately not routed through set_velocity: every precondition that method enforces (right mode, registered input source) is a reason to refuse *starting* motion, never a reason to refuse stopping it. An emergency stop that can be declined is not an emergency stop. """ self.state.velocity_command = {"forward": 0.0, "lateral": 0.0, "angular": 0.0} return CommandResult.success("Motion stopped") async def play_preset(self, motion: int, area: int, interrupt: bool = True) -> CommandResult: return CommandResult.failure("play_preset not supported by this bridge") async def set_joints(self, group: str, mode: str, targets: dict[str, float], stiffness: float | None = None, damping: float | None = None) -> CommandResult: return CommandResult.failure("set_joints not supported by this bridge") async def set_hand(self, side: str, positions: list[float]) -> CommandResult: return CommandResult.failure("set_hand not supported by this bridge") async def speak(self, text: str, priority: int = 6, interrupt: bool = False) -> CommandResult: return CommandResult.failure("speak not supported by this bridge") async def set_volume(self, volume: int) -> CommandResult: return CommandResult.failure("set_volume not supported by this bridge") async def set_mute(self, muted: bool) -> CommandResult: return CommandResult.failure("set_mute not supported by this bridge") async def play_emoji(self, emotion_id: int, mode: int = 1, priority: int = 6) -> CommandResult: return CommandResult.failure("play_emoji not supported by this bridge") async def set_led(self, mode: int, r: int, g: int, b: int, priority: int = x2_spec.LED_DEFAULT_PRIORITY, keep: bool = True) -> CommandResult: return CommandResult.failure("set_led not supported by this bridge") async def register_input_source(self, name: str, priority: int, timeout: int) -> CommandResult: return CommandResult.failure("register_input_source not supported by this bridge") async def camera_frame(self, camera_key: str, stream: str = "rgb", flip: bool | None = None) -> bytes | None: """Latest frame as JPEG bytes, or None if unavailable.""" return None # -- on-demand streams -------------------------------------------------- # Cameras and the LiDAR stay unsubscribed on the robot until switched on # here, so an unopened tab costs no bandwidth. async def set_stream(self, key: str, active: bool) -> CommandResult: return CommandResult.failure("set_stream not supported by this bridge") async def list_streams(self) -> CommandResult: return CommandResult.failure("list_streams not supported by this bridge") async def lidar_points(self) -> CommandResult: return CommandResult.failure("lidar_points not supported by this bridge") # -- escape hatch for plugins ------------------------------------------ async def publish_raw(self, topic: str, message_type: str, payload: dict) -> CommandResult: return CommandResult.failure("publish_raw not supported by this bridge") async def call_service(self, service: str, service_type: str, payload: dict) -> CommandResult: return CommandResult.failure("call_service not supported by this bridge")