Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion editor-server/device-runtime-sources.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@
},
"hardware": {
"repository": "temiroff/blacknode-robot",
"commit": "0311491e73e3b71fe0d2d6c18d64588dd38947b0"
"commit": "7528d637fe661bc628d86765c65d82b2a0d5050a"
}
}
89 changes: 85 additions & 4 deletions editor-server/device_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import os
import re
import secrets
import shlex
import shutil
import socket
import subprocess
Expand Down Expand Up @@ -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)
Expand All @@ -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() {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions editor-server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
21 changes: 20 additions & 1 deletion tests/test_editor_devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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)
Expand Down