From 88a3223fce808b21b0df5eb521c0cb65825a9ce3 Mon Sep 17 00:00:00 2001 From: AlexTemirov Date: Sat, 25 Jul 2026 23:19:24 -0700 Subject: [PATCH 1/2] Add named robot capability profiles --- README.md | 27 ++ blacknode-package.toml | 4 +- nodes/__init__.py | 1 + nodes/interfaces.py | 258 +++++++++++++ templates/hiwonder-rosorin-pro-readiness.json | 341 ++++++++++++++++++ tests/test_robot_nodes.py | 44 +++ tests/test_robot_template_device_selection.py | 1 + 7 files changed, 674 insertions(+), 2 deletions(-) create mode 100644 nodes/interfaces.py create mode 100644 templates/hiwonder-rosorin-pro-readiness.json diff --git a/README.md b/README.md index ba21d15..8919696 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,33 @@ and verification commands. | `RobotCalibrationRecorder` | Safely records released-arm limits and a home pose for one physical robot | | `RobotDriverLauncher` | Starts/stops a driver process from the descriptor | | `RobotConnectionDashboard` | Shows USB, driver, ROS interface, live joint positions, home references, safe ranges, and calibration source in one view | +| `RobotROSInterfaceCheck` | Matches a live ROS graph to a supported robot interface profile without publishing commands | + +## Hiwonder ROSOrin Pro + +Open the **Hiwonder ROSOrin Pro Readiness** template after Blacknode is +deployed to the robot and the vendor ROS 2 services are running. The workflow +is read-only. It inventories the live topics, nodes, and services, then checks +the documented interfaces for: + +- Mecanum base velocity and odometry +- TOF LiDAR and chassis IMU +- RGB, depth, and camera-calibration streams +- 6DOF arm command and inverse-kinematics interfaces +- navigation, SLAM, voice interaction, and the controller buzzer + +The vendor software uses more than one RGB/depth namespace across controller +images. The readiness profile accepts both `rgb`/`depth` and `rgb0`/`depth0` +layouts and records the exact live binding in its `bindings` output. Keep base +and arm motion disarmed until this discovery passes and the physical unit's +feedback, calibration, joint limits, command expiry, and stop behavior have +been verified. + +The template also captures one frame from +`/depth_cam/rgb/image_raw`. If the readiness output selects +`/depth_cam/rgb0/image_raw`, update the camera node to that discovered topic. +Navigation, SLAM, kinematics, and voice entries may show `ON DEMAND` until the +corresponding vendor launch file is active. Changing the generic `Robot.profile_id` invalidates the old dashboard. Press **Run** to apply it: if its generated driver command differs, Blacknode safely diff --git a/blacknode-package.toml b/blacknode-package.toml index 8110702..c81bc58 100644 --- a/blacknode-package.toml +++ b/blacknode-package.toml @@ -1,6 +1,6 @@ [package] name = "blacknode-robot" -version = "0.2.1" +version = "0.3.0" description = "Generic robot discovery, USB permission checks, driver launch, and robot profile contracts." requires-blacknode = ">=0.3.0" layer = "robot" @@ -64,7 +64,7 @@ capabilities = ["robot.capabilities"] # Declared for the package roadmap; sources land in components/authorization/nodes. node-types = [ - "RobotConnectionDashboard", "RobotDiscovery", "RobotUSBDiscovery" + "RobotConnectionDashboard", "RobotDiscovery", "RobotROSInterfaceCheck", "RobotUSBDiscovery" ] [components.authorization] diff --git a/nodes/__init__.py b/nodes/__init__.py index f0292c1..e86affa 100644 --- a/nodes/__init__.py +++ b/nodes/__init__.py @@ -1,2 +1,3 @@ +from . import interfaces # noqa: F401 from . import presets # noqa: F401 from . import robot # noqa: F401 diff --git a/nodes/interfaces.py b/nodes/interfaces.py new file mode 100644 index 0000000..665bee4 --- /dev/null +++ b/nodes/interfaces.py @@ -0,0 +1,258 @@ +"""Read-only capability binding checks for supported robot ROS interfaces.""" +from __future__ import annotations + +from typing import Any + +from blacknode.node import Bool, Dict, Enum, List, Text, node + + +_CATEGORY = "Robot" +_PRESETS = ["hiwonder_rosorin_pro"] + + +def _ros_names(values: Any) -> set[str]: + """Normalize `ros2 * list -t` rows to graph names.""" + if not isinstance(values, list): + return set() + names: set[str] = set() + for value in values: + name = str(value or "").strip().split(maxsplit=1)[0] + if name: + names.add(name) + return names + + +def _binding( + *, + label: str, + kind: str, + candidates: list[str], + observed: set[str], + required: bool, + contains: tuple[str, ...] = (), + note: str = "", +) -> dict[str, Any]: + exact = next((name for name in candidates if name in observed), "") + fuzzy = next( + ( + name + for name in sorted(observed) + if contains and any(token in name.lower() for token in contains) + ), + "", + ) + matched = exact or fuzzy + return { + "label": label, + "kind": kind, + "available": bool(matched), + "matched": matched, + "candidates": list(candidates), + "required": required, + "note": note, + } + + +def _rosorin_pro_bindings( + topics: set[str], + nodes: set[str], + services: set[str], +) -> dict[str, dict[str, Any]]: + """Bindings documented by Hiwonder, including known camera-name variants.""" + return { + "mobile_base": _binding( + label="Mecanum base velocity", + kind="topic", + candidates=["/controller/cmd_vel"], + observed=topics, + required=True, + ), + "odometry": _binding( + label="Base odometry", + kind="topic", + candidates=["/odom", "/odom_raw"], + observed=topics, + required=True, + ), + "lidar": _binding( + label="TOF LiDAR scan", + kind="topic", + candidates=["/scan"], + observed=topics, + required=True, + ), + "imu": _binding( + label="Chassis IMU", + kind="topic", + candidates=["/ros_robot_controller/imu_raw"], + observed=topics, + required=True, + ), + "rgb_camera": _binding( + label="Depth-camera RGB stream", + kind="topic", + candidates=["/depth_cam/rgb/image_raw", "/depth_cam/rgb0/image_raw"], + observed=topics, + required=True, + note="The vendor image uses either the rgb or rgb0 namespace.", + ), + "depth_camera": _binding( + label="Depth image stream", + kind="topic", + candidates=["/depth_cam/depth/image_raw", "/depth_cam/depth0/image_raw"], + observed=topics, + required=True, + note="The vendor image uses either the depth or depth0 namespace.", + ), + "camera_info": _binding( + label="Depth-camera calibration", + kind="topic", + candidates=["/depth_cam/depth/camera_info", "/depth_cam/depth0/camera_info"], + observed=topics, + required=True, + ), + "arm_command": _binding( + label="6DOF arm servo command", + kind="topic", + candidates=["/servo_controller", "/servo_controller11"], + observed=topics, + required=True, + note="Blacknode keeps arm motion disarmed until live feedback and limits are confirmed.", + ), + "arm_kinematics": _binding( + label="Arm inverse kinematics", + kind="service", + candidates=[ + "/kinematics/set_pose_target", + "/kinematics/set_joint_value_target", + ], + observed=services, + required=False, + note="These services appear when the vendor kinematics stack is running.", + ), + "depth_control": _binding( + label="Depth-camera emitter control", + kind="service", + candidates=["/depth_cam/set_ldp_enable"], + observed=services, + required=False, + ), + "navigation": _binding( + label="Navigation stack", + kind="node", + candidates=[], + observed=nodes, + required=False, + contains=("nav2", "navigate", "planner_server", "controller_server"), + note="Navigation nodes are normally launched only for a navigation mission.", + ), + "slam": _binding( + label="SLAM mapping", + kind="node", + candidates=[], + observed=nodes | topics, + required=False, + contains=("slam", "gmapping", "/map"), + note="Mapping nodes and /map appear only while the mapping stack is active.", + ), + "voice": _binding( + label="Voice interaction", + kind="node", + candidates=[], + observed=nodes | topics | services, + required=False, + contains=("voice", "speech", "audio", "microphone", "wonder_echo", "asr", "tts"), + note="Voice interfaces vary by kit and vendor image; live discovery selects the binding.", + ), + "buzzer": _binding( + label="Controller buzzer", + kind="topic", + candidates=["/ros_robot_controller/set_buzzer"], + observed=topics, + required=False, + ), + } + + +@node( + name="RobotROSInterfaceCheck", + component="capabilities", + category=_CATEGORY, + description=( + "Match a live ROS graph against a supported robot interface profile. " + "Read-only: discovers capability bindings and never publishes commands." + ), + inputs={ + "preset": Enum(_PRESETS, default="hiwonder_rosorin_pro"), + "topics": List(default=[]), + "nodes": List(default=[]), + "services": List(default=[]), + }, + outputs={ + "ready": Bool, + "capabilities": List, + "missing": List, + "bindings": Dict, + "report": Text, + }, +) +def robot_ros_interface_check(ctx: dict) -> dict: + preset = str(ctx.get("preset") or "hiwonder_rosorin_pro").strip() + topics = _ros_names(ctx.get("topics")) + nodes = _ros_names(ctx.get("nodes")) + services = _ros_names(ctx.get("services")) + if preset != "hiwonder_rosorin_pro": + return { + "ready": False, + "capabilities": [], + "missing": [], + "bindings": {}, + "report": f"unknown robot ROS interface preset: {preset}", + } + + bindings = _rosorin_pro_bindings(topics, nodes, services) + capabilities = [name for name, value in bindings.items() if value["available"]] + missing = [name for name, value in bindings.items() if not value["available"]] + required = [name for name, value in bindings.items() if value["required"]] + missing_required = [name for name in required if not bindings[name]["available"]] + ready = not missing_required + + lines = [ + "Hiwonder ROSOrin Pro ROS interface readiness", + f"observed: {len(topics)} topic(s), {len(nodes)} node(s), {len(services)} service(s)", + f"required data-plane bindings: {len(required) - len(missing_required)}/{len(required)}", + "", + ] + for name, value in bindings.items(): + if value["available"]: + state = "READY" + detail = value["matched"] + elif value["required"]: + state = "MISSING" + detail = " or ".join(value["candidates"]) or "matching live interface" + else: + state = "ON DEMAND" + detail = "not active in this graph" + lines.append(f"[{state}] {value['label']}: {detail}") + lines.extend([ + "", + ( + "READY: the read-only base, sensor, camera, and arm-command interfaces were discovered. " + "Keep motion disarmed until feedback, calibration, limits, and stop behavior are verified." + if ready + else "NEXT: start the vendor base/camera/arm services, then run this check again. No motion command was sent." + ), + ]) + return { + "ready": ready, + "capabilities": capabilities, + "missing": missing, + "bindings": { + "preset": preset, + "topics": sorted(topics), + "nodes": sorted(nodes), + "services": sorted(services), + "capabilities": bindings, + }, + "report": "\n".join(lines), + } diff --git a/templates/hiwonder-rosorin-pro-readiness.json b/templates/hiwonder-rosorin-pro-readiness.json new file mode 100644 index 0000000..1c6b07c --- /dev/null +++ b/templates/hiwonder-rosorin-pro-readiness.json @@ -0,0 +1,341 @@ +{ + "kind": "blacknode.workflow", + "schema_version": 1, + "name": "Hiwonder ROSOrin Pro Readiness", + "saved_at": "2026-07-25T00:00:00", + "entrypoint": { + "node_id": "interface_check", + "port": "report" + }, + "metadata": { + "template": true, + "description": "Read-only bring-up for a Hiwonder ROSOrin Pro. Run this on Blacknode deployed to the robot after the vendor ROS 2 services are started. It inventories topics, nodes, and services; matches the base, odometry, LiDAR, IMU, RGB/depth camera, arm, navigation, SLAM, voice, and buzzer interfaces; and captures one RGB frame. It never publishes a base or arm command.", + "color": "#14b8a6", + "required_packages": [ + "blacknode-perception", + "blacknode-robot", + "blacknode-ros2" + ], + "required_components": [ + "blacknode-perception/camera", + "blacknode-robot/capabilities", + "blacknode-ros2/core", + "blacknode-ros2/diagnostics", + "blacknode-ros2/services", + "blacknode-ros2/topics" + ], + "required_adapters": [ + "blacknode-perception/camera@ros2" + ], + "required_capabilities": [ + "camera", + "depth_camera", + "imu", + "lidar", + "mobile_base", + "position_feedback", + "robot_arm" + ] + }, + "node_meta": { + "system": { + "id": "system", + "type": "ROS2SystemCheck", + "pos": [40, 180], + "params": { + "refresh": true + }, + "inputs": ["refresh"], + "outputs": ["available", "backend", "report"], + "input_types": { + "refresh": "Bool" + }, + "output_types": { + "available": "Bool", + "backend": "Text", + "report": "Text" + }, + "input_defaults": { + "refresh": true + } + }, + "topics": { + "id": "topics", + "type": "ROS2TopicList", + "pos": [390, 80], + "params": { + "show_types": true + }, + "inputs": ["trigger", "show_types"], + "outputs": ["topics", "report"], + "input_types": { + "trigger": "Any", + "show_types": "Bool" + }, + "output_types": { + "topics": "List", + "report": "Text" + }, + "input_defaults": { + "show_types": true + } + }, + "nodes": { + "id": "nodes", + "type": "ROS2NodeList", + "pos": [390, 250], + "params": {}, + "inputs": ["trigger"], + "outputs": ["nodes", "report"], + "input_types": { + "trigger": "Any" + }, + "output_types": { + "nodes": "List", + "report": "Text" + }, + "input_defaults": {} + }, + "services": { + "id": "services", + "type": "ROS2ServiceList", + "pos": [390, 420], + "params": { + "show_types": true + }, + "inputs": ["trigger", "show_types"], + "outputs": ["services", "report"], + "input_types": { + "trigger": "Any", + "show_types": "Bool" + }, + "output_types": { + "services": "List", + "report": "Text" + }, + "input_defaults": { + "show_types": true + } + }, + "interface_check": { + "id": "interface_check", + "type": "RobotROSInterfaceCheck", + "pos": [790, 230], + "params": { + "preset": "hiwonder_rosorin_pro" + }, + "inputs": ["preset", "topics", "nodes", "services"], + "outputs": ["ready", "capabilities", "missing", "bindings", "report"], + "input_types": { + "preset": "Text", + "topics": "List", + "nodes": "List", + "services": "List" + }, + "output_types": { + "ready": "Bool", + "capabilities": "List", + "missing": "List", + "bindings": "Dict", + "report": "Text" + }, + "input_defaults": { + "preset": "hiwonder_rosorin_pro", + "topics": [], + "nodes": [], + "services": [] + }, + "input_choices": { + "preset": ["hiwonder_rosorin_pro"] + } + }, + "rgb_camera": { + "id": "rgb_camera", + "type": "CameraROS2Subscribe", + "pos": [1190, 80], + "params": { + "action": "start", + "stream_id": "rosorin_pro_rgb", + "topic": "/depth_cam/rgb/image_raw", + "message_type": "auto", + "host": "127.0.0.1", + "port": 0, + "max_fps": 10.0, + "max_width": 960, + "jpeg_quality": 80 + }, + "inputs": [ + "trigger", "action", "stream_id", "topic", "message_type", + "host", "port", "max_fps", "max_width", "jpeg_quality" + ], + "outputs": [ + "preview", "streaming", "stream_url", "snapshot_url", + "stream_id", "report", "frame_stream" + ], + "input_types": { + "trigger": "Any", + "action": "Text", + "stream_id": "Text", + "topic": "Text", + "message_type": "Text", + "host": "Text", + "port": "Int", + "max_fps": "Float", + "max_width": "Int", + "jpeg_quality": "Int" + }, + "output_types": { + "preview": "Image", + "streaming": "Bool", + "stream_url": "Text", + "snapshot_url": "Text", + "stream_id": "Text", + "report": "Text", + "frame_stream": "Dict" + }, + "input_defaults": { + "action": "start", + "stream_id": "camera", + "topic": "/camera/image_raw", + "message_type": "auto", + "host": "127.0.0.1", + "port": 0, + "max_fps": 10.0, + "max_width": 960, + "jpeg_quality": 80 + }, + "input_choices": { + "action": ["start", "stop"], + "message_type": ["auto", "raw", "compressed"] + } + }, + "status_out": { + "id": "status_out", + "type": "Output", + "pos": [1190, 350], + "params": { + "label": "ROSOrin Pro readiness" + }, + "inputs": ["value"], + "outputs": [], + "input_types": { + "value": "Any" + }, + "output_types": {}, + "input_defaults": {} + }, + "bindings_out": { + "id": "bindings_out", + "type": "Output", + "pos": [1190, 500], + "params": { + "label": "Discovered ROSOrin Pro bindings" + }, + "inputs": ["value"], + "outputs": [], + "input_types": { + "value": "Any" + }, + "output_types": {}, + "input_defaults": {} + }, + "camera_out": { + "id": "camera_out", + "type": "OutputImage", + "pos": [1570, 80], + "params": {}, + "inputs": ["image"], + "outputs": ["image"], + "input_types": { + "image": "Image" + }, + "output_types": { + "image": "Image" + }, + "input_defaults": {} + }, + "camera_status_out": { + "id": "camera_status_out", + "type": "Output", + "pos": [1570, 280], + "params": { + "label": "ROSOrin Pro RGB camera status" + }, + "inputs": ["value"], + "outputs": [], + "input_types": { + "value": "Any" + }, + "output_types": {}, + "input_defaults": {} + } + }, + "edges": [ + { + "from": "system", + "from_port": "report", + "to": "topics", + "to_port": "trigger" + }, + { + "from": "topics", + "from_port": "report", + "to": "nodes", + "to_port": "trigger" + }, + { + "from": "nodes", + "from_port": "report", + "to": "services", + "to_port": "trigger" + }, + { + "from": "topics", + "from_port": "topics", + "to": "interface_check", + "to_port": "topics" + }, + { + "from": "nodes", + "from_port": "nodes", + "to": "interface_check", + "to_port": "nodes" + }, + { + "from": "services", + "from_port": "services", + "to": "interface_check", + "to_port": "services" + }, + { + "from": "interface_check", + "from_port": "report", + "to": "rgb_camera", + "to_port": "trigger" + }, + { + "from": "interface_check", + "from_port": "report", + "to": "status_out", + "to_port": "value" + }, + { + "from": "interface_check", + "from_port": "bindings", + "to": "bindings_out", + "to_port": "value" + }, + { + "from": "rgb_camera", + "from_port": "preview", + "to": "camera_out", + "to_port": "image" + }, + { + "from": "rgb_camera", + "from_port": "report", + "to": "camera_status_out", + "to_port": "value" + } + ] +} diff --git a/tests/test_robot_nodes.py b/tests/test_robot_nodes.py index 192527c..b3ca3c6 100644 --- a/tests/test_robot_nodes.py +++ b/tests/test_robot_nodes.py @@ -20,6 +20,7 @@ "RobotDriverPreset", "Robot", "RobotConnectionDashboard", + "RobotROSInterfaceCheck", "RobotJointDefinition", "RobotJointList", "RobotDefinition", @@ -38,6 +39,48 @@ def test_robot_nodes_registered_with_category(): assert _NODE_REGISTRY[name]._bn_package == "blacknode-robot" +def test_rosorin_interface_check_matches_documented_topic_variants(): + result = _NODE_REGISTRY["RobotROSInterfaceCheck"]({ + "preset": "hiwonder_rosorin_pro", + "topics": [ + "/controller/cmd_vel [geometry_msgs/msg/Twist]", + "/odom_raw [nav_msgs/msg/Odometry]", + "/scan [sensor_msgs/msg/LaserScan]", + "/ros_robot_controller/imu_raw [sensor_msgs/msg/Imu]", + "/depth_cam/rgb0/image_raw [sensor_msgs/msg/Image]", + "/depth_cam/depth0/image_raw [sensor_msgs/msg/Image]", + "/depth_cam/depth0/camera_info [sensor_msgs/msg/CameraInfo]", + "/servo_controller11 [servo_controller_msgs/msg/ServosPosition]", + "/ros_robot_controller/set_buzzer [ros_robot_controller_msgs/msg/BuzzerState]", + ], + "nodes": ["/controller", "/nav2_controller"], + "services": [ + "/kinematics/set_pose_target [kinematics_msgs/srv/SetRobotPose]", + "/depth_cam/set_ldp_enable [std_srvs/srv/SetBool]", + ], + }) + + assert result["ready"] is True + assert result["missing"] == ["slam", "voice"] + assert result["bindings"]["capabilities"]["rgb_camera"]["matched"] == "/depth_cam/rgb0/image_raw" + assert result["bindings"]["capabilities"]["arm_command"]["matched"] == "/servo_controller11" + assert "No motion command was sent" not in result["report"] + + +def test_rosorin_interface_check_reports_missing_without_claiming_hardware(): + result = _NODE_REGISTRY["RobotROSInterfaceCheck"]({ + "topics": ["/rosout [rcl_interfaces/msg/Log]"], + "nodes": [], + "services": [], + }) + + assert result["ready"] is False + assert "mobile_base" in result["missing"] + assert "rgb_camera" in result["missing"] + assert result["capabilities"] == [] + assert "No motion command was sent" in result["report"] + + def test_usb_discovery_reports_no_devices(monkeypatch): monkeypatch.setattr(robot_nodes, "_serial_candidate_paths", lambda: []) result = _NODE_REGISTRY["RobotUSBDiscovery"]({}) @@ -843,6 +886,7 @@ def test_custom_robot_templates_validate(): templates = Path(__file__).resolve().parents[1] / "templates" for name in ( "editable-so-arm101-profile.json", + "hiwonder-rosorin-pro-readiness.json", "robot-guided-calibration.json", "so-arm101-motion-test.json", ): diff --git a/tests/test_robot_template_device_selection.py b/tests/test_robot_template_device_selection.py index fa44831..5c299ed 100644 --- a/tests/test_robot_template_device_selection.py +++ b/tests/test_robot_template_device_selection.py @@ -8,6 +8,7 @@ _TEMPLATE_DIR = Path(__file__).resolve().parents[1] / "templates" _EXPECTED_ROBOTS = { "editable-so-arm101-profile.json": {"robot": 0}, + "hiwonder-rosorin-pro-readiness.json": {}, "robot-guided-calibration.json": {"robot": 0}, "so-arm101-motion-test.json": {"robot": 0}, } From 36dbef9f49051dc51dc9025efb8f5247b8ec6e78 Mon Sep 17 00:00:00 2001 From: AlexTemirov Date: Sat, 25 Jul 2026 23:33:31 -0700 Subject: [PATCH 2/2] Load perception dependency in robot CI --- .github/workflows/ci.yml | 1 + tests/test_robot_nodes.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a4144e..4711ae6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,7 @@ jobs: git clone --depth 1 https://github.com/temiroff/blacknode-ros2.git core/packages/blacknode-ros2 git clone --depth 1 https://github.com/temiroff/blacknode-drivers.git core/packages/blacknode-drivers git clone --depth 1 https://github.com/temiroff/blacknode-controllers.git core/packages/blacknode-controllers + git clone --depth 1 https://github.com/temiroff/blacknode-perception.git core/packages/blacknode-perception - name: Set up Python uses: actions/setup-python@v6 with: diff --git a/tests/test_robot_nodes.py b/tests/test_robot_nodes.py index b3ca3c6..d8b6fc4 100644 --- a/tests/test_robot_nodes.py +++ b/tests/test_robot_nodes.py @@ -892,4 +892,4 @@ def test_custom_robot_templates_validate(): ): workflow = json.loads((templates / name).read_text(encoding="utf-8")) report = validate_workflow(workflow) - assert report.ok, (name, [issue.message for issue in report.issues]) + assert report.ok, (name, [issue.message for issue in report.errors])