2026-08-13 16:23:18 +04:00

611 lines
26 KiB
Python

"""
Bridge to the on-robot agent (agent/x2_agent.py) over plain TCP.
This is the bridge that drives a real X2. The dashboard server itself never
imports rclpy - it talks JSON-lines to the agent running on PC2. That keeps the
dashboard usable on any machine (including Windows) and, more importantly, keeps
the web UI up when the robot is powered off so it can say so instead of dying.
Reconnection is automatic and continuous: switch the robot off and the UI shows
the offline gate; switch it on and the dashboard reattaches on its own.
"""
from __future__ import annotations
import asyncio
import base64
import contextlib
import json
import time
from . import netinfo, recovery, settings, x2_spec
from .bridge_base import Bridge, CommandResult
RECONNECT_MIN = 1.0
RECONNECT_MAX = 6.0
COMMAND_TIMEOUT = 12.0
# asyncio's StreamReader defaults to a 64 KiB limit per line, and readline()
# raises once a line exceeds it. Camera frames arrive base64-encoded at roughly
# 400 KB and a full ROS graph dump is over 100 KB, so the default silently tore
# the link down mid-command. 32 MiB leaves generous headroom.
STREAM_LIMIT = 32 * 1024 * 1024
class AgentBridge(Bridge):
name = "agent"
simulated = False
def __init__(self, hub, config: dict):
super().__init__(hub, config)
self._reader = None
self._writer = None
self._task: asyncio.Task | None = None
self._push_task: asyncio.Task | None = None
self._pending: dict[int, asyncio.Future] = {}
self._next_id = 1
self._connected = asyncio.Event()
self._closing = False
self._agent_info: dict = {}
# key -> (ts, bytes, content_type, flip_used)
self._frames: dict[str, tuple[float, bytes, str, bool | None]] = {}
self._attempts = 0
self._last_error = ""
self._recovering = False
# -- lifecycle ----------------------------------------------------------
@property
def host(self) -> str:
return (self.config.get("robot_host") or "").strip()
@property
def port(self) -> int:
return int(self.config.get("agent_port") or x2_spec.AGENT_PORT)
async def start(self) -> None:
if not self.host and not self.config.get("auto_discover"):
raise RuntimeError(
"No robot host configured. Set it in the Settings tab, run the "
"server with --robot <ip>, or enable auto-discovery."
)
self._closing = False
self.state.connection.transport = "agent"
self.state.connection.host = f"{self.host}:{self.port}"
self.state.connection.simulated = False
self.state.connection.online = False
self._task = asyncio.create_task(self._link_loop(), name="agent-link")
self._push_task = asyncio.create_task(self._push_loop(), name="agent-push")
# Give the first connection a moment so the UI opens attached rather
# than flashing the offline gate, but never block startup on it.
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(self._connected.wait(), timeout=4.0)
async def stop(self) -> None:
self._closing = True
for task in (self._task, self._push_task):
if task:
task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await task
await self._drop_link()
self.state.connection.online = False
self.state.connection.transport = "none"
async def reconfigure(self, config: dict) -> None:
"""
Adopt new settings, and re-dial immediately if the address changed.
Without the re-dial an operator who corrects the robot address sees
nothing happen until they restart the bridge by hand, because the link
loop only reads the address when it next reconnects - and a healthy
connection never reconnects.
"""
before = (self.host, self.port)
self.config = config
after = ((config.get("robot_host") or "").strip(),
int(config.get("agent_port") or x2_spec.AGENT_PORT))
if before != after and self._writer is not None:
await self.hub.emit("info", "robot",
f"Robot address changed to {after[0]}:{after[1]} - reconnecting")
self._attempts = 0
# Dropping the socket makes _connect_once return; the link loop then
# dials the new address on its next pass.
await self._drop_link()
# -- link ---------------------------------------------------------------
async def _link_loop(self) -> None:
while not self._closing:
try:
await self._connect_once()
except asyncio.CancelledError:
raise
except Exception as exc:
self._last_error = str(exc)
finally:
await self._drop_link()
if self._closing:
return
if self.state.connection.online:
# We were up and lost it - that is worth saying out loud.
self.state.connection.online = False
self.state.connection.error = self._last_error or "link lost"
await self.hub.emit("warn", "robot",
f"Lost connection to the robot ({self._last_error or 'link closed'})")
self._attempts += 1
# After a few plain retries, try harder: the robot may have come
# back on a different address, or be up with the agent not running.
if self._attempts % 4 == 0:
await self._attempt_recovery()
delay = min(RECONNECT_MAX, RECONNECT_MIN * (1.35 ** min(self._attempts, 8)))
await asyncio.sleep(delay)
async def _attempt_recovery(self) -> None:
"""
Try to get the robot back without the operator doing anything.
Two failure modes are handled, in order of likelihood after a power
cycle: the agent is not running (robot answers SSH but not the agent
port), and the robot moved to a different DHCP address.
"""
if self._recovering:
return
self._recovering = True
try:
config = settings.load()
port = int(config.get("agent_port") or x2_spec.AGENT_PORT)
host = self.host
# 1. Is the robot there at all, just without the agent running?
if host and config.get("auto_start_agent"):
info = await netinfo.probe_host(
host, ports=[port, int(config.get("robot_ssh_port") or 22)], timeout=1.5)
open_ports = info.get("open_ports") or []
if port in open_ports:
return # agent is up; the next retry will land
if 22 in open_ports:
await self._start_agent_on(host, config, port)
return
# 2. Otherwise look for it somewhere else on this network.
if not config.get("auto_discover"):
return
self._set_recovery("Looking for the robot on this network…")
await self.hub.emit("info", "robot",
"Robot not answering - scanning the network for it")
candidates = await recovery.find_robot(port, int(config.get("robot_ssh_port") or 22))
candidates = [c for c in candidates if c["host"] != host]
running = next((c for c in candidates if c["agent"]), None)
if running:
await self._adopt(running["host"], "found the agent running there")
return
if config.get("auto_start_agent"):
for candidate in candidates:
if not candidate["ssh"]:
continue
if await self._start_agent_on(candidate["host"], config, port, adopt=True):
return
self._set_recovery("")
except asyncio.CancelledError:
raise
except Exception as exc:
self._set_recovery("")
await self.hub.emit("warn", "robot", f"Recovery attempt failed: {exc}")
finally:
self._recovering = False
async def _start_agent_on(self, host: str, config: dict, port: int,
adopt: bool = False) -> bool:
self._set_recovery(f"Robot is on at {host} but the agent is not running — starting it…")
await self.hub.emit("info", "robot",
f"{host} is reachable but the agent is down - starting it over SSH")
ok, message = await recovery.start_agent(host, config)
if not ok:
self._set_recovery(message)
await self.hub.emit("warn", "robot", message)
return False
if await recovery.wait_for_agent(host, port, timeout=45.0):
self._set_recovery("")
if adopt:
await self._adopt(host, "started the agent there")
else:
await self.hub.emit("info", "robot", "Agent started - reconnecting")
return True
self._set_recovery("Started the agent, but it has not come up yet…")
return False
async def _adopt(self, host: str, why: str) -> None:
"""Switch to a newly found address and remember it."""
settings.save({"robot_host": host})
self.config = settings.load()
self.state.connection.host = f"{host}:{self.port}"
self._attempts = 0
self._set_recovery("")
await self.hub.emit("info", "robot", f"Robot found at {host} - {why}")
await self.hub.broadcast("settings", settings.describe())
def _set_recovery(self, message: str) -> None:
"""Progress text for the offline gate, so the operator sees us working."""
self.state.custom["recovery"] = message
if message:
self.state.connection.error = message
async def _connect_once(self) -> None:
host, port = self.host, self.port
if not host:
# Nothing to dial yet - let the recovery pass find one.
self.state.connection.error = "Looking for the robot on this network…"
raise ConnectionError("no robot address yet")
try:
self._reader, self._writer = await asyncio.wait_for(
asyncio.open_connection(host, port, limit=STREAM_LIMIT), timeout=5.0)
except (asyncio.TimeoutError, OSError) as exc:
self._last_error = f"{host}:{port} unreachable"
self.state.connection.error = (
f"Cannot reach the robot agent at {host}:{port}. "
"Is the robot powered on and on this network?"
)
raise ConnectionError(self._last_error) from exc
was_offline = not self.state.connection.online
self._attempts = 0
self._last_error = ""
self.state.connection.online = True
self.state.connection.since = time.time()
self.state.connection.error = ""
self._connected.set()
if was_offline:
await self.hub.emit("info", "robot", f"Connected to robot agent at {host}:{port}")
# Re-assert anything that does not survive a reconnect.
self.state.source_registered = False
asyncio.create_task(self._announce_self())
while not self._closing:
line = await self._reader.readline()
if not line:
raise ConnectionError("agent closed the connection")
try:
message = json.loads(line)
except json.JSONDecodeError:
continue
self._on_message(message)
async def _announce_self(self) -> None:
"""
Tell the robot this dashboard exists as a control source.
The motion controller ignores velocity from a source it has never heard
of, so this has to happen before the joystick will do anything. It is
not a takeover: registering only makes the dashboard *eligible*, and
arbitration still hands control to the highest-priority source that is
actively sending - the handheld RC (80) and mobile app (60) both
outrank us at 30.
Done automatically rather than left to a button, because there is no
situation where an operator wants the dashboard connected but not
allowed to drive, and "register input source" means nothing to someone
who just wants to move the robot.
"""
source = x2_spec.DASHBOARD_INPUT_SOURCE
for attempt in range(3):
await asyncio.sleep(1.0 if attempt == 0 else 3.0)
if not self.state.connection.online:
return
result = await self.register_input_source(
source["name"], source["priority"], source["timeout"])
if result.ok:
return
if attempt == 2:
await self.hub.emit(
"warn", "control",
f"Could not announce the dashboard to the robot: {result.message}. "
"Driving will be refused until this succeeds - use the Control tab to retry.",
)
async def _drop_link(self) -> None:
self._connected.clear()
writer, self._writer, self._reader = self._writer, None, None
if writer is not None:
with contextlib.suppress(Exception):
writer.close()
await writer.wait_closed()
for future in self._pending.values():
if not future.done():
future.set_exception(ConnectionError("link dropped"))
self._pending.clear()
def _on_message(self, message: dict) -> None:
kind = message.get("type")
if kind == "state":
self._apply_state(message.get("data") or {})
elif kind == "hello":
self._agent_info = message.get("data") or {}
self.state.custom["agent"] = self._agent_info
# Report the domain the agent is actually on, rather than whatever
# this machine has configured - only the agent's value is real.
try:
self.state.connection.ros_domain_id = int(
self._agent_info.get("ros_domain_id", 0))
except (TypeError, ValueError):
pass
elif kind == "result":
future = self._pending.pop(message.get("id"), None)
if future and not future.done():
future.set_result(message)
# -- state mapping ------------------------------------------------------
def _apply_state(self, data: dict) -> None:
s = self.state
s.mode = data.get("mode") or "UNKNOWN"
spec = x2_spec.MC_MODE_BY_ID.get(s.mode)
s.mode_desc = spec["label"] if spec else s.mode
status = data.get("mode_status")
s.mode_status = x2_spec.MC_ACTION_STATUS.get(status, str(status or ""))
for field in ("battery_pct", "battery_voltage", "battery_current",
"battery_temp", "battery_cycles", "pmu_temp",
"fan_rpm", "fan_pct"):
setattr(s, field, data.get(field))
s.charging = bool(data.get("charging"))
rails = {}
for spec_rail in x2_spec.PMU_RAILS:
live = (data.get("rails") or {}).get(spec_rail["key"])
if not live:
continue
rails[spec_rail["key"]] = {
"label": spec_rail["label"],
"nominal": spec_rail["nominal"],
"voltage": live.get("voltage"),
"current": live.get("current"),
"ok": live.get("ok", True),
}
s.rails = rails
s.imu = data.get("imu") or {}
s.joints = data.get("joints") or {}
s.hand_type = data.get("hand_type") or "None"
s.hand_state = data.get("hand_state") or {}
s.touch_head = data.get("touch_head") or {}
s.velocity = data.get("velocity") or s.velocity
s.velocity_command = data.get("velocity_command") or s.velocity_command
s.odom = data.get("odom") or s.odom
s.volume = data.get("volume") if data.get("volume") is not None else s.volume
s.muted = bool(data.get("muted"))
s.emoji_id = data.get("emoji_id")
s.led = data.get("led") or s.led
s.input_source = data.get("input_source") or ""
s.topic_stats = data.get("topic_stats") or {}
s.custom["pmu_raw"] = data.get("pmu_raw") or {}
s.custom["pmu_info"] = data.get("pmu_info") or {}
s.custom["cameras"] = data.get("cameras") or {}
# Which feeds are actually subscribed on the robot right now. The
# Vision tab reads this rather than tracking its own idea of on/off,
# so a second browser sees the true state instead of "off".
s.custom["streams"] = data.get("streams") or {}
s.custom["face_status"] = data.get("face_status")
s.custom["battery_capacity_mah"] = data.get("battery_capacity_mah")
s.custom["battery_power"] = data.get("battery_power")
s.custom["agent_version"] = data.get("agent_version")
s.custom["hand_left_type"] = data.get("hand_left_type")
s.custom["hand_right_type"] = data.get("hand_right_type")
s.updated = time.time()
async def _push_loop(self) -> None:
"""Forward state to browsers and record chart series."""
hz = max(1, int(self.config.get("telemetry_hz", 10)))
while True:
try:
await asyncio.sleep(1.0 / hz)
now = time.time()
if self.state.connection.online:
self._record(now)
await self.hub.broadcast("state", self.state.as_dict())
except asyncio.CancelledError:
raise
except Exception:
await asyncio.sleep(1.0)
def _record(self, now: float) -> None:
for key, value in (
("battery_pct", self.state.battery_pct),
("battery_voltage", self.state.battery_voltage),
("battery_current", self.state.battery_current),
("battery_temp", self.state.battery_temp),
("pmu_temp", self.state.pmu_temp),
("fan_pct", self.state.fan_pct),
("vel_forward", self.state.velocity.get("forward")),
("vel_lateral", self.state.velocity.get("lateral")),
("vel_angular", self.state.velocity.get("angular")),
):
if value is not None:
self.hub.record(key, value, now)
chest = self.state.imu.get("chest") or {}
for key, field in (("imu_roll", "roll"), ("imu_pitch", "pitch"), ("imu_yaw", "yaw")):
if field in chest:
self.hub.record(key, chest[field], now)
# -- command plumbing ---------------------------------------------------
async def _send(self, name: str, args: dict | None = None,
timeout: float = COMMAND_TIMEOUT) -> CommandResult:
if self._writer is None or not self.state.connection.online:
return CommandResult.failure(
"The robot is not connected. Power it on and wait for the dashboard to reattach."
)
message_id = self._next_id
self._next_id += 1
future: asyncio.Future = asyncio.get_running_loop().create_future()
self._pending[message_id] = future
payload = json.dumps({"type": "cmd", "id": message_id,
"name": name, "args": args or {}}) + "\n"
try:
self._writer.write(payload.encode())
await self._writer.drain()
except Exception as exc:
self._pending.pop(message_id, None)
return CommandResult.failure(f"Send failed: {exc}")
try:
reply = await asyncio.wait_for(future, timeout=timeout)
except asyncio.TimeoutError:
self._pending.pop(message_id, None)
return CommandResult.failure(f"The robot did not answer '{name}' within {timeout:g} s")
except ConnectionError:
return CommandResult.failure("Connection to the robot dropped mid-command")
return CommandResult(bool(reply.get("ok")),
reply.get("message", ""),
reply.get("detail"))
# -- commands -----------------------------------------------------------
async def set_mode(self, mode_id: str) -> CommandResult:
result = await self._send("set_mode", {"mode": mode_id})
if result.ok:
await self.hub.emit("info", "mode", f"Mode set to {mode_id}")
return result
async def get_mode(self) -> CommandResult:
return await self._send("get_mode")
async def set_velocity(self, forward: float, lateral: float, angular: float) -> CommandResult:
return await self._send("set_velocity",
{"forward": forward, "lateral": lateral, "angular": angular},
timeout=4.0)
async def stop_motion(self) -> CommandResult:
return await self._send("stop", timeout=4.0)
async def play_preset(self, motion: int, area: int, interrupt: bool = True) -> CommandResult:
return await self._send("preset", {"motion": motion, "area": area,
"interrupt": interrupt}, timeout=15.0)
async def set_joints(self, group: str, mode: str, targets: dict,
stiffness=None, damping=None) -> CommandResult:
return await self._send("set_joints", {
"group": group, "mode": mode, "targets": targets,
"stiffness": stiffness, "damping": damping,
})
async def set_hand(self, side: str, positions: list) -> CommandResult:
return await self._send("set_hand", {"side": side, "positions": positions})
async def speak(self, text: str, priority: int = 6, interrupt: bool = False) -> CommandResult:
return await self._send("speak", {"text": text, "priority": priority,
"interrupt": interrupt}, timeout=15.0)
async def set_volume(self, volume: int) -> CommandResult:
return await self._send("set_volume", {"volume": volume})
async def set_mute(self, muted: bool) -> CommandResult:
return await self._send("set_mute", {"muted": muted})
async def play_emoji(self, emotion_id: int, mode: int = 1, priority: int = 6) -> CommandResult:
return await self._send("emoji", {"emotion_id": emotion_id, "mode": mode,
"priority": priority})
async def set_led(self, mode: int, r: int, g: int, b: int, priority: int = 6,
keep: bool = True) -> CommandResult:
return await self._send("led", {"mode": mode, "r": r, "g": g, "b": b,
"priority": priority, "keep": keep})
async def register_input_source(self, name: str, priority: int, timeout: int) -> CommandResult:
result = await self._send("register_source", {"name": name, "priority": priority,
"timeout": timeout})
if result.ok:
self.state.source_registered = True
self.state.input_source = name
return result
async def publish_raw(self, topic: str, message_type: str, payload: dict) -> CommandResult:
return await self._send("publish_raw", {"topic": topic, "type": message_type,
"fields": payload})
async def call_service(self, service: str, service_type: str, payload: dict) -> CommandResult:
return CommandResult.failure(
"Arbitrary service calls are not exposed by the agent. Use the typed commands, "
"or add a handler to agent/x2_agent.py."
)
async def list_topics(self) -> list[dict]:
return list(self.state.topic_stats.values())
async def graph(self) -> dict:
result = await self._send("graph", timeout=10.0)
return result.detail if result.ok and result.detail else {"topics": [], "services": []}
# -- on-demand streams --------------------------------------------------
async def set_stream(self, key: str, active: bool) -> CommandResult:
"""Switch a camera or the LiDAR on or off on the robot."""
result = await self._send("stream_set", {"key": key, "active": bool(active)},
timeout=8.0)
if result.ok:
# A stopped feed must not keep serving its last frame from cache.
if not active:
self._frames.pop(key, None)
await self.hub.emit("info", "vision",
f"{key} switched {'on' if active else 'off'}")
return result
async def list_streams(self) -> CommandResult:
return await self._send("stream_list", timeout=6.0)
async def lidar_points(self) -> CommandResult:
return await self._send("lidar_points", timeout=8.0)
# -- camera -------------------------------------------------------------
async def camera_frame(self, camera_key: str, stream: str = "rgb",
flip: bool | None = None) -> bytes | None:
cached = self._frames.get(camera_key)
# The agent pushes on request; a 120 ms cache stops several browser tabs
# from each pulling the same frame off the robot.
if cached and (time.time() - cached[0]) < 0.12 and cached[3] == flip:
return cached[1]
args: dict = {"key": camera_key}
if flip is not None:
args["flip"] = bool(flip)
result = await self._send("camera_frame", args, timeout=6.0)
if not result.ok or not result.detail:
return None
try:
data = base64.b64decode(result.detail["b64"])
except (KeyError, ValueError):
return None
fmt = result.detail.get("format", "jpeg")
self._frames[camera_key] = (time.time(), data,
"image/png" if fmt == "png" else "image/jpeg",
flip)
return data
def frame_content_type(self, camera_key: str = "", stream: str = "rgb") -> str:
cached = self._frames.get(camera_key)
return cached[2] if cached else "image/jpeg"