From 98b0f76f633f187058b280a09d0a905d65fcb87b Mon Sep 17 00:00:00 2001 From: AlexTemirov Date: Mon, 10 Aug 2026 21:41:11 -0700 Subject: [PATCH] Configure existing ROS 2 robots from device discovery --- .../device-runtime-sources.lock.json | 2 +- editor-server/device_installer.py | 89 ++++++++++++++++++- editor-server/server.py | 1 + tests/test_editor_devices.py | 21 ++++- 4 files changed, 107 insertions(+), 6 deletions(-) diff --git a/editor-server/device-runtime-sources.lock.json b/editor-server/device-runtime-sources.lock.json index 711751f..02b8b8c 100644 --- a/editor-server/device-runtime-sources.lock.json +++ b/editor-server/device-runtime-sources.lock.json @@ -11,6 +11,6 @@ }, "hardware": { "repository": "temiroff/blacknode-robot", - "commit": "0311491e73e3b71fe0d2d6c18d64588dd38947b0" + "commit": "7528d637fe661bc628d86765c65d82b2a0d5050a" } } diff --git a/editor-server/device_installer.py b/editor-server/device_installer.py index 8791d99..7a57144 100644 --- a/editor-server/device_installer.py +++ b/editor-server/device_installer.py @@ -9,6 +9,7 @@ import os import re import secrets +import shlex import shutil import socket import subprocess @@ -2027,8 +2028,9 @@ def configure_hardware_services( host_fingerprint: str, instance_id: str, runtime_port: int, + robot_name: str = "", ) -> dict[str, Any]: - """Configure connected serial robots in an organized Hardware stack.""" + """Configure robots from a confirmed provider in an organized Hardware stack.""" selected_instance = _clean_instance_id(instance_id or "default") selected_runtime_port = int(runtime_port) @@ -2047,6 +2049,58 @@ def configure_hardware_services( expected_fingerprint=expected, timeout=15.0, ) + inspection = _inspect_connection(connection) + graph = ( + inspection.get("ros2_graph") + if isinstance(inspection.get("ros2_graph"), dict) + else {} + ) + topic_names = { + str(value or "").split(" [", 1)[0].strip() + for value in (graph.get("topics") or []) + if str(value or "").strip().startswith("/") + } + # One odometry source and one scan source are enough to prove that this is + # the live robot graph. Other interfaces remain reported as capabilities. + required_topics = [] + for candidates in (("/odom", "/odom_raw"), ("/scan", "/scan_raw")): + selected_topic = next( + (topic for topic in candidates if topic in topic_names), + "", + ) + if selected_topic: + required_topics.append(selected_topic) + capabilities = ["existing_ros2", "ros2_graph"] if required_topics else [] + if any(topic in topic_names for topic in ("/cmd_vel", "/controller/cmd_vel", "/app/cmd_vel")): + capabilities.append("mobile_base") + if any(topic in topic_names for topic in ("/odom", "/odom_raw")): + capabilities.append("odometry") + if any(topic in topic_names for topic in ("/scan", "/scan_raw")): + capabilities.append("lidar") + if any("image" in topic and "depth" not in topic for topic in topic_names): + capabilities.append("camera") + if any("depth" in topic and "image" in topic for topic in topic_names): + capabilities.append("depth_camera") + if any("imu" in topic.lower() for topic in topic_names): + capabilities.append("imu") + if any(topic.endswith("/joint_states") or topic == "/joint_states" for topic in topic_names): + capabilities.append("joint_state") + ros_profile = ( + { + "name": str(robot_name or "ROS 2 Robot").strip()[:80] or "ROS 2 Robot", + "required_topics": required_topics, + "capabilities": capabilities, + } + if bool(graph.get("available")) and required_topics + else {} + ) + profile_payload = ( + base64.urlsafe_b64encode( + json.dumps(ros_profile, separators=(",", ":")).encode("utf-8") + ).decode("ascii") + if ros_profile + else "" + ) script = r"""#!/usr/bin/env bash set -euo pipefail sudo() { @@ -2055,6 +2109,7 @@ def configure_hardware_services( export -f sudo instance="$1" runtime_port="$2" +profile_payload="${3:-}" [[ "$instance" == "default" || "$instance" =~ ^[a-z0-9][a-z0-9-]{0,31}$ ]] || { echo "Invalid Blacknode Hardware instance." >&2 exit 2 @@ -2123,9 +2178,34 @@ def configure_hardware_services( fi ( cd "$target" + configuration_args=() + if [[ -n "$profile_payload" ]]; then + mapfile -t configuration_args < <( + "$target/.venv/bin/python" - "$profile_payload" <<'PY' +import base64 +import json +import sys + +profile = json.loads(base64.urlsafe_b64decode(sys.argv[1]).decode("utf-8")) +print("--existing-ros2") +print("--rosbridge-host") +print("127.0.0.1") +print("--rosbridge-port") +print("9090") +print("--name") +print(profile["name"]) +for topic in profile["required_topics"]: + print("--required-topic") + print(topic) +for capability in profile["capabilities"]: + print("--capability") + print(capability) +PY + ) + fi BLACKNODE_HARDWARE_INSTANCE="$service_instance" \ BLACKNODE_RUNTIME_PORT="$runtime_port" \ - bash ./configure.sh --all --install + bash ./configure.sh --all --install "${configuration_args[@]}" ) for unit in "${orphan_units[@]}"; do @@ -2160,7 +2240,7 @@ def configure_hardware_services( } | awk '{print $1}' | sort -u ) (( configured > 0 )) || { - echo "No connected serial robots were configured." >&2 + echo "No robot Hardware services were configured." >&2 exit 6 } restore_orphans=false @@ -2182,7 +2262,8 @@ def configure_hardware_services( connection, ( f"bash {remote_script_path} " - f"{selected_instance} {selected_runtime_port}" + f"{selected_instance} {selected_runtime_port} " + f"{shlex.quote(profile_payload)}" ), stdin_text=_sudo_input(password, attempts=32), timeout=300.0, diff --git a/editor-server/server.py b/editor-server/server.py index 115d784..d21ac16 100644 --- a/editor-server/server.py +++ b/editor-server/server.py @@ -7249,6 +7249,7 @@ def discover_and_pair_host_robots(host_id: str, req: DiscoverHostRobotsReq): host_fingerprint=str(managed.get("host_fingerprint") or ""), instance_id="default", runtime_port=int(managed.get("runtime_port") or 0), + robot_name=str(host.get("name") or "ROS 2 Robot"), ) configured = int(configuration.get("configured") or 0) if configured: diff --git a/tests/test_editor_devices.py b/tests/test_editor_devices.py index 5d01e50..a18cf11 100644 --- a/tests/test_editor_devices.py +++ b/tests/test_editor_devices.py @@ -1428,7 +1428,7 @@ def fake_run(_connection, command, **kwargs): uploaded[0], ) - def test_default_stack_can_configure_connected_serial_robots(self): + def test_default_stack_configures_existing_ros2_robot_from_live_graph(self): uploaded = [] class RemoteFile(io.StringIO): @@ -1473,6 +1473,22 @@ def fake_run(_connection, command, **kwargs): with ( patch.object(device_installer, "_connect", return_value=connection), + patch.object( + device_installer, + "_inspect_connection", + return_value={ + "ros2_graph": { + "available": True, + "topics": [ + "/odom [nav_msgs/msg/Odometry]", + "/scan [sensor_msgs/msg/LaserScan]", + "/cmd_vel [geometry_msgs/msg/Twist]", + "/camera/image_raw [sensor_msgs/msg/Image]", + "/imu/data [sensor_msgs/msg/Imu]", + ], + } + }, + ), patch.object(device_installer, "_run", side_effect=fake_run), ): result = device_installer.configure_hardware_services( @@ -1503,6 +1519,8 @@ def fake_run(_connection, command, **kwargs): uploaded[0], ) self.assertIn("./configure.sh --all --install", uploaded[0]) + self.assertIn('print("--existing-ros2")', uploaded[0]) + self.assertNotIn(" default 8766 ''", commands[0]) self.assertIn( 'sudo systemctl stop "$unit"', uploaded[0], @@ -2463,6 +2481,7 @@ def test_robot_discovery_configures_connected_robots_when_no_services_exist(self host_fingerprint="SHA256:trusted-device-key", instance_id="default", runtime_port=8766, + robot_name="alex-desktop", ) self.assertNotIn(hardware_token, response.text) self.assertNotIn("ssh-password", response.text)