Tests · HIL bridges
Part of the OpenRAL public-symbol inventory. Hand-curated;
(LNN)markers are refreshed bytools/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, viaopenral_cli.autodetect.enumerate_can_interfaces. (L15)- Lives in a
_-prefixed module rather thanconftest.pybecause a conftest is not importable as a module: pytest registers it under its own private name, sofrom tests.hil.conftest import ...raisesModuleNotFoundErroron 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_actionon the wire would mean the kernel is a source of commands rather than a filter on them. - Seven checks against one module-scoped
rclpynode: graph observable; HAL connected with both buses reported up (preflight_can_linksrenders a dead bus as"<name> (DOWN)", so the bare name is the test);controller_managerreporting all fourJointTrajectoryControllers andjoint_state_broadcasteractive;/joint_statescarrying all sixteen ros2_control joints above a 20 Hz floor; the TF tree resolvingworldto both*_ee_base_link; the C++ kernel ACTIVE withenvelope_loaded=trueat 16 DoF; and/openral/world_voxelsarriving with a unit-quaternion lattice and a non-zero occupied count. - The last one is the point of
scenes/deploy/openarm_bench.yaml.head_zedauto-enables the octomap leg, butoctomap_cloud_topickeeps its sim-only launch default (/openral/cameras/front_depth/points) unless the scene pins it — leaving the octree,world_voxelsand the dashboard card empty forever while every node reports healthy. - Gated on
OPENARM_DEPLOY_HIL=1and both CAN links up, so a straypytest tests/hil/never attaches to a live cell. Run it viajust hil-openarm-deploy, which also setsPYTEST_DISABLE_PLUGIN_AUTOLOAD=1— a sourced ROS overlay drags system Python'slaunch_testingplugin into the workspace's pytest, where it is incompatible. The SO-101 bench arm has noros2_controlbridge either — it is a serial robot (SO100FollowerHALover a Feetechscservo_sdkbus, 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 drivingSO100FollowerHAL(tests/unit/test_so100_follower_hal.py) injects aSO100DigitalTwinand never opens a port. Port, lerobot calibrationidandcalibration_dirare all read fromscenes/deploy/so101_bench.yaml'shal:binding rather than hardcoded, so the test andopenral deploy run --config scenes/deploy/so101_bench.yamlcannot drift. It splits in two like the OpenArm's: bench-wiring checks (the committed calibration's joint keys + servo ids matchrobots/so101_follower/robot.yaml;hal.realstill 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/ttyACM0looking healthy and the bus completely silent — a skip, not a failure. No position commands by construction: the powered tier never callssend_action()orreset_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 byjust 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 atrajectory_msgs/JointTrajectorypublisher with the shared HIL QoS profile. Reused by_aloha_ros_transport.py. (L52)RosControlHILTransport(node, joint_names, *, command_topic, joint_state_topic)— Subscribes tojoint_state_topic, exposespublish(_topic, msg)for the HAL's outgoing trajectories andstate()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)— FourJointTrajectorypublishers plus one aggregatedJointStatesubscriber. Simpler than the ALOHA bridge since ADR-0102 putsjoint_namesin the message, so the transport forwards them rather than keeping a second, driftable slice table. Build it fromOpenArmRealHAL.ros2_control_joint_names()— the URDF namespace/joint_statesis keyed by, not the manifest's. (L55)time_from_start_sis a constructor argument, unlike the 100 ms the production transports hardcode. AJointTrajectoryControllergiven 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_groupsis a hand-copied transcription of names that live in a different repo on a different cadence, and nothing compared the two. AJointTrajectoryControllerhanded 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, notopenarm_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(aForwardCommandControllertakesFloat64MultiArrayon/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_bringupbeing 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 runsOpenArmHW::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:
TestSlotGroupDispatchintests/unit/test_openarm_real_hal.pyproves — 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 radon 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) andOPENRAL_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 fourJointTrajectorypublishers (two arms 6-DOF + two grippers 1-DOF) and one aggregatedJointStatesubscriber. MirrorsAlohaHAL's current wire contract, which issue #250 established is not the rig's:xs_sdktakesinterbotix_xs_msgs/JointGroupCommandon/<ns>/commands/joint_groupand publishes per-namespace joint states, with no aggregated topic. Retargeted with the HAL.publish(topic, msg)dispatches by topic match; unknown topics raiseValueError. (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 publicALOHA_REAL_DESCRIPTION.jointsso 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 productionRosControlTransportas the stop seam (_production_stop_seam),estop(), then assert the actual downstream state, not onlyROSEStopRequested:controller_managerlistsscaled_joint_trajectory_controllerinactive, the report'svendor_stopis/dashboard_client/stop, and — whenur_dashboard_msgsis installed —/dashboard_client/program_runninganswersprogram_running=false.tests/hil/test_franka_panda.py::TestFrankaDownstreamEStop— Same seam; assertsfranka_arm_controllerinactive(the deactivation that makesfranka_hardwarecallstopRobot()) andvendor_stop == "".tests/hil/test_sawyer.py::TestSawyerDownstreamEStop— Same seam; assertssawyer_arm_controllerinactive,vendor_stop == /robot/set_super_stop, and — whenintera_core_msgsis installed —/robot/state.stoppedturns true.tests/hil/test_aloha.py::TestAlohaDownstreamEStop— Attaches the productionInterbotixXSTransport; asserts every arm'storque_enable(false)was acknowledged (controller_statesalltorque_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 intests/integration/test_real_hal_estop_ros2_control_live.py(its CAN gate does not command motion).