Skip to content

Tests · HIL bridges

Part of the OpenRAL public-symbol inventory. Hand-curated; (LNN) markers are refreshed by tools/refresh_methods_linenos.py.

Lab-runner-only rclpy bridges that wire the real-HW HAL adapters (UR5eRealHAL / UR10eRealHAL / FrankaPandaRealHAL / SawyerRealHAL / AlohaHAL) onto a live controller stack via the HALs' injected publish_fn / state_fn callables. ros2_control covers the four RosControlHAL arms but not ALOHA, which runs interbotix_xs_sdk and exposes no ros2_control topics. Off-lab they are guarded behind importlib.util.find_spec("rclpy") is None plus a per-robot env probe; the unit-lane conformance tests use SimTransport instead.

The Galaxea A1 uses an isolated ROS 1 sidecar rather than a ros2_control bridge. Its HAL-level gate lives in tests/hil/test_galaxea_a1.py; the separate tests/hil/test_galaxea_a1_deploy.py gate runs inside an active deploy container and proves a measured hold across the real C++ kernel, ROS 2 HAL, and staged ROS 1 command relay. Both are environment-gated, time-bounded, and end by stopping the sidecar through the downstream e-stop path.

The OpenArm has no ros2_control HIL bridge yet — its gate (tests/hil/test_openarm_can_live.py) runs one layer lower, on the CAN transport itself, and is gated on the two udev-pinned interfaces (openarm_left / openarm_right) being up. It splits in two: transport checks (CAN FD at 1 Mbit/s nominal / 5 Mbit/s data, openral detect identifying the arm from the bus alone, OpenArmRealHAL's preflight passing) run whenever the links are up, while the motor round-trip additionally requires a bus in ERROR-ACTIVE — an unpowered arm leaves the links up but ERROR-PASSIVE, which is a skip, not a failure, because it is a fact about the bench and not about the code. The round-trip is read-only by construction: it queries motor state and never calls enable_all(), so it cannot energise or move the arm.

Above that sits the full-graph gate, tests/hil/test_openarm_deploy.py, which attaches to a running openral deploy run and asserts only observations. On this robot the deploy graph cannot be started by a test: real_bringup.launch.py starts openarm_bringup, whose OpenArmHW::on_activate calls enable_all() then return_to_zero() — an unramped MIT position command to 0.0 on all seven joints per side, issued before the current pose is sampled. Bringing the cell up is therefore an attended operator act, and the test's whole job is to prove what the operator brought up.

tests/hil/_can_gate.py

The CAN-link skip gate every OpenArm HIL file shares.

  • _can_links_up(can_links) -> bool — True when every named SocketCAN interface exists and is up, via openral_cli.autodetect.enumerate_can_interfaces. (L15)
  • Lives in a _-prefixed module rather than conftest.py because a conftest is not importable as a module: pytest registers it under its own private name, so from tests.hil.conftest import ... raises ModuleNotFoundError on the lab hosts these tests run on. Found the hard way on the lab Orin.

tests/hil/test_openarm_deploy.py

Full-graph, non-motion gate for the real bimanual deploy. [self-hosted, lab-openarm].

  • Modelled on test_galaxea_a1_deploy.py, but every assertion is an observation — it publishes no candidate action at any point. That absence is itself an assertion: any /openral/safe_action on the wire would mean the kernel is a source of commands rather than a filter on them.
  • Seven checks against one module-scoped rclpy node: graph observable; HAL connected with both buses reported up (preflight_can_links renders a dead bus as "<name> (DOWN)", so the bare name is the test); controller_manager reporting all four JointTrajectoryControllers and joint_state_broadcaster active; /joint_states carrying all sixteen ros2_control joints above a 20 Hz floor; the TF tree resolving world to both *_ee_base_link; the C++ kernel ACTIVE with envelope_loaded=true at 16 DoF; and /openral/world_voxels arriving with a unit-quaternion lattice and a non-zero occupied count.
  • The last one is the point of scenes/deploy/openarm_bench.yaml. head_zed auto-enables the octomap leg, but octomap_cloud_topic keeps its sim-only launch default (/openral/cameras/front_depth/points) unless the scene pins it — leaving the octree, world_voxels and the dashboard card empty forever while every node reports healthy.
  • Gated on OPENARM_DEPLOY_HIL=1 and both CAN links up, so a stray pytest tests/hil/ never attaches to a live cell. Run it via just hil-openarm-deploy, which also sets PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 — a sourced ROS overlay drags system Python's launch_testing plugin into the workspace's pytest, where it is incompatible. The SO-101 bench arm has no ros2_control bridge either — it is a serial robot (SO100FollowerHAL over a Feetech scservo_sdk bus, shared verbatim with the SO-100), so its gate (tests/hil/test_so101_serial_live.py) runs on the serial transport itself. It is the first HIL file backed by a rig that physically exists; everything else driving SO100FollowerHAL (tests/unit/test_so100_follower_hal.py) injects a SO100DigitalTwin and never opens a port. Port, lerobot calibration id and calibration_dir are all read from scenes/deploy/so101_bench.yaml's hal: binding rather than hardcoded, so the test and openral deploy run --config scenes/deploy/so101_bench.yaml cannot drift. It splits in two like the OpenArm's: bench-wiring checks (the committed calibration's joint keys + servo ids match robots/so101_follower/robot.yaml; hal.real still points at the class under test; an enumerated port whose servos are dark raises instead of connecting) run whenever the device node and calibration file are present, while the live-servo tier additionally requires a broadcast ping to find all six ids. The USB-serial adapter enumerates on 5 V, so an arm with its 12 V supply off leaves /dev/ttyACM0 looking healthy and the bus completely silent — a skip, not a failure. No position commands by construction: the powered tier never calls send_action() or reset_to_pose(). Its terminal E-stop assertion disconnects the motor bus, so run it attended with the physical power switch within reach. connect() can re-enable torque, so a stale servo goal can still cause a twitch. Driven by just hil-so101, run by hand on the bench host (no CI workflow; no self-hosted runner is registered).

tests/hil/_ros_control_transport.py

Single-controller bridge. Used by UR5e, UR10e, Franka Panda, Sawyer.

  • _make_trajectory_publisher(node, command_topic) -> Publisher — Module-private helper that creates a trajectory_msgs/JointTrajectory publisher with the shared HIL QoS profile. Reused by _aloha_ros_transport.py. (L52)
  • RosControlHILTransport(node, joint_names, *, command_topic, joint_state_topic) — Subscribes to joint_state_topic, exposes publish(_topic, msg) for the HAL's outgoing trajectories and state() for the cached joint state. Helpers: spin_once, wait_for_first_state, last_stamp. (L130)
  • make_hil_transport(node_name, joint_names, *, command_topic, joint_state_topic) -> tuple[Node, RosControlHILTransport, Callable[[], None]] — Factory that initialises rclpy, creates the node, and returns a teardown callable. (L201)

tests/hil/_openarm_ros_transport.py

4-way fan-out bridge for the bimanual OpenArm v2 HAL — the only thing between OpenArmRealHAL and a physical arm.

  • OpenArmHILTransport(node, joint_names, *, command_topics, joint_state_topic, time_from_start_s=0.8) — Four JointTrajectory publishers plus one aggregated JointState subscriber. Simpler than the ALOHA bridge since ADR-0102 puts joint_names in the message, so the transport forwards them rather than keeping a second, driftable slice table. Build it from OpenArmRealHAL.ros2_control_joint_names() — the URDF namespace /joint_states is keyed by, not the manifest's. (L55)
  • time_from_start_s is a constructor argument, unlike the 100 ms the production transports hardcode. A JointTrajectoryController given an absolute target and a 100 ms deadline moves at (target - current) / 0.1s, so the rate is set by how wrong the command is. A longer window bounds it by construction; a test that cares about production timing passes 0.1.
  • publish(topic, msg) dispatches by topic match and raises on an unknown topic or a name/value width mismatch — silently dropping a command is the ADR-0102 failure mode itself. state() zero-fills joints it has never heard from; missing_joints() / wait_for_every_joint() are what let a caller tell that apart from a real pose.
  • Its own tests (tests/hil/test_openarm_ros_transport.py) need only a ROS install, not the cell: real publishers and real messages over DDS on an isolated domain with LOCALHOST discovery, since a stray graph could otherwise reach a live OpenArm on another host.

tests/hil/test_openarm_bringup_agreement.py

OpenArmRealHAL's controller/joint table vs openarm_bringup's own YAML — no hardware, not even the CAN links.

  • The HAL's _command_groups is a hand-copied transcription of names that live in a different repo on a different cadence, and nothing compared the two. A JointTrajectoryController handed joints it does not own rejects the whole message: no publisher-side exception, nothing odd on /joint_states, the arm just does not move — indistinguishable from a policy holding still. Same failure shape ADR-0102 ended, one layer further out.
  • The gripper is the trap and the reason the file exists: bringup calls it openarm_left_finger_joint1, not openarm_left_gripper. Every plausible guess is wrong, so the config is the only safe source.
  • Four checks: every controller the HAL publishes to is declared; each is a JointTrajectoryController (a ForwardCommandController takes Float64MultiArray on /commands, so the HAL's topic and message type would both miss); the joint names match per controller; and the four lists concatenate to exactly the HAL's 16-DoF order, which is what the slice spans mean.
  • Gated on openarm_bringup being on the ament prefix path, so it skips cleanly off-rig. It answers statically what would otherwise need a powered cell: bringing the controllers up to look at them runs OpenArmHW::on_activate → openarm_->enable_all(), energising all sixteen motors. Reading the config energises nothing.

tests/hil/test_openarm_slot_group_motion.py

The one test in the tree that commands a real OpenArm to move.

  • Closes the last ADR-0102 gate: TestSlotGroupDispatch in tests/unit/test_openarm_real_hal.py proves — off-rig, no hardware — that the composed 16-DoF vector reaches the four controllers correctly named and sliced, but never publishes, so it cannot see a sign flip, a scale error, or a joint the controller ignores. This one commands measured-pose + 0.02 rad on one joint, holds the other fifteen at their measured values, and reads the physical result back off /joint_states — asserting the addressed joint arrived and every other joint stayed put.
  • Two independent gates, both explicit: OPENRAL_OPENARM_ALLOW_MOTION=1 (this bench can move) and OPENRAL_OPENARM_ATTENDED=1 (someone is at the E-stop right now). Separate on purpose, so a rig that leaves the first exported does not thereby become a rig that moves unattended.
  • Why a small number is not by itself a small motion: the wire format is an absolute position with a deadline, so travel is (target - measured) / time_from_start — set by how wrong the command is, which is the quantity under test. The bounds that do work are the measured-pose baseline, the 0.8 s window, and the refusal to start until all 16 joints have appeared on /joint_states (a partial state zero-fills into a plausible pose, and "measured + delta" on top of that is a command to slew the cell home).
  • Cases run gripper-first (1-DoF, lowest inertia, and the actuator ADR-0102 exists for) then the most distal arm joint, and each restores the measured pose before returning, so the suite is idempotent.

tests/hil/_aloha_ros_transport.py

4-way fan-out bridge for the bimanual ALOHA HAL.

  • AlohaHILTransport(node, joint_names, *, left_arm_command_topic, right_arm_command_topic, left_gripper_command_topic, right_gripper_command_topic, joint_state_topic) — Owns four JointTrajectory publishers (two arms 6-DOF + two grippers 1-DOF) and one aggregated JointState subscriber. Mirrors AlohaHAL's current wire contract, which issue #250 established is not the rig's: xs_sdk takes interbotix_xs_msgs/JointGroupCommand on /<ns>/commands/joint_group and publishes per-namespace joint states, with no aggregated topic. Retargeted with the HAL. publish(topic, msg) dispatches by topic match; unknown topics raise ValueError. (L62)
  • make_aloha_hil_transport(node_name, *, left/right arm + gripper command topics, joint_state_topic) -> tuple[Node, AlohaHILTransport, Callable[[], None]] — Factory; derives the canonical 14 joint names from the public ALOHA_REAL_DESCRIPTION.joints so the bridge stays in lock-step with the HAL manifest. (L186)

Downstream e-stop gates (issue #295)

One attended test per real HAL, last in its file (test_zz_estop_*): it stops the vendor controller and leaves the robot needing an operator restart.

  • tests/hil/test_ur5e.py::TestUR5eDownstreamEStop / tests/hil/test_ur10e.py::TestUR10eDownstreamEStop — Attach the production RosControlTransport as the stop seam (_production_stop_seam), estop(), then assert the actual downstream state, not only ROSEStopRequested: controller_manager lists scaled_joint_trajectory_controller inactive, the report's vendor_stop is /dashboard_client/stop, and — when ur_dashboard_msgs is installed — /dashboard_client/program_running answers program_running=false.
  • tests/hil/test_franka_panda.py::TestFrankaDownstreamEStop — Same seam; asserts franka_arm_controller inactive (the deactivation that makes franka_hardware call stopRobot()) and vendor_stop == "".
  • tests/hil/test_sawyer.py::TestSawyerDownstreamEStop — Same seam; asserts sawyer_arm_controller inactive, vendor_stop == /robot/set_super_stop, and — when intera_core_msgs is installed — /robot/state.stopped turns true.
  • tests/hil/test_aloha.py::TestAlohaDownstreamEStop — Attaches the production InterbotixXSTransport; asserts every arm's torque_enable(false) was acknowledged (controller_states all torque_off). Both ViperX arms go limp, so the bench must be clear.
  • The SO-101 (tests/hil/test_so101_serial_live.py::test_estop_disconnects_the_live_motor_bus) and Galaxea A1 (tests/hil/test_galaxea_a1*.py) gates already end by driving their own downstream stop; the OpenArm's is proven off-rig in tests/integration/test_real_hal_estop_ros2_control_live.py (its CAN gate does not command motion).