1478 lines
61 KiB
Python
1478 lines
61 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
AGIBOT X2 dashboard agent - runs ON the robot (PC2).
|
|
|
|
Why this exists
|
|
---------------
|
|
A browser cannot speak ROS 2 DDS, and the dashboard is meant to stay reachable
|
|
even when the robot is switched off (so it can show "power the robot on" rather
|
|
than a dead link). So the dashboard server lives on the operator's machine and
|
|
this small agent lives on the robot, bridging ROS 2 to a plain TCP socket.
|
|
|
|
Protocol: newline-delimited JSON, no dependencies beyond rclpy.
|
|
|
|
-> {"type":"hello","data":{...}} (agent, on connect)
|
|
-> {"type":"state","data":{...}} (agent, ~10 Hz)
|
|
<- {"type":"cmd","id":7,"name":"set_mode","args":{}} (client)
|
|
-> {"type":"result","id":7,"ok":true,"message":"..."} (agent)
|
|
|
|
Run it with ROS 2 and the AimDK workspace sourced:
|
|
|
|
source /opt/ros/humble/setup.bash
|
|
source ~/aimdk/install/setup.bash
|
|
python3 x2_agent.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import base64
|
|
import importlib
|
|
import json
|
|
import math
|
|
import os
|
|
import signal
|
|
import socket
|
|
import threading
|
|
import time
|
|
import traceback
|
|
|
|
os.environ.setdefault("RCUTILS_LOGGING_SEVERITY", "ERROR")
|
|
|
|
import rclpy # noqa: E402
|
|
from rclpy.callback_groups import MutuallyExclusiveCallbackGroup # noqa: E402
|
|
from rclpy.executors import MultiThreadedExecutor # noqa: E402
|
|
from rclpy.qos import ( # noqa: E402
|
|
QoSProfile, ReliabilityPolicy, DurabilityPolicy, HistoryPolicy,
|
|
)
|
|
|
|
VERSION = "1.1.0"
|
|
DEFAULT_PORT = 8781
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Interface constants - these mirror backend/x2_spec.py and were read off this
|
|
# robot's own aimdk_msgs package.
|
|
# --------------------------------------------------------------------------
|
|
|
|
SRV = {
|
|
"get_mode": ("aimdk_msgs/srv/GetMcAction", "/aimdk_5Fmsgs/srv/GetMcAction"),
|
|
"set_mode": ("aimdk_msgs/srv/SetMcAction", "/aimdk_5Fmsgs/srv/SetMcAction"),
|
|
"preset": ("aimdk_msgs/srv/SetMcPresetMotion", "/aimdk_5Fmsgs/srv/SetMcPresetMotion"),
|
|
"set_source": ("aimdk_msgs/srv/SetMcInputSource", "/aimdk_5Fmsgs/srv/SetMcInputSource"),
|
|
"get_source": ("aimdk_msgs/srv/GetCurrentInputSource", "/aimdk_5Fmsgs/srv/GetCurrentInputSource"),
|
|
"tts": ("aimdk_msgs/srv/PlayTts", "/aimdk_5Fmsgs/srv/PlayTts"),
|
|
"emoji": ("aimdk_msgs/srv/PlayEmoji", "/aimdk_5Fmsgs/srv/PlayEmoji"),
|
|
"led": ("aimdk_msgs/srv/SetPmuLed", "/aimdk_5Fmsgs/srv/SetPmuLed"),
|
|
"set_volume": ("aimdk_msgs/srv/SetVolume", "/aimdk_5Fmsgs/srv/SetVolume"),
|
|
"get_volume": ("aimdk_msgs/srv/GetVolume", "/aimdk_5Fmsgs/srv/GetVolume"),
|
|
"set_mute": ("aimdk_msgs/srv/SetMute", "/aimdk_5Fmsgs/srv/SetMute"),
|
|
"get_mute": ("aimdk_msgs/srv/GetMute", "/aimdk_5Fmsgs/srv/GetMute"),
|
|
"hand_type": ("aimdk_msgs/srv/GetHandType", "/aimdk_5Fmsgs/srv/GetHandType"),
|
|
}
|
|
|
|
TOPIC_PMU = "/aima/hal/pmu/state"
|
|
TOPIC_IMU_CHEST = "/aima/hal/imu/chest/state"
|
|
TOPIC_IMU_TORSO = "/aima/hal/imu/torso/state"
|
|
TOPIC_TOUCH = "/aima/hal/sensor/touch_head"
|
|
TOPIC_HAND_STATE = "/aima/hal/joint/hand/state"
|
|
TOPIC_HAND_CMD = "/aima/hal/joint/hand/command"
|
|
TOPIC_VELOCITY = "/aima/mc/locomotion/velocity"
|
|
TOPIC_ODOM = "/aima/mc/leg_odometry"
|
|
TOPIC_FACE = "/face_ui_proxy/status"
|
|
|
|
JOINT_GROUPS = {
|
|
"head": ("/aima/hal/joint/head/state", "/aima/hal/joint/head/command"),
|
|
"waist": ("/aima/hal/joint/waist/state", "/aima/hal/joint/waist/command"),
|
|
"arm": ("/aima/hal/joint/arm/state", "/aima/hal/joint/arm/command"),
|
|
"leg": ("/aima/hal/joint/leg/state", "/aima/hal/joint/leg/command"),
|
|
}
|
|
|
|
# Every camera on this robot, verified against the live ROS graph rather than
|
|
# the datasheet. All of them are *on demand*: nothing is subscribed until the
|
|
# operator switches a feed on. A compressed frame off this robot is 170-430 KB
|
|
# and the six RGB feeds together publish at ~60 Hz, so subscribing to the lot at
|
|
# startup would push ~15 MB/s through the DDS stack for pictures nobody is
|
|
# looking at - on the same Wi-Fi the robot uses to walk.
|
|
#
|
|
# `flip` is a per-feed default the browser can override. Every value below was
|
|
# set by pulling a real frame and looking at it, not by guessing:
|
|
# * both head cameras and the stereo pair are mounted upright
|
|
# * the Orbbec RGB-D module is mounted UPSIDE DOWN - its colour frame shows
|
|
# the floor across the top and the chairs hanging from it - so its colour
|
|
# and depth feeds both default to a 180 rotation.
|
|
CAMERAS = {
|
|
"rgb_head_front_center": {
|
|
"topic": "/aima/hal/sensor/rgb_head_front_center/rgb_image/compressed",
|
|
"type": "sensor_msgs/msg/CompressedImage", "kind": "rgb", "flip": False,
|
|
},
|
|
"rgb_head_rear": {
|
|
"topic": "/aima/hal/sensor/rgb_head_rear/rgb_image/compressed",
|
|
"type": "sensor_msgs/msg/CompressedImage", "kind": "rgb", "flip": False,
|
|
},
|
|
"rgbd_head_front": {
|
|
"topic": "/aima/hal/sensor/rgbd_head_front/rgb_image/compressed",
|
|
"type": "sensor_msgs/msg/CompressedImage", "kind": "rgb", "flip": True,
|
|
},
|
|
"stereo_head_front_left": {
|
|
"topic": "/aima/hal/sensor/stereo_head_front_left/rgb_image/compressed",
|
|
"type": "sensor_msgs/msg/CompressedImage", "kind": "rgb", "flip": False,
|
|
},
|
|
"stereo_head_front_right": {
|
|
"topic": "/aima/hal/sensor/stereo_head_front_right/rgb_image/compressed",
|
|
"type": "sensor_msgs/msg/CompressedImage", "kind": "rgb", "flip": False,
|
|
},
|
|
"depth_front": {
|
|
"topic": "/camera/depth/image_raw/compressedDepth",
|
|
"type": "sensor_msgs/msg/CompressedImage", "kind": "depth", "flip": True,
|
|
},
|
|
"perception_input": {
|
|
"topic": "/mono_perception/debug/input_image",
|
|
"type": "sensor_msgs/msg/CompressedImage", "kind": "debug", "flip": False,
|
|
},
|
|
"perception_seg": {
|
|
"topic": "/mono_perception/debug/seg_mask",
|
|
"type": "sensor_msgs/msg/CompressedImage", "kind": "debug", "flip": False,
|
|
},
|
|
"perception_lines": {
|
|
"topic": "/mono_perception/debug/line_color_map",
|
|
"type": "sensor_msgs/msg/CompressedImage", "kind": "debug", "flip": False,
|
|
},
|
|
}
|
|
|
|
# Chest LiDAR. 25.5k points per scan at 2 Hz, 816 KB a message - far too much to
|
|
# hold open for a tab nobody has opened, so it is on demand like the cameras.
|
|
LIDAR_KEY = "lidar_chest_front"
|
|
LIDAR = {
|
|
"topic": "/aima/hal/sensor/lidar_chest_front/lidar_pointcloud_down_sampling",
|
|
"type": "sensor_msgs/msg/PointCloud2", "kind": "lidar",
|
|
}
|
|
|
|
# Points kept per scan after decimation. The browser draws these as a point
|
|
# cloud; beyond a few thousand the JSON hop costs more than the picture gains.
|
|
LIDAR_MAX_POINTS = 4000
|
|
|
|
# compressedDepth frames carry a 12-byte ConfigHeader before the PNG payload
|
|
# (an int32 format enum plus two float32 quantisation params). Verified by
|
|
# decoding a real frame off this robot: strip 12 and a 720x1280 uint16 image
|
|
# decodes; strip 8, 16 or 20 and it does not.
|
|
DEPTH_HEADER_BYTES = 12
|
|
# Depth arrives in millimetres. Measured maximum return on this unit is ~4.2 m,
|
|
# so the ramp covers 4.5 m: stretching it to 6 m spent a third of the colour
|
|
# range on distances the sensor never reports, and flattened the near field
|
|
# everyone actually looks at into a single shade of blue.
|
|
DEPTH_RANGE_MM = 4500
|
|
|
|
MC_ACTION_VALUES = {
|
|
"PASSIVE_DEFAULT": 1, "SOFT_EMERGENCY_STOP": 2, "DAMPING_DEFAULT": 3,
|
|
"ZERO_TORQUE_DEFAULT": 4, "JOINT_DEFAULT": 100, "JOINT_FREEZE": 101,
|
|
"STAND_DEFAULT": 200, "STAND_BODY_CONTROL": 201, "LOCOMOTION_DEFAULT": 300,
|
|
"RUN_DEFAULT": 301, "LOCOMOTION_STEP": 302, "VR_REMOTE_CONTROLLER": 400,
|
|
"SIT_DOWN_DEFAULT": 2000, "CROUCH_DOWN_DEFAULT": 2002, "LIE_DOWN_DEFAULT": 2004,
|
|
"STAND_UP_DEFAULT": 2005, "ASCEND_STAIRS": 2006, "DESCEND_STAIRS": 2008,
|
|
}
|
|
MC_VALUE_TO_NAME = {v: k for k, v in MC_ACTION_VALUES.items()}
|
|
|
|
PMU_RAILS = [
|
|
("bus_48v", "bus_48v_voltage", "bus_48v_current", 48.0),
|
|
("output_48v", "output_48v_voltage", "output_48v_current", 48.0),
|
|
("output_12v", "output_12v_voltage", "output_12v_current", 12.0),
|
|
("head_power", "head_power_voltage", "head_power_current", 24.0),
|
|
("orin", "orin_voltage", "orin_current", 19.0),
|
|
("rk3588", "rk3588_voltage", "rk3588_current", 12.0),
|
|
("fan", "fan_voltage", None, 12.0),
|
|
("bus_48v_pmos", "bus_48v_pmos_voltage", None, 48.0),
|
|
]
|
|
|
|
DRIVEABLE_MODES = {"LOCOMOTION_DEFAULT", "LOCOMOTION_STEP", "RUN_DEFAULT",
|
|
"STAND_DEFAULT", "STAND_BODY_CONTROL"}
|
|
|
|
DEADMAN_S = 0.5
|
|
VELOCITY_HZ = 20
|
|
|
|
|
|
def load_type(path: str):
|
|
pkg, kind, name = path.split("/")
|
|
return getattr(importlib.import_module(f"{pkg}.{kind}"), name)
|
|
|
|
|
|
def seq(value):
|
|
"""
|
|
Normalise a ROS array field to a plain list.
|
|
|
|
Fixed-size array fields come back as numpy arrays, where `value or default`
|
|
raises ValueError instead of testing emptiness. Everything that touches an
|
|
array field goes through here.
|
|
"""
|
|
if value is None:
|
|
return []
|
|
try:
|
|
return list(value)
|
|
except TypeError:
|
|
return []
|
|
|
|
|
|
def quat_to_rpy(x, y, z, w):
|
|
roll = math.atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y))
|
|
s = 2 * (w * y - z * x)
|
|
pitch = math.copysign(math.pi / 2, s) if abs(s) >= 1 else math.asin(s)
|
|
yaw = math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z))
|
|
return roll, pitch, yaw
|
|
|
|
|
|
def flatten(msg, depth=0):
|
|
"""ROS message -> JSON-safe dict."""
|
|
if depth > 3:
|
|
return None
|
|
try:
|
|
fields = msg.get_fields_and_field_types()
|
|
except AttributeError:
|
|
return msg if isinstance(msg, (int, float, str, bool)) else str(msg)[:120]
|
|
out = {}
|
|
for name in fields:
|
|
value = getattr(msg, name, None)
|
|
if isinstance(value, (int, float, str, bool)):
|
|
out[name] = value
|
|
elif hasattr(value, "get_fields_and_field_types"):
|
|
out[name] = flatten(value, depth + 1)
|
|
else:
|
|
try:
|
|
out[name] = [flatten(v, depth + 1) for v in list(value)[:32]]
|
|
except TypeError:
|
|
pass
|
|
return out
|
|
|
|
|
|
class Agent:
|
|
def __init__(self, node):
|
|
self.node = node
|
|
self.lock = threading.Lock()
|
|
self.state = {
|
|
"agent_version": VERSION,
|
|
"hostname": socket.gethostname(),
|
|
"mode": "UNKNOWN", "mode_desc": "", "mode_value": None, "mode_status": "",
|
|
"battery_pct": None, "battery_voltage": None, "battery_current": None,
|
|
"battery_temp": None, "battery_cycles": None, "battery_capacity_mah": None,
|
|
"battery_power": None, "charging": False,
|
|
"pmu_temp": None, "fan_rpm": None, "fan_pct": None,
|
|
"rails": {}, "pmu_info": {}, "pmu_raw": {},
|
|
"imu": {}, "joints": {}, "hand_type": "None", "hand_state": {},
|
|
"touch_head": {"touched": False, "zones": []},
|
|
"velocity": {"forward": 0.0, "lateral": 0.0, "angular": 0.0},
|
|
"velocity_command": {"forward": 0.0, "lateral": 0.0, "angular": 0.0},
|
|
"odom": {"x": 0.0, "y": 0.0, "yaw": 0.0},
|
|
"volume": None, "muted": False, "emoji_id": None, "face_status": None,
|
|
"led": {"mode": 0, "r": 0, "g": 0, "b": 0},
|
|
"input_source": "", "source_registered": False,
|
|
"topic_stats": {}, "cameras": {}, "streams": {},
|
|
}
|
|
self.frames = {} # camera key -> (ts, bytes, format)
|
|
self.clients = set()
|
|
self.publishers = {}
|
|
self.service_clients = {}
|
|
self.last_velocity_cmd = 0.0
|
|
self.led_keepalive = None
|
|
self.loop = None
|
|
|
|
# -- on-demand streams ------------------------------------------------
|
|
# Cameras and the LiDAR are not subscribed until asked for. `_streams`
|
|
# is the live registry; `_stream_wanted` is what the operator has asked
|
|
# for. The two are reconciled on the executor thread (see
|
|
# _reconcile_streams) because rclpy entity creation is not thread-safe.
|
|
self._streams = {} # key -> {"sub": obj, "spec": dict}
|
|
self._stream_wanted = {} # key -> bool
|
|
self._stream_lock = threading.Lock()
|
|
|
|
# Callback groups matter enormously here, and getting them wrong fails
|
|
# silently. Three separate groups, for three separate reasons:
|
|
#
|
|
# * Everything defaults into ONE MutuallyExclusiveCallbackGroup, which
|
|
# runs one callback at a time for the whole node. That group already
|
|
# carries two 500 Hz IMUs, four 100 Hz joint arrays and 100 Hz touch.
|
|
# Adding six camera feeds on top - each callback copying 170-430 KB -
|
|
# starved the reconcile timer within seconds of the first camera
|
|
# delivering data: switching feeds on and off stopped working while
|
|
# telemetry carried on looking perfectly healthy.
|
|
#
|
|
# * The timer therefore gets a group to itself, so nothing can delay it.
|
|
#
|
|
# * The streams get their own MutuallyExclusive group - deliberately NOT
|
|
# Reentrant. A reentrant group has no concurrency limit, so seven live
|
|
# feeds happily occupied all six executor threads and starved the
|
|
# timer all over again, just more slowly. Serialising them costs
|
|
# nothing (each callback is a memcpy - 60 frames/s measured at about
|
|
# 3% of one thread) and bounds their thread use at exactly one.
|
|
self._stream_group = MutuallyExclusiveCallbackGroup()
|
|
self._control_group = MutuallyExclusiveCallbackGroup()
|
|
self.cloud = None # (ts, [[x,y,z,intensity], ...])
|
|
for key in list(CAMERAS) + [LIDAR_KEY]:
|
|
self.state["streams"][key] = {
|
|
"key": key, "active": False, "frames": 0, "last": None,
|
|
"since": None, "error": "",
|
|
}
|
|
|
|
self._setup()
|
|
|
|
# -- ROS wiring ---------------------------------------------------------
|
|
|
|
def _qos(self, reliable=True, transient=False, depth=5):
|
|
return QoSProfile(
|
|
reliability=ReliabilityPolicy.RELIABLE if reliable else ReliabilityPolicy.BEST_EFFORT,
|
|
durability=DurabilityPolicy.TRANSIENT_LOCAL if transient else DurabilityPolicy.VOLATILE,
|
|
history=HistoryPolicy.KEEP_LAST, depth=depth,
|
|
)
|
|
|
|
def _sub(self, type_path, topic, handler, depth=2):
|
|
"""
|
|
Subscribe with BEST_EFFORT + VOLATILE.
|
|
|
|
In DDS a reader matches a writer when the writer *offers at least* what
|
|
the reader *requests*. BEST_EFFORT/VOLATILE requests the least of
|
|
anything, so this one profile matches every publisher on the robot -
|
|
the RELIABLE ones and the TRANSIENT_LOCAL ones alike. Requesting more
|
|
(an earlier version subscribed twice, once TRANSIENT_LOCAL) buys nothing
|
|
for live telemetry and makes Fast DDS retain history it then complains
|
|
about overflowing on the high-rate topics.
|
|
"""
|
|
try:
|
|
cls = load_type(type_path)
|
|
except Exception as exc:
|
|
print(f"[agent] cannot load {type_path} for {topic}: {exc}")
|
|
return
|
|
try:
|
|
self.node.create_subscription(
|
|
cls, topic, self._guard(topic, handler),
|
|
self._qos(reliable=False, transient=False, depth=depth))
|
|
except Exception as exc:
|
|
print(f"[agent] subscribe failed {topic}: {exc}")
|
|
|
|
def _guard(self, topic, handler):
|
|
"""
|
|
Wrap a subscription callback so it can never kill the executor.
|
|
|
|
rclpy lets an exception raised inside a callback propagate out of
|
|
MultiThreadedExecutor.spin(), which terminates the spin thread outright:
|
|
every subscription, timer and pending service response stops, silently,
|
|
for the life of the process. One malformed message must not be able to
|
|
take the whole bridge down, so each callback swallows and reports
|
|
instead.
|
|
"""
|
|
state = {"errors": 0}
|
|
|
|
def wrapped(msg):
|
|
try:
|
|
handler(msg)
|
|
except Exception as exc:
|
|
state["errors"] += 1
|
|
if state["errors"] <= 3:
|
|
print(f"[agent] callback error on {topic}: "
|
|
f"{type(exc).__name__}: {exc}")
|
|
traceback.print_exc()
|
|
elif state["errors"] == 4:
|
|
print(f"[agent] further errors on {topic} suppressed")
|
|
self.state.setdefault("callback_errors", {})[topic] = state["errors"]
|
|
|
|
return wrapped
|
|
|
|
def _setup(self):
|
|
self._sub("aimdk_msgs/msg/PmuState", TOPIC_PMU, self.on_pmu)
|
|
self._sub("sensor_msgs/msg/Imu", TOPIC_IMU_CHEST, lambda m: self.on_imu("chest", m))
|
|
self._sub("sensor_msgs/msg/Imu", TOPIC_IMU_TORSO, lambda m: self.on_imu("torso", m))
|
|
self._sub("aimdk_msgs/msg/TouchState", TOPIC_TOUCH, self.on_touch)
|
|
self._sub("aimdk_msgs/msg/HandStateArray", TOPIC_HAND_STATE, self.on_hand)
|
|
self._sub("nav_msgs/msg/Odometry", TOPIC_ODOM, self.on_odom)
|
|
self._sub("aimdk_msgs/msg/FaceEmojiStatus", TOPIC_FACE, self.on_face)
|
|
self._sub("aimdk_msgs/msg/McLocomotionVelocity", TOPIC_VELOCITY, self.on_velocity)
|
|
|
|
for key, (state_topic, _) in JOINT_GROUPS.items():
|
|
self._sub("aimdk_msgs/msg/JointStateArray", state_topic,
|
|
(lambda k: (lambda m: self.on_joints(k, m)))(key))
|
|
|
|
# Cameras and the LiDAR are deliberately NOT subscribed here. They are
|
|
# created and destroyed at runtime by _reconcile_streams below, so a
|
|
# feed costs nothing until somebody switches it on.
|
|
|
|
# Every publisher and service client is created here, before the
|
|
# executor starts spinning. rclpy is not thread-safe for entity
|
|
# creation: making a client from a worker thread while the executor is
|
|
# spinning wedges the executor, and subscriptions silently stop firing.
|
|
for alias in SRV:
|
|
try:
|
|
type_path, name = SRV[alias]
|
|
self.service_clients[alias] = self.node.create_client(
|
|
load_type(type_path), name)
|
|
except Exception as exc:
|
|
print(f"[agent] cannot create client for {alias}: {exc}")
|
|
|
|
for topic, type_path in (
|
|
(TOPIC_VELOCITY, "aimdk_msgs/msg/McLocomotionVelocity"),
|
|
(TOPIC_HAND_CMD, "aimdk_msgs/msg/HandCommandArray"),
|
|
*[(cmd_topic, "aimdk_msgs/msg/JointCommandArray")
|
|
for _, cmd_topic in JOINT_GROUPS.values()],
|
|
):
|
|
try:
|
|
self.publishers[topic] = self.node.create_publisher(
|
|
load_type(type_path), topic, self._qos(depth=10))
|
|
except Exception as exc:
|
|
print(f"[agent] cannot create publisher for {topic}: {exc}")
|
|
|
|
# Heartbeat. If this stops incrementing the executor has wedged, which
|
|
# is otherwise invisible - subscriptions just quietly stop arriving.
|
|
self.node.create_timer(1.0, self._on_heartbeat)
|
|
|
|
# Reconciles requested streams with live subscriptions. This has to be a
|
|
# timer rather than something the command handler does directly:
|
|
# commands run on an asyncio worker thread (see serve()), and creating
|
|
# or destroying a subscription off the executor thread while the
|
|
# executor is spinning wedges it - every subscription and timer stops,
|
|
# silently, for the life of the process. Timer callbacks run *on* the
|
|
# executor, which is exactly where this work is safe.
|
|
#
|
|
# _control_group, not the default one: see the note in __init__ about
|
|
# this timer being starved by the high-rate telemetry callbacks.
|
|
self.node.create_timer(0.2, self._reconcile_streams,
|
|
callback_group=self._control_group)
|
|
|
|
def _on_heartbeat(self):
|
|
try:
|
|
self.state["spin_ticks"] = self.state.get("spin_ticks", 0) + 1
|
|
except Exception:
|
|
pass
|
|
|
|
# -- on-demand streams --------------------------------------------------
|
|
|
|
def want_stream(self, key: str, active: bool) -> None:
|
|
"""Record a request. The executor timer does the actual ROS work."""
|
|
with self._stream_lock:
|
|
self._stream_wanted[key] = active
|
|
|
|
def stop_all_streams(self, why: str = "") -> None:
|
|
with self._stream_lock:
|
|
if not any(self._stream_wanted.values()):
|
|
return
|
|
for key in self._stream_wanted:
|
|
self._stream_wanted[key] = False
|
|
print(f"[agent] stopping all streams{f' ({why})' if why else ''}")
|
|
|
|
def _reconcile_streams(self):
|
|
"""Bring live subscriptions in line with what has been asked for.
|
|
|
|
Runs on the executor thread, ~5 Hz. Cheap when nothing has changed.
|
|
"""
|
|
with self._stream_lock:
|
|
wanted = dict(self._stream_wanted)
|
|
|
|
for key, active in wanted.items():
|
|
live = key in self._streams
|
|
if active == live:
|
|
continue
|
|
if active:
|
|
self._open_stream(key)
|
|
else:
|
|
self._close_stream(key)
|
|
|
|
def _open_stream(self, key: str):
|
|
spec = LIDAR if key == LIDAR_KEY else CAMERAS.get(key)
|
|
if spec is None:
|
|
return
|
|
entry = self.state["streams"].setdefault(key, {"key": key})
|
|
|
|
try:
|
|
cls = load_type(spec["type"])
|
|
except Exception as exc:
|
|
entry.update(active=False, error=f"cannot load {spec['type']}: {exc}")
|
|
with self._stream_lock:
|
|
self._stream_wanted[key] = False
|
|
return
|
|
|
|
handler = (self.on_cloud if key == LIDAR_KEY
|
|
else (lambda k: (lambda m: self.on_frame(k, m)))(key))
|
|
try:
|
|
sub = self.node.create_subscription(
|
|
cls, spec["topic"], self._guard(spec["topic"], handler),
|
|
# depth=1: only the newest frame or scan is ever served, so
|
|
# queueing older ones is pure memory for data that is already
|
|
# stale by the time anyone asks.
|
|
self._qos(reliable=False, transient=False, depth=1),
|
|
callback_group=self._stream_group)
|
|
except Exception as exc:
|
|
entry.update(active=False, error=str(exc))
|
|
with self._stream_lock:
|
|
self._stream_wanted[key] = False
|
|
return
|
|
|
|
self._streams[key] = {"sub": sub, "spec": spec}
|
|
entry.update(active=True, error="", since=time.time(), frames=0, last=None)
|
|
print(f"[agent] stream on: {key} -> {spec['topic']}")
|
|
|
|
def _close_stream(self, key: str):
|
|
entry = self._streams.pop(key, None)
|
|
if entry is not None:
|
|
try:
|
|
self.node.destroy_subscription(entry["sub"])
|
|
except Exception as exc:
|
|
print(f"[agent] destroy_subscription failed for {key}: {exc}")
|
|
# Drop the retained payload too - a stopped feed must not keep serving
|
|
# the last picture it saw as though it were live.
|
|
self.frames.pop(key, None)
|
|
if key == LIDAR_KEY:
|
|
self.cloud = None
|
|
self.state["cameras"].pop(key, None)
|
|
status = self.state["streams"].setdefault(key, {"key": key})
|
|
status.update(active=False, since=None, last=None)
|
|
print(f"[agent] stream off: {key}")
|
|
|
|
def mark(self, topic):
|
|
stats = self.state["topic_stats"]
|
|
now = time.time()
|
|
entry = stats.get(topic)
|
|
if entry is None:
|
|
stats[topic] = {"topic": topic, "count": 1, "last": now, "hz": 0.0}
|
|
return
|
|
gap = now - entry["last"]
|
|
if gap > 0:
|
|
inst = 1.0 / gap
|
|
entry["hz"] = round(inst if entry["count"] < 2 else entry["hz"] * 0.85 + inst * 0.15, 2)
|
|
entry["count"] += 1
|
|
entry["last"] = now
|
|
|
|
# -- subscription handlers ---------------------------------------------
|
|
|
|
def on_pmu(self, msg):
|
|
s = self.state
|
|
g = lambda f, d=None: getattr(msg, f, d) # noqa: E731
|
|
|
|
s["battery_pct"] = float(g("battery_remaining_capacity_percentage", 0) or 0)
|
|
s["battery_voltage"] = g("battery_voltage")
|
|
s["battery_current"] = g("battery_current")
|
|
s["battery_temp"] = g("battery_temperature")
|
|
s["battery_cycles"] = g("battery_cycle_count")
|
|
s["battery_capacity_mah"] = g("battery_remaining_capacity")
|
|
s["battery_power"] = g("battery_output_power")
|
|
s["pmu_temp"] = g("pmu_temperature")
|
|
s["fan_rpm"] = g("fan_speed")
|
|
s["fan_pct"] = g("fan_pecentage")
|
|
current = s["battery_current"]
|
|
s["charging"] = bool(current is not None and current > 0.05)
|
|
|
|
rails = {}
|
|
for key, vfield, cfield, nominal in PMU_RAILS:
|
|
voltage = g(vfield)
|
|
current = g(cfield) if cfield else None
|
|
rails[key] = {
|
|
"voltage": voltage, "current": current, "nominal": nominal,
|
|
"ok": voltage is None or voltage > nominal * 0.8,
|
|
}
|
|
s["rails"] = rails
|
|
|
|
s["pmu_info"] = {f: g(f) for f in (
|
|
"bms_manufacturer", "bms_serial_number", "bms_hardware_version",
|
|
"bms_software_version", "pmu_software_version", "pmu_hardware_version",
|
|
"pmu_protocol_version")}
|
|
s["pmu_raw"] = flatten(msg)
|
|
self.mark(TOPIC_PMU)
|
|
|
|
def on_imu(self, key, msg):
|
|
q = msg.orientation
|
|
roll, pitch, yaw = quat_to_rpy(q.x, q.y, q.z, q.w)
|
|
self.state["imu"][key] = {
|
|
"roll": round(roll, 5), "pitch": round(pitch, 5), "yaw": round(yaw, 5),
|
|
"accel_x": round(msg.linear_acceleration.x, 4),
|
|
"accel_y": round(msg.linear_acceleration.y, 4),
|
|
"accel_z": round(msg.linear_acceleration.z, 4),
|
|
"gyro_x": round(msg.angular_velocity.x, 5),
|
|
"gyro_y": round(msg.angular_velocity.y, 5),
|
|
"gyro_z": round(msg.angular_velocity.z, 5),
|
|
}
|
|
self.mark(TOPIC_IMU_CHEST if key == "chest" else TOPIC_IMU_TORSO)
|
|
|
|
def on_touch(self, msg):
|
|
# TouchState's fixed-size arrays arrive as numpy arrays, and
|
|
# `numpy_array or []` raises ValueError ("truth value ... is
|
|
# ambiguous"). Convert explicitly; never lean on truthiness here.
|
|
touched = [bool(v) for v in seq(getattr(msg, "is_touched", None))]
|
|
self.state["touch_head"] = {
|
|
"touched": any(touched),
|
|
"zones": touched,
|
|
"data": [int(v) for v in seq(getattr(msg, "data", None))],
|
|
"threshold": [int(v) for v in seq(getattr(msg, "threshold", None))],
|
|
"event_type": int(getattr(msg, "event_type", 0) or 0),
|
|
}
|
|
self.mark(TOPIC_TOUCH)
|
|
|
|
def on_hand(self, msg):
|
|
def side(prefix):
|
|
return [{
|
|
"name": getattr(h, "name", "") or f"j{i}",
|
|
"position": round(float(getattr(h, "position", 0.0)), 4),
|
|
"velocity": round(float(getattr(h, "velocity", 0.0)), 4),
|
|
"effort": round(float(getattr(h, "effort", 0.0)), 4),
|
|
"fault": int(getattr(h, "faultcode", 0) or 0),
|
|
} for i, h in enumerate(seq(getattr(msg, f"{prefix}_hands", None)))]
|
|
|
|
left_type = getattr(getattr(msg, "left_hand_type", None), "value", 0)
|
|
right_type = getattr(getattr(msg, "right_hand_type", None), "value", 0)
|
|
self.state["hand_state"] = {
|
|
"left": side("left"), "right": side("right"),
|
|
"left_type": int(left_type), "right_type": int(right_type),
|
|
}
|
|
self.mark(TOPIC_HAND_STATE)
|
|
|
|
def on_joints(self, key, msg):
|
|
rows = []
|
|
for j in seq(getattr(msg, "joints", None)):
|
|
rows.append({
|
|
"name": getattr(j, "name", ""),
|
|
"position": round(float(getattr(j, "position", 0.0)), 5),
|
|
"velocity": round(float(getattr(j, "velocity", 0.0)), 5),
|
|
"effort": round(float(getattr(j, "effort", 0.0)), 4),
|
|
"error": int(getattr(j, "error_code", 0) or 0),
|
|
})
|
|
self.state["joints"][key] = rows
|
|
self.mark(JOINT_GROUPS[key][0])
|
|
|
|
def on_odom(self, msg):
|
|
p = msg.pose.pose.position
|
|
q = msg.pose.pose.orientation
|
|
_, _, yaw = quat_to_rpy(q.x, q.y, q.z, q.w)
|
|
self.state["odom"] = {"x": round(p.x, 4), "y": round(p.y, 4), "yaw": round(yaw, 4)}
|
|
t = msg.twist.twist
|
|
self.state["velocity"] = {
|
|
"forward": round(t.linear.x, 4),
|
|
"lateral": round(t.linear.y, 4),
|
|
"angular": round(t.angular.z, 4),
|
|
}
|
|
self.mark(TOPIC_ODOM)
|
|
|
|
def on_velocity(self, msg):
|
|
# Someone (possibly us) is commanding velocity; reflect it so the UI can
|
|
# show the RC or app driving even when the dashboard is not.
|
|
self.state["velocity_command"] = {
|
|
"forward": round(float(getattr(msg, "forward_velocity", 0.0)), 4),
|
|
"lateral": round(float(getattr(msg, "lateral_velocity", 0.0)), 4),
|
|
"angular": round(float(getattr(msg, "angular_velocity", 0.0)), 4),
|
|
"source": getattr(msg, "source", ""),
|
|
}
|
|
self.mark(TOPIC_VELOCITY)
|
|
|
|
def on_face(self, msg):
|
|
self.state["emoji_id"] = int(getattr(msg, "e_id", 0) or 0)
|
|
self.state["face_status"] = int(getattr(msg, "status", 0) or 0)
|
|
self.mark(TOPIC_FACE)
|
|
|
|
def on_frame(self, key, msg):
|
|
fmt = (getattr(msg, "format", "") or "jpeg").lower()
|
|
data = bytes(msg.data)
|
|
now = time.time()
|
|
# Kept encoded exactly as it arrived. Depth is decoded lazily in
|
|
# cmd_camera_frame instead of here: this callback runs on the executor
|
|
# thread at the publish rate (~13 Hz for depth), while the browser asks
|
|
# for 1-5 frames a second, so decoding here would burn CPU on the
|
|
# robot's own control machine for frames nobody collects.
|
|
self.frames[key] = (now, data, "png" if "png" in fmt else "jpeg")
|
|
self.state["cameras"][key] = {"bytes": len(data), "ts": now, "format": fmt}
|
|
|
|
status = self.state["streams"].setdefault(key, {"key": key})
|
|
status["frames"] = status.get("frames", 0) + 1
|
|
status["last"] = now
|
|
self.mark(CAMERAS[key]["topic"])
|
|
|
|
def on_cloud(self, msg):
|
|
"""Decimate a PointCloud2 to something a browser can draw.
|
|
|
|
Fields on this robot: x,y,z float32 at offsets 0/4/8 and intensity
|
|
float32 at 16, point_step 32 (read off the live topic, not assumed).
|
|
A scan is ~25.5k points; sending them all as JSON would be several MB a
|
|
second for a picture that looks identical at a tenth of the points.
|
|
"""
|
|
import numpy as np
|
|
|
|
step = int(msg.point_step)
|
|
raw = np.frombuffer(bytes(msg.data), dtype=np.uint8)
|
|
count = raw.size // step
|
|
if not count:
|
|
return
|
|
rows = raw[:count * step].reshape(count, step)
|
|
|
|
# Stride rather than random choice: it keeps the scan's angular
|
|
# structure, so walls stay walls instead of dissolving into noise.
|
|
if count > LIDAR_MAX_POINTS:
|
|
rows = rows[:: max(1, count // LIDAR_MAX_POINTS)][:LIDAR_MAX_POINTS]
|
|
|
|
xyz = np.frombuffer(rows[:, 0:12].tobytes(), dtype=np.float32).reshape(-1, 3)
|
|
try:
|
|
intensity = np.frombuffer(rows[:, 16:20].tobytes(), dtype=np.float32)
|
|
except ValueError:
|
|
intensity = np.zeros(len(xyz), dtype=np.float32)
|
|
|
|
good = np.isfinite(xyz).all(axis=1)
|
|
xyz, intensity = xyz[good], intensity[good]
|
|
|
|
points = np.column_stack([xyz.round(3), intensity.round(1)])
|
|
self.cloud = (time.time(), points.tolist())
|
|
|
|
status = self.state["streams"].setdefault(LIDAR_KEY, {"key": LIDAR_KEY})
|
|
status["frames"] = status.get("frames", 0) + 1
|
|
status["last"] = time.time()
|
|
status["points"] = int(len(points))
|
|
status["scan_points"] = int(count)
|
|
self.mark(LIDAR["topic"])
|
|
|
|
# -- service helpers ----------------------------------------------------
|
|
|
|
def client_for(self, alias):
|
|
"""Clients are all pre-created in _setup - never build one here."""
|
|
type_path, _ = SRV[alias]
|
|
cli = self.service_clients.get(alias)
|
|
if cli is None:
|
|
raise RuntimeError(f"no client for '{alias}' (creation failed at startup)")
|
|
return cli, load_type(type_path)
|
|
|
|
def call(self, alias, build=None, timeout=6.0):
|
|
"""
|
|
Blocking service call, made from a worker thread.
|
|
|
|
Deliberately does NOT use wait_for_service. That builds a second wait
|
|
set on the same context while the executor is already waiting on one,
|
|
which deadlocks the executor in rclpy/Humble - subscriptions fire once
|
|
and then never again. service_is_ready() is a plain graph query with no
|
|
wait set, so it is safe to call from here.
|
|
"""
|
|
try:
|
|
cli, cls = self.client_for(alias)
|
|
except Exception as exc:
|
|
return False, f"type unavailable: {exc}", None
|
|
|
|
deadline = time.time() + timeout
|
|
while not cli.service_is_ready():
|
|
if time.time() > deadline:
|
|
return False, f"service {SRV[alias][1]} unavailable", None
|
|
time.sleep(0.05)
|
|
|
|
req = cls.Request()
|
|
if build:
|
|
try:
|
|
build(req)
|
|
except Exception as exc:
|
|
return False, f"bad request: {exc}", None
|
|
future = cli.call_async(req)
|
|
deadline = time.time() + timeout
|
|
while not future.done() and time.time() < deadline:
|
|
time.sleep(0.01)
|
|
if not future.done():
|
|
return False, "service call timed out", None
|
|
resp = future.result()
|
|
return True, "ok", flatten(resp)
|
|
|
|
def publisher(self, topic, type_path):
|
|
pub = self.publishers.get(topic)
|
|
if pub is None:
|
|
# Only reached by publish_raw for a topic not known at startup.
|
|
# Creating it now is a calculated risk the typed commands avoid.
|
|
pub = self.node.create_publisher(load_type(type_path), topic, self._qos(depth=10))
|
|
self.publishers[topic] = pub
|
|
return pub
|
|
|
|
# -- polling ------------------------------------------------------------
|
|
|
|
def poll_slow(self):
|
|
"""Mode, volume, mute, hand type - not available as topics."""
|
|
ok, _, detail = self.call("get_mode", timeout=3.0)
|
|
if ok and detail:
|
|
info = detail.get("info") or {}
|
|
desc = info.get("action_desc") or ""
|
|
value = (info.get("current_action") or {}).get("value")
|
|
status = (info.get("status") or {}).get("value")
|
|
if desc:
|
|
self.state["mode"] = desc
|
|
elif value in MC_VALUE_TO_NAME:
|
|
self.state["mode"] = MC_VALUE_TO_NAME[value]
|
|
self.state["mode_value"] = value
|
|
self.state["mode_status"] = status
|
|
|
|
ok, _, detail = self.call("get_volume", timeout=3.0)
|
|
if ok and detail and "audio_volume" in detail:
|
|
self.state["volume"] = detail["audio_volume"]
|
|
|
|
ok, _, detail = self.call("get_mute", timeout=3.0)
|
|
if ok and detail and "is_mute" in detail:
|
|
self.state["muted"] = bool(detail["is_mute"])
|
|
|
|
ok, _, detail = self.call("hand_type", timeout=3.0)
|
|
if ok and detail:
|
|
left = (detail.get("left_hands_type") or {}).get("value", 0)
|
|
right = (detail.get("right_hands_type") or {}).get("value", 0)
|
|
names = {0: "None", 1: "Nimble hands", 2: "Claw gripper",
|
|
3: "Leisai nimble hands", 255: "Error"}
|
|
self.state["hand_type"] = names.get(right or left, "Unknown")
|
|
self.state["hand_left_type"] = int(left)
|
|
self.state["hand_right_type"] = int(right)
|
|
|
|
ok, _, detail = self.call("get_source", timeout=3.0)
|
|
if ok and detail:
|
|
src = (detail.get("input_source") or {}).get("name", "")
|
|
self.state["input_source"] = src
|
|
|
|
def publish_velocity(self, forward, lateral, angular):
|
|
msg = load_type("aimdk_msgs/msg/McLocomotionVelocity")()
|
|
msg.source = "x2_dashboard"
|
|
msg.forward_velocity = float(forward)
|
|
msg.lateral_velocity = float(lateral)
|
|
msg.angular_velocity = float(angular)
|
|
self.publisher(TOPIC_VELOCITY, "aimdk_msgs/msg/McLocomotionVelocity").publish(msg)
|
|
|
|
# -- command handlers ---------------------------------------------------
|
|
|
|
def cmd_set_mode(self, args):
|
|
mode = str(args.get("mode", ""))
|
|
if mode not in MC_ACTION_VALUES:
|
|
return {"ok": False, "message": f"unknown mode '{mode}'"}
|
|
|
|
def build(req):
|
|
req.source = "x2_dashboard"
|
|
req.command.action.value = MC_ACTION_VALUES[mode]
|
|
req.command.action_desc = mode
|
|
|
|
ok, msg, detail = self.call("set_mode", build)
|
|
if ok:
|
|
self.state["mode"] = mode
|
|
return {"ok": ok, "message": f"mode -> {mode}" if ok else msg, "detail": detail}
|
|
|
|
def cmd_get_mode(self, args):
|
|
self.poll_slow()
|
|
return {"ok": True, "message": self.state["mode"],
|
|
"detail": {"mode": self.state["mode"], "status": self.state["mode_status"]}}
|
|
|
|
def cmd_set_velocity(self, args):
|
|
mode = self.state.get("mode")
|
|
if mode not in DRIVEABLE_MODES:
|
|
return {"ok": False,
|
|
"message": f"mode {mode} does not accept velocity - enter Stable stand or Walk"}
|
|
if not self.state.get("source_registered"):
|
|
return {"ok": False, "message": "register an input source first"}
|
|
f = float(args.get("forward", 0.0))
|
|
l = float(args.get("lateral", 0.0))
|
|
a = float(args.get("angular", 0.0))
|
|
self.state["velocity_command"] = {"forward": f, "lateral": l, "angular": a}
|
|
self.last_velocity_cmd = time.time()
|
|
try:
|
|
self.publish_velocity(f, l, a)
|
|
except Exception as exc:
|
|
return {"ok": False, "message": f"publish failed: {exc}"}
|
|
return {"ok": True, "message": "velocity published",
|
|
"detail": {"forward": f, "lateral": l, "angular": a}}
|
|
|
|
def cmd_stop(self, args):
|
|
"""Never gated - an emergency stop that can be refused is not one."""
|
|
self.state["velocity_command"] = {"forward": 0.0, "lateral": 0.0, "angular": 0.0}
|
|
self.last_velocity_cmd = time.time()
|
|
try:
|
|
self.publish_velocity(0.0, 0.0, 0.0)
|
|
except Exception as exc:
|
|
return {"ok": False, "message": f"stop publish failed: {exc}"}
|
|
return {"ok": True, "message": "motion stopped"}
|
|
|
|
def cmd_preset(self, args):
|
|
motion = int(args.get("motion", 0))
|
|
area = int(args.get("area", 0))
|
|
interrupt = bool(args.get("interrupt", True))
|
|
|
|
def build(req):
|
|
req.area.value = area
|
|
req.motion.value = motion
|
|
req.interrupt = interrupt
|
|
req.ani_path = ""
|
|
req.play_timestamp = 0
|
|
|
|
ok, msg, detail = self.call("preset", build, timeout=8.0)
|
|
return {"ok": ok, "message": "preset started" if ok else msg, "detail": detail}
|
|
|
|
def cmd_register_source(self, args):
|
|
name = str(args.get("name", "x2_dashboard"))
|
|
priority = int(args.get("priority", 30))
|
|
timeout = int(args.get("timeout", 1000))
|
|
|
|
def build(req):
|
|
req.action.value = 1001 # INPUTACTION_ADD
|
|
req.input_source.name = name
|
|
req.input_source.priority = priority
|
|
req.input_source.timeout = timeout
|
|
|
|
ok, msg, detail = self.call("set_source", build)
|
|
if ok:
|
|
self.state["source_registered"] = True
|
|
self.state["input_source"] = name
|
|
return {"ok": ok, "message": f"registered '{name}'" if ok else msg, "detail": detail}
|
|
|
|
def cmd_set_joints(self, args):
|
|
group = str(args.get("group", ""))
|
|
if group not in JOINT_GROUPS:
|
|
return {"ok": False, "message": f"unknown joint group '{group}'"}
|
|
mode = str(args.get("mode", "position"))
|
|
targets = args.get("targets") or {}
|
|
stiffness = args.get("stiffness")
|
|
damping = args.get("damping")
|
|
|
|
try:
|
|
ArrayT = load_type("aimdk_msgs/msg/JointCommandArray")
|
|
CmdT = load_type("aimdk_msgs/msg/JointCommand")
|
|
except Exception as exc:
|
|
return {"ok": False, "message": f"aimdk_msgs unavailable: {exc}"}
|
|
|
|
live = self.state["joints"].get(group) or []
|
|
names = [j["name"] for j in live] or list(targets)
|
|
|
|
commands = []
|
|
for name in names:
|
|
c = CmdT()
|
|
c.name = name
|
|
value = float(targets.get(name, 0.0))
|
|
if mode == "position":
|
|
c.position = value
|
|
elif mode == "velocity":
|
|
c.velocity = value
|
|
else:
|
|
c.effort = value
|
|
if stiffness is not None:
|
|
c.stiffness = float(stiffness)
|
|
if damping is not None:
|
|
c.damping = float(damping)
|
|
commands.append(c)
|
|
|
|
arr = ArrayT()
|
|
arr.joints = commands
|
|
try:
|
|
self.publisher(JOINT_GROUPS[group][1],
|
|
"aimdk_msgs/msg/JointCommandArray").publish(arr)
|
|
except Exception as exc:
|
|
return {"ok": False, "message": f"publish failed: {exc}"}
|
|
return {"ok": True, "message": f"{len(commands)} joint command(s) published"}
|
|
|
|
def cmd_set_hand(self, args):
|
|
side = str(args.get("side", "right"))
|
|
positions = [float(v) for v in (args.get("positions") or [])]
|
|
try:
|
|
ArrayT = load_type("aimdk_msgs/msg/HandCommandArray")
|
|
CmdT = load_type("aimdk_msgs/msg/HandCommand")
|
|
except Exception as exc:
|
|
return {"ok": False, "message": f"aimdk_msgs unavailable: {exc}"}
|
|
|
|
arr = ArrayT()
|
|
cmds = []
|
|
for v in positions:
|
|
c = CmdT()
|
|
c.position = v
|
|
cmds.append(c)
|
|
if side == "left":
|
|
arr.left_hands = cmds
|
|
else:
|
|
arr.right_hands = cmds
|
|
try:
|
|
self.publisher(TOPIC_HAND_CMD, "aimdk_msgs/msg/HandCommandArray").publish(arr)
|
|
except Exception as exc:
|
|
return {"ok": False, "message": f"publish failed: {exc}"}
|
|
return {"ok": True, "message": f"{side} hand command published"}
|
|
|
|
def cmd_speak(self, args):
|
|
text = str(args.get("text", "")).strip()
|
|
if not text:
|
|
return {"ok": False, "message": "nothing to say"}
|
|
priority = int(args.get("priority", 6))
|
|
interrupt = bool(args.get("interrupt", False))
|
|
|
|
def build(req):
|
|
req.tts_req.text = text
|
|
req.tts_req.priority_level.value = priority
|
|
req.tts_req.priority_weight = 50
|
|
req.tts_req.domain = "dashboard"
|
|
req.tts_req.trace_id = f"dash-{int(time.time() * 1000)}"
|
|
req.tts_req.is_interrupted = interrupt
|
|
|
|
ok, msg, detail = self.call("tts", build, timeout=8.0)
|
|
return {"ok": ok, "message": "speaking" if ok else msg, "detail": detail}
|
|
|
|
def cmd_set_volume(self, args):
|
|
volume = max(0, min(100, int(args.get("volume", 50))))
|
|
ok, msg, detail = self.call("set_volume", lambda r: setattr(r, "audio_volume", volume))
|
|
if ok:
|
|
self.state["volume"] = volume
|
|
return {"ok": ok, "message": f"volume {volume}" if ok else msg, "detail": detail}
|
|
|
|
def cmd_set_mute(self, args):
|
|
muted = bool(args.get("muted", False))
|
|
ok, msg, detail = self.call("set_mute", lambda r: setattr(r, "is_mute", muted))
|
|
if ok:
|
|
self.state["muted"] = muted
|
|
return {"ok": ok, "message": "muted" if muted else "unmuted", "detail": detail}
|
|
|
|
def cmd_emoji(self, args):
|
|
emotion = int(args.get("emotion_id", 1))
|
|
mode = int(args.get("mode", 1))
|
|
priority = int(args.get("priority", 6))
|
|
|
|
def build(req):
|
|
req.emotion_id = emotion
|
|
req.mode = mode
|
|
req.priority = priority
|
|
|
|
ok, msg, detail = self.call("emoji", build)
|
|
return {"ok": ok, "message": "emoji sent" if ok else msg, "detail": detail}
|
|
|
|
def _send_led(self, mode, r, g, b, priority):
|
|
def build(req):
|
|
req.trace_id = f"dash-{int(time.time() * 1000)}"
|
|
req.led_strip_mode = int(mode)
|
|
req.r = int(r)
|
|
req.g = int(g)
|
|
req.b = int(b)
|
|
req.priority = int(priority)
|
|
req.reset_priority = False
|
|
|
|
return self.call("led", build)
|
|
|
|
def cmd_led(self, args):
|
|
mode = int(args.get("mode", 0))
|
|
r, g, b = (int(args.get(k, 0)) for k in ("r", "g", "b"))
|
|
priority = int(args.get("priority", 6))
|
|
keep = bool(args.get("keep", True))
|
|
|
|
ok, msg, detail = self._send_led(mode, r, g, b, priority)
|
|
if ok:
|
|
self.state["led"] = {"mode": mode, "r": r, "g": g, "b": b, "keep": keep}
|
|
# The robot's own task_manager drives this strip too and reclaims it
|
|
# after roughly a minute, which is why a colour set from here fades
|
|
# away on its own. Remembering the request lets the keepalive below
|
|
# put it back.
|
|
self.led_keepalive = (
|
|
{"mode": mode, "r": r, "g": g, "b": b, "priority": priority}
|
|
if keep else None
|
|
)
|
|
return {"ok": ok, "message": "led updated" if ok else msg, "detail": detail}
|
|
|
|
def cmd_camera_frame(self, args):
|
|
key = str(args.get("key", ""))
|
|
if key not in self.state["streams"]:
|
|
return {"ok": False, "message": f"unknown stream '{key}'"}
|
|
if not self.state["streams"][key].get("active"):
|
|
return {"ok": False, "message": "feed is off", "detail": {"off": True}}
|
|
|
|
entry = self.frames.get(key)
|
|
if not entry:
|
|
return {"ok": False, "message": "no frame received on this topic yet"}
|
|
ts, data, fmt = entry
|
|
if time.time() - ts > 5.0:
|
|
return {"ok": False, "message": "feed is stale"}
|
|
|
|
spec = CAMERAS.get(key) or {}
|
|
flip = bool(args.get("flip", spec.get("flip", False)))
|
|
|
|
if spec.get("kind") == "depth":
|
|
data, fmt = self._render_depth(data, flip)
|
|
if data is None:
|
|
return {"ok": False, "message": fmt}
|
|
elif flip:
|
|
rotated = self._rotate180(data)
|
|
if rotated is not None:
|
|
data, fmt = rotated, "jpeg"
|
|
|
|
return {"ok": True, "message": "frame",
|
|
"detail": {"format": fmt, "ts": ts, "bytes": len(data),
|
|
"b64": base64.b64encode(data).decode("ascii")}}
|
|
|
|
@staticmethod
|
|
def _rotate180(data):
|
|
try:
|
|
import cv2
|
|
import numpy as np
|
|
img = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR)
|
|
if img is None:
|
|
return None
|
|
ok, jpeg = cv2.imencode(".jpg", cv2.rotate(img, cv2.ROTATE_180),
|
|
[cv2.IMWRITE_JPEG_QUALITY, 80])
|
|
return jpeg.tobytes() if ok else None
|
|
except Exception:
|
|
return None
|
|
|
|
@staticmethod
|
|
def _render_depth(data, flip=False):
|
|
"""Turn a compressedDepth payload into a viewable colour image.
|
|
|
|
The raw frame is a 16-bit millimetre map behind a 12-byte header - a
|
|
browser cannot show that, and scaling it by eye ruins the near field.
|
|
Mapping a fixed metric range to a colour ramp keeps distances readable
|
|
and, more importantly, comparable between frames.
|
|
"""
|
|
try:
|
|
import cv2
|
|
import numpy as np
|
|
except ImportError:
|
|
return None, "depth needs numpy and opencv on the robot"
|
|
|
|
try:
|
|
img = cv2.imdecode(np.frombuffer(data[DEPTH_HEADER_BYTES:], np.uint8),
|
|
cv2.IMREAD_UNCHANGED)
|
|
if img is None:
|
|
return None, "could not decode depth frame"
|
|
|
|
depth = img.astype(np.float32)
|
|
if img.dtype != np.uint16:
|
|
# 32FC1 variants carry metres, not millimetres.
|
|
depth *= 1000.0
|
|
|
|
scaled = np.clip(depth / DEPTH_RANGE_MM * 255.0, 0, 255).astype(np.uint8)
|
|
coloured = cv2.applyColorMap(scaled, cv2.COLORMAP_JET)
|
|
# 0 means "no return", not "touching the lens". Painting it black
|
|
# stops the sky and every reflective surface reading as closest.
|
|
coloured[depth <= 0] = 0
|
|
if flip:
|
|
coloured = cv2.rotate(coloured, cv2.ROTATE_180)
|
|
|
|
ok, jpeg = cv2.imencode(".jpg", coloured, [cv2.IMWRITE_JPEG_QUALITY, 80])
|
|
if not ok:
|
|
return None, "could not encode depth frame"
|
|
return jpeg.tobytes(), "jpeg"
|
|
except Exception as exc:
|
|
return None, f"depth render failed: {type(exc).__name__}: {exc}"
|
|
|
|
def cmd_stream_set(self, args):
|
|
"""Switch a camera or the LiDAR on or off."""
|
|
key = str(args.get("key", ""))
|
|
active = bool(args.get("active", False))
|
|
if key not in self.state["streams"]:
|
|
return {"ok": False, "message": f"unknown stream '{key}'"}
|
|
|
|
self.want_stream(key, active)
|
|
|
|
# Wait for the executor timer to apply it, so the caller gets a truthful
|
|
# answer rather than an optimistic one.
|
|
deadline = time.time() + 6.0
|
|
while time.time() < deadline:
|
|
status = self.state["streams"][key]
|
|
if bool(status.get("active")) == active:
|
|
return {"ok": True,
|
|
"message": f"{key} {'on' if active else 'off'}",
|
|
"detail": dict(status)}
|
|
if status.get("error"):
|
|
return {"ok": False, "message": status["error"], "detail": dict(status)}
|
|
time.sleep(0.05)
|
|
|
|
return {"ok": False, "message": "the agent did not apply that in time",
|
|
"detail": dict(self.state["streams"][key])}
|
|
|
|
def cmd_stream_list(self, args):
|
|
return {"ok": True, "message": "streams", "detail": {
|
|
"streams": self.state["streams"],
|
|
"cameras": {k: {"topic": v["topic"], "kind": v["kind"], "flip": v["flip"]}
|
|
for k, v in CAMERAS.items()},
|
|
"lidar": {"key": LIDAR_KEY, **LIDAR},
|
|
}}
|
|
|
|
def cmd_lidar_points(self, args):
|
|
status = self.state["streams"].get(LIDAR_KEY) or {}
|
|
if not status.get("active"):
|
|
return {"ok": False, "message": "LiDAR is off", "detail": {"off": True}}
|
|
if not self.cloud:
|
|
return {"ok": False, "message": "no scan received yet"}
|
|
ts, points = self.cloud
|
|
if time.time() - ts > 8.0:
|
|
return {"ok": False, "message": "scan is stale"}
|
|
return {"ok": True, "message": "points", "detail": {
|
|
"ts": ts, "count": len(points), "points": points,
|
|
"frame": "lidar_chest_front",
|
|
}}
|
|
|
|
def cmd_graph(self, args):
|
|
return {"ok": True, "message": "graph", "detail": {
|
|
"topics": [{"name": n, "types": list(t)}
|
|
for n, t in self.node.get_topic_names_and_types()],
|
|
"services": [{"name": n, "types": list(t)}
|
|
for n, t in self.node.get_service_names_and_types()],
|
|
"nodes": [f"{ns}{n}" for n, ns in self.node.get_node_names_and_namespaces()],
|
|
}}
|
|
|
|
def cmd_publish_raw(self, args):
|
|
topic = str(args.get("topic", ""))
|
|
type_path = str(args.get("type", ""))
|
|
fields = args.get("fields") or {}
|
|
try:
|
|
msg = load_type(type_path)()
|
|
for k, v in fields.items():
|
|
if hasattr(msg, k):
|
|
setattr(msg, k, v)
|
|
self.publisher(topic, type_path).publish(msg)
|
|
except Exception as exc:
|
|
return {"ok": False, "message": f"{type(exc).__name__}: {exc}"}
|
|
return {"ok": True, "message": f"published to {topic}"}
|
|
|
|
def cmd_ping(self, args):
|
|
return {"ok": True, "message": "pong", "detail": {"ts": time.time()}}
|
|
|
|
HANDLERS = {
|
|
"set_mode": cmd_set_mode, "get_mode": cmd_get_mode,
|
|
"set_velocity": cmd_set_velocity, "stop": cmd_stop,
|
|
"preset": cmd_preset, "register_source": cmd_register_source,
|
|
"set_joints": cmd_set_joints, "set_hand": cmd_set_hand,
|
|
"speak": cmd_speak, "set_volume": cmd_set_volume, "set_mute": cmd_set_mute,
|
|
"emoji": cmd_emoji, "led": cmd_led, "camera_frame": cmd_camera_frame,
|
|
"graph": cmd_graph, "publish_raw": cmd_publish_raw, "ping": cmd_ping,
|
|
"stream_set": cmd_stream_set, "stream_list": cmd_stream_list,
|
|
"lidar_points": cmd_lidar_points,
|
|
}
|
|
|
|
def dispatch(self, name, args):
|
|
handler = self.HANDLERS.get(name)
|
|
if handler is None:
|
|
return {"ok": False, "message": f"unknown command '{name}'"}
|
|
try:
|
|
return handler(self, args or {})
|
|
except Exception as exc:
|
|
traceback.print_exc()
|
|
return {"ok": False, "message": f"{type(exc).__name__}: {exc}"}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# TCP server
|
|
# --------------------------------------------------------------------------
|
|
|
|
async def serve(agent: Agent, host: str, port: int):
|
|
async def handle(reader, writer):
|
|
peer = writer.get_extra_info("peername")
|
|
agent.clients.add(writer)
|
|
print(f"[agent] client connected: {peer}")
|
|
try:
|
|
hello = {"type": "hello", "data": {
|
|
"agent_version": VERSION,
|
|
"hostname": socket.gethostname(),
|
|
"ros_domain_id": os.environ.get("ROS_DOMAIN_ID", "0"),
|
|
"cameras": list(CAMERAS),
|
|
"camera_specs": {k: {"topic": v["topic"], "kind": v["kind"],
|
|
"flip": v["flip"]} for k, v in CAMERAS.items()},
|
|
"lidar": {"key": LIDAR_KEY, **LIDAR},
|
|
"on_demand": True,
|
|
"started": AGENT_STARTED,
|
|
}}
|
|
writer.write((json.dumps(hello) + "\n").encode())
|
|
await writer.drain()
|
|
|
|
while True:
|
|
line = await reader.readline()
|
|
if not line:
|
|
break
|
|
try:
|
|
msg = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if msg.get("type") != "cmd":
|
|
continue
|
|
result = await asyncio.to_thread(
|
|
agent.dispatch, msg.get("name", ""), msg.get("args"))
|
|
result["type"] = "result"
|
|
result["id"] = msg.get("id")
|
|
writer.write((json.dumps(result) + "\n").encode())
|
|
await writer.drain()
|
|
except (ConnectionError, asyncio.IncompleteReadError):
|
|
pass
|
|
except Exception as exc:
|
|
print(f"[agent] client error: {exc}")
|
|
finally:
|
|
agent.clients.discard(writer)
|
|
try:
|
|
writer.close()
|
|
except Exception:
|
|
pass
|
|
# Nobody left to watch: drop every camera and the LiDAR. Otherwise
|
|
# closing the last browser leaves the robot pushing megabytes a
|
|
# second through DDS forever, and the operator has no way to tell.
|
|
if not agent.clients:
|
|
agent.stop_all_streams("no clients connected")
|
|
print(f"[agent] client disconnected: {peer}")
|
|
|
|
server = await asyncio.start_server(handle, host, port)
|
|
addrs = ", ".join(str(s.getsockname()) for s in server.sockets)
|
|
print(f"[agent] listening on {addrs}")
|
|
async with server:
|
|
await server.serve_forever()
|
|
|
|
|
|
async def broadcaster(agent: Agent, hz: float):
|
|
period = 1.0 / hz
|
|
while True:
|
|
await asyncio.sleep(period)
|
|
if not agent.clients:
|
|
continue
|
|
agent.state["ts"] = time.time()
|
|
payload = (json.dumps({"type": "state", "data": agent.state},
|
|
default=str) + "\n").encode()
|
|
for writer in list(agent.clients):
|
|
try:
|
|
writer.write(payload)
|
|
except Exception:
|
|
agent.clients.discard(writer)
|
|
|
|
|
|
async def velocity_keepalive(agent: Agent):
|
|
"""Republish the standing velocity command and zero it if the client goes quiet."""
|
|
period = 1.0 / VELOCITY_HZ
|
|
while True:
|
|
await asyncio.sleep(period)
|
|
cmd = agent.state["velocity_command"]
|
|
moving = any(abs(float(cmd.get(k, 0) or 0)) > 1e-6
|
|
for k in ("forward", "lateral", "angular"))
|
|
if not moving or not agent.last_velocity_cmd:
|
|
continue
|
|
if time.time() - agent.last_velocity_cmd > DEADMAN_S:
|
|
agent.state["velocity_command"] = {"forward": 0.0, "lateral": 0.0, "angular": 0.0}
|
|
try:
|
|
agent.publish_velocity(0.0, 0.0, 0.0)
|
|
except Exception:
|
|
pass
|
|
print("[agent] dead-man timeout - velocity zeroed")
|
|
continue
|
|
try:
|
|
agent.publish_velocity(cmd["forward"], cmd["lateral"], cmd["angular"])
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
async def led_keepalive(agent: Agent):
|
|
"""
|
|
Keep re-asserting the requested light-strip setting.
|
|
|
|
The PMU animations (breathing, blinking, flowing) are cyclic and would run
|
|
forever on their own, but the robot's task_manager also owns this strip and
|
|
takes it back after roughly a minute, so a colour set from the dashboard
|
|
quietly disappears. Re-sending the same request wins it back.
|
|
|
|
The interval is 20 s on purpose: it is a whole multiple of all three
|
|
animation cycles (4 s breathing, 1 s blinking, 2 s flowing), so the restart
|
|
lands on a cycle boundary and is not visible as a stutter. A value like 15 s
|
|
would cut the 4 s breathing cycle mid-way and look like a glitch.
|
|
"""
|
|
INTERVAL = 20.0
|
|
misses = 0
|
|
while True:
|
|
await asyncio.sleep(INTERVAL)
|
|
wanted = agent.led_keepalive
|
|
if not wanted:
|
|
misses = 0
|
|
continue
|
|
try:
|
|
ok, msg, _ = await asyncio.to_thread(
|
|
agent._send_led, wanted["mode"], wanted["r"], wanted["g"],
|
|
wanted["b"], wanted["priority"])
|
|
if ok:
|
|
misses = 0
|
|
else:
|
|
misses += 1
|
|
if misses in (1, 5):
|
|
print(f"[agent] led keepalive failed: {msg}")
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as exc:
|
|
print(f"[agent] led keepalive error: {exc}")
|
|
|
|
|
|
async def slow_poller(agent: Agent):
|
|
quiet_failures = 0
|
|
while True:
|
|
# Once rclpy is tearing down, every service call raises. Stop rather
|
|
# than filling the journal with the same error twice a second.
|
|
if not rclpy.ok():
|
|
return
|
|
try:
|
|
await asyncio.to_thread(agent.poll_slow)
|
|
quiet_failures = 0
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as exc:
|
|
quiet_failures += 1
|
|
if quiet_failures <= 3:
|
|
print(f"[agent] poll error: {exc}")
|
|
elif quiet_failures == 4:
|
|
print("[agent] further poll errors suppressed")
|
|
await asyncio.sleep(2.0)
|
|
|
|
|
|
AGENT_STARTED = time.time()
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="AGIBOT X2 dashboard agent")
|
|
parser.add_argument("--host", default="0.0.0.0")
|
|
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
|
|
parser.add_argument("--hz", type=float, default=10.0, help="state broadcast rate")
|
|
args = parser.parse_args()
|
|
|
|
rclpy.init()
|
|
node = rclpy.create_node("x2_dashboard_agent")
|
|
agent = Agent(node)
|
|
|
|
# Six threads, not four: there are now three callback groups (core
|
|
# telemetry, the reentrant stream group, and the reconcile timer), and the
|
|
# stream group wants room to copy several large frames at once.
|
|
executor = MultiThreadedExecutor(num_threads=6)
|
|
executor.add_node(node)
|
|
spin_thread = threading.Thread(target=executor.spin, daemon=True, name="ros-spin")
|
|
spin_thread.start()
|
|
print(f"[agent] x2_agent {VERSION} up on ROS domain {os.environ.get('ROS_DOMAIN_ID', '0')}")
|
|
|
|
async def run():
|
|
loop = asyncio.get_running_loop()
|
|
agent.loop = loop
|
|
stopping = asyncio.Event()
|
|
|
|
# systemd stops the unit with SIGTERM, which never surfaces as
|
|
# KeyboardInterrupt. Without this the loop keeps running after rclpy is
|
|
# torn down, the unit sits in "deactivating" until it is killed, and the
|
|
# journal fills with invalid-context errors.
|
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
try:
|
|
loop.add_signal_handler(sig, stopping.set)
|
|
except NotImplementedError:
|
|
pass
|
|
|
|
tasks = [
|
|
asyncio.create_task(serve(agent, args.host, args.port), name="serve"),
|
|
asyncio.create_task(broadcaster(agent, args.hz), name="broadcast"),
|
|
asyncio.create_task(velocity_keepalive(agent), name="velocity"),
|
|
asyncio.create_task(led_keepalive(agent), name="led"),
|
|
asyncio.create_task(slow_poller(agent), name="poller"),
|
|
]
|
|
|
|
await stopping.wait()
|
|
print("[agent] stop requested, shutting down")
|
|
|
|
# Leave the robot safe: never hand back control with a velocity standing.
|
|
try:
|
|
agent.publish_velocity(0.0, 0.0, 0.0)
|
|
except Exception:
|
|
pass
|
|
|
|
for task in tasks:
|
|
task.cancel()
|
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
try:
|
|
asyncio.run(run())
|
|
except KeyboardInterrupt:
|
|
print("\n[agent] interrupted")
|
|
finally:
|
|
try:
|
|
executor.shutdown()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
node.destroy_node()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
if rclpy.ok():
|
|
rclpy.shutdown()
|
|
except Exception:
|
|
pass
|
|
print("[agent] stopped")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|