Layer 1 — Hardware Abstraction (HAL)
Part of the OpenRAL public-symbol inventory. Hand-curated;
(LNN)markers are refreshed bytools/refresh_methods_linenos.py.
python/hal/src/openral_hal/protocol.py
Normative HAL protocol plus explicit optional lifecycle extensions.
class HAL(Protocol)— Structural protocol every HAL adapter must satisfy.- attr
description: RobotDescription connect() -> None— Open connection to robot/sim.disconnect() -> None— Close connection (idempotent).read_state() -> JointState— Latest joint state snapshot (hot path).send_action(action: Action) -> None— Forward action chunk to controller (hot path).estop() -> None— Trigger emergency stop, always raisesROSEStopRequested.class LifecycleEStopHAL(Protocol)— Opt-in propagation of the generic lifecycle e-stop to downstream hardware or owned processes.class ResettableLifecycleEStopHAL(LifecycleEStopHAL, Protocol)— Opt-in in-process recovery contract.class EStopRecovery(StrEnum)— Declares whether recovery is resettable or requires a full lifecycle restart.class HALHealthProvider(Protocol)/class HALHealthReport— Cached, I/O-free diagnostics consumed by the generic lifecycle heartbeat.
python/hal/src/openral_hal/_mujoco_arm.py
Internal MuJoCo-backed HAL implementation shared by UR / Franka / SO-100 / G1 / H1 / Rizon-4 / OpenArm / ALOHA adapters. Reads its wiring from RobotDescription.sim.
class _MujocoArmInitKwargs(TypedDict)— Typed shape of the kwargs accepted byMujocoArmHAL.__init__. (L61) LetsMujocoArmHAL._sim_kwargs_forreturn a value that unpacks cleanly into the constructor undermypy --strictwithout the# type: ignore[arg-type]hatch every thin subclass used to need. Fields:mjcf_path, joint_qpos_addr, joint_qvel_addr, actuator_index, grippers, keyframe_index, seed_ctrl_from_qpos, settle_steps, gravity_enabled, staleness_limit_s._resolve_mjcf_path(desc: RobotDescription) -> str[private] — Resolvedesc.assets.mjcfto an absolute MJCF path viaopenral_core.assets.resolve_asset; raisesROSConfigErrorwhen the ref is unset or unresolvable. Replaced the former publicresolve_mjcf_uri/SimDescription.mjcf_uri. (L81)build_hal(description, *, mode: Literal["sim","real"], transport=None, sim_env_yaml=None) -> HAL— Single seam for constructing a robot's simulation or real-hardware HAL from its manifest (resolver.py, L50).mode="sim"+sim_env_yamlset → callsbuild_sim_env_from_yamland returns aSimAttachedHALwrapping the scene'sSimRollout; bypasses the bare-twin /hal.simclass entirely.mode="sim"withoutsim_env_yamlbuildsdescription.hal.simor derivesMujocoArmHAL.from_descriptionwhen it is null + asim:block exists.mode="real"importsdescription.hal.realand threadstransportkwargs (real HALs takeport/robot_ip/fci_ipand embed their own description). Both modes mergedescription.hal.parameters.defaultsunderneath the explicittransportso the manifest carries a robot's construction kwargs; unaccepted keys are dropped.sim_env_yaml+mode="real"→ROSConfigError. Missing HAL for the mode →ROSCapabilityMismatch; malformed/unresolvable entry →ROSConfigError. Routed bydeploy sim(sim) anddeploy run(real)._import_object(path: str) -> object[private] — Resolve a"module.path:Attribute"import string; raisesROSConfigErroron malformed/unimportable/missing. Reuse watch: the canonical entrypoint-string importer for HAL classes — do not hand-rollimportlibin HAL callers.class MujocoArmHAL— Generic MuJoCo-backed HAL adapter for position-controlled arms (and, via the_per_step_updatehook, torque-controlled humanoids like the H1). (L119)read_images() -> dict[str, NDArray]— Render the manifest's RGBSensorSpecs off the live MJCF, keyed by sensorname(issue #191 Phase 3b). Same contractSimAttachedHAL.read_imagesexposes, soSimSensorBridgepublishes a composed-scene arm's cameras (openarm) through the shared path. Renders the MJCF camerasim_camera_name or nameat that sensor's ownintrinsicsresolution — onemujoco.Rendereris cached per distinct(height, width), so e.g. a 256×256 wrist camera alongside a 640×480 overhead publishes 256×256 (not a shared max); the published frame size always matches the sensor's camera model. A missing camera / render error is skipped with a one-shot warning (never raises). Renderers are created lazily per resolution so each EGL context binds on the caller (executor) thread. Returns{}when disconnected / no RGB sensors / after a renderer failure.__init__(description, *, mjcf_path, joint_qpos_addr, actuator_index, joint_qvel_addr=None, grippers=(), keyframe_index=None, seed_ctrl_from_qpos=False, settle_steps=1, gravity_enabled=True, staleness_limit_s=0.5)— Init only; MJCF is not loaded untilconnect().joint_qvel_addrdefaults tojoint_qpos_addr(correct for arms without a floating base) and is passed explicitly by humanoid HALs likeG1MujocoHAL/H1MujocoHALwhere the free joint shifts the qvel indices by 1.grippersis a sequence ofSimGripperDescriptionentries; single-arm robots ship one (or none), bimanual robots (Aloha, OpenArm) ship two. (L174)_per_step_update(targets) -> None— Hook invoked before everymj_stepinside the settle loop. Default no-op; subclasses driving torque-mode actuators (H1MujocoHAL) override to recompute the actuator torque each step from the currentqpos/qvel.connect() -> None— Load MJCF, prepareMjDatabuffer. Before compiling, runs the generic camera rig (_camera_rig.rig_cameras_into_mjcf): if the MJCF lacks a manifest RGB camera that declares asim_placement, it splices the camera (+ visual-only floor + fill light) into a sibling<name>_camrig.xmland loads that — so a bare-arm deploy twin (so100/so101) renders its declared cameras without a scene composer. Idempotent: a scene-attached / composed MJCF that already has the cameras loads unchanged.disconnect() -> None— Release the MuJoCo model (idempotent).read_state() -> JointState— Joint state in description-joint order. Reads live in-processMjData(always current), so it never latchesROSPerceptionStale: a gap >staleness_limit_ssince the last service means the single-threaded executor was starved (e.g. a slow camera render), not bad data — it emits a one-shothal.read_state.starvedWARNING and returns the live state (re-armed on the next healthy read). The prior behaviour raised before refreshing the clock, so one transient stall bricked the HAL permanently (the deploy-sim "Joint state is X s old" loop). Async live-feedback staleness is policed by the subscription HALs (ros_control/aloha), not here.send_action(action: Action) -> None— Forward last waypoint to MuJoCo and step. Stamps_last_action_nsso the idle stepper yields to a recent command.sim_time_ns() -> int | None— Bare-twin MuJoCo elapsed time in ns, read from liveMjData.time;Nonebefore connect / after disconnect or e-stop. This is the/clockseam for OpenArm / SO-100 / SO-101 deploy-sim graphs, matchingSimAttachedHAL.sim_time_ns()for scene-attached rollouts.clock_authority() -> ClockAuthority— ReturnClockAuthority.simulation("mujoco", timestep_s=model.opt.timestep)while connected, otherwiseClockAuthority.host_wall().idle_step(wall_dt_s=None) -> bool— Sim-only HOLD stepper that gives a bareMujocoArmHALthe cameras-stay-live treatment, plus joint_state published off the executor viaProprioSnapshot+ dedicated thread, that the lifecycle node gates on a callableidle_step. Leavesctrluntouched (it already holds the last commanded / seeded pose). Withwall_dt_s, advances that wall-time slice (bounded to 200 physics steps); without it, advances one legacymj_step. Bare MuJoCo arms set the internal_step_while_activecapability so this wall-time integrator continues during active skills:send_action()advances only one physics tick, and yielding the stepper previously collapsed/clockand top/wrist camera publication to ~0.25 Hz during rollout. ReturnsFalseafter disconnect/e-stop, so it can never autonomously drive an e-stopped robot.- (property)
last_action_ns -> int—time.monotonic_ns()of the lastsend_action(0if never actuated → idle-stepping starts immediately). TheSimSensorBridgereads it (should_idle_step) to yield the idle stepper to a recently-commanded skill. MirrorsSimAttachedHAL.last_action_ns. reset_to_pose(pose: list[float]) -> None— Snapqposto a manifeststarting_poseand re-seedctrl(instantaneous teleport; best-effort). Gripper entries use the HAL's public units: normalized values are mapped throughSimGripperDescription.ctrl_range, so SO-1010.0195reads back as0.0195rather than being mistaken for raw jaw radians. The collision-aware alternative is not a HAL method — the runner dispatches therskill-moveit-multi-jointsrSkill to plan a collision-free MoveGroup motion tostarting_pose(see05-inference-runner/08-cli). (L597)estop() -> None— Zeroctrland raiseROSEStopRequested.- (classmethod)
from_description(description, *, settle_steps=None, gravity_enabled=True, staleness_limit_s=0.5, mjcf_path_override=None) -> MujocoArmHAL— Manifest-driven constructor. Readsdescription.simand builds the HAL with the right MJCF path, qpos/qvel/actuator maps and gripper config. Removes the need for per-robot Python subclasses. (L952) - (staticmethod)
_sim_kwargs_for(description, *, settle_steps=None, gravity_enabled=True, staleness_limit_s=0.5, mjcf_path_override=None) -> _MujocoArmInitKwargs— Translatedescription.siminto the__init__kwarg dict. Default 1:1 joint→qpos/actuator mapping is derived fromdescription.joints, offset by 7 (qpos) / 6 (qvel) whensim.floating_base=True. Used by bothfrom_description,_init_from_description, and any caller that wants to post-process the kwargs. (L876) - (instance method)
_init_from_description(description, *, mjcf_path=None, settle_steps=None, gravity_enabled=True, staleness_limit_s=0.5) -> None— Seam every thin per-robot subclass (UR5e/UR10e, Franka, ALOHA, OpenArm, Rizon4, G1, H1, SO-100) uses to drop the boilerplatesuper().__init__(DESC, **MujocoArmHAL._sim_kwargs_for(DESC, …))dance. Subclasses keep their typed__init__(*, mjcf_path, settle_steps, gravity_enabled, staleness_limit_s)signature (so IDEs still surface the four user-tunable knobs) and forward straight to here. (L1008) - private:
_require_connected,_validate_action,_last_arm_targets,_apply_arm_targets,_apply_gripper_target,_read_gripper_normalised,_effective_actuator_index_for
python/hal/src/openral_hal/_camera_rig.py
Generic sim camera rig — splice manifest cameras into a bare-arm MJCF for deploy sim.
rig_cameras_into_mjcf(xml: str, sensors: list[SensorSpec]) -> tuple[str, bool]— For each RGBSensorSpecwith asim_placementwhose camera (sim_camera_name or name) is absent fromxml, splice a<camera>(look-at orientation viaopenral_core.geometry.look_at_quat_wxyz,-zMuJoCo view axis; FoV fromsim_placement.fovy_degor derived fromintrinsics) into the namedparent_body(a wrist camera) or<worldbody>(a world-fixed overhead), plus minimal staging — a visual-only (contype=0 conaffinity=0, no collisions) ground plane and an ambient fill light — when the MJCF declares none. Returns(xml, changed);changed=False(input untouched) when no rigging is needed, so a scene-attached / already-composed MJCF passes through and the caller loads the original. Idempotent. RaisesROSConfigErrorwhen a sensor'sparent_bodyis missing or there is no</worldbody>for a world camera. Called byMujocoArmHAL.connect.
python/hal/src/openral_hal/_real_description.py
Internal helper to derive a real-hardware RobotDescription from a sim baseline.
make_real_description(base, *, sdk_kind) -> RobotDescription—model_copy(update={"sdk_kind": sdk_kind}); thehalentrypoints (hal.sim/hal.real) are inherited from base. (L48)
python/hal/src/openral_hal/franka_panda.py
HAL adapter for the Franka Emika Panda 7-DoF arm (sim, MuJoCo).
class FrankaPandaHAL(MujocoArmHAL)— Franka Panda HAL (MuJoCo-backed). Thin manifest-driven wrapper aroundMujocoArmHAL;__init__forwards toself._init_from_description(FRANKA_PANDA_DESCRIPTION, …). (L266)__init__(*, mjcf_path=None, settle_steps=1, gravity_enabled=True, staleness_limit_s=0.5)(L295)_panda_joint_specs() -> list[JointSpec](L122)- const
FRANKA_PANDA_DESCRIPTION = RobotDescription(...)(L176) — sim baseline;sdk_kind="open",hal.sim="openral_hal.franka_panda:FrankaPandaHAL"+hal.real="openral_hal.franka_panda_real:FrankaPandaRealHAL". All MuJoCo wiring (MJCF URI, joint→qpos/actuator maps, gripper config) lives inFRANKA_PANDA_DESCRIPTION.sim. The real-HW companionFRANKA_PANDA_REAL_DESCRIPTIONlives infranka_panda_real.py.
python/hal/src/openral_hal/franka_panda_real.py
Real-hardware HAL adapter for the Franka Emika Panda over the FCI (issue #56).
class FrankaPandaRealHAL— Production adapter for a physical Panda overfranka_ros2/ FCI. WrapsRosControlHALvia composition. (L90)__init__(*, fci_ip='172.16.0.2', controller_name='franka_arm_controller', joint_state_topic='/joint_states', command_topic=None, error_recovery_topic='/error_recovery/goal', publish_fn=None, state_fn=None, staleness_limit_s=0.2)(L144)description -> RobotDescription[@property] — ReturnsFRANKA_PANDA_REAL_DESCRIPTION. (L180)controller_name -> str[@property] (L185)fci_ip -> str[@property] (L190)connect() -> None(L196)disconnect() -> None(L214)read_state() -> JointState(L220)send_action(action) -> None(L230)estop() -> None— Publishes to/error_recovery/goalthen raisesROSEStopRequested. (L246)- const
FRANKA_PANDA_REAL_DESCRIPTION = make_real_description(FRANKA_PANDA_DESCRIPTION, sdk_kind="closed_with_api")(L84) — inherits the sharedhal; whatrobots/franka_panda/robot.yamlmirrors.
python/hal/src/openral_hal/sawyer_real.py
Real-hardware HAL adapter for the Rethink Sawyer 7-DoF arm (issue #57).
class SawyerRealHAL— Production adapter for a physical Sawyer overintera_sdk/sawyer_robot. (L219)__init__(*, hostname='sawyer.local', controller_name='sawyer_arm_controller', joint_state_topic='/robot/joint_states', command_topic=None, estop_topic='/robot/set_super_stop', publish_fn=None, state_fn=None, staleness_limit_s=0.2)(L266)description -> RobotDescription[@property] — MirrorsSAWYER_DESCRIPTION. (L302)hostname -> str[@property] (L307)controller_name -> str[@property] (L312)connect() -> None(L316)disconnect() -> None(L330)read_state() -> JointState(L334)send_action(action) -> None(L344)estop() -> None(L354)_sawyer_joint_specs() -> list[JointSpec](L111)- const
SAWYER_DESCRIPTION = RobotDescription(...)(L154) — sim baseline;sdk_kind="open",hal.sim=None(no MuJoCo HAL adapter today) +hal.real="openral_hal.sawyer_real:SawyerRealHAL". - const
SAWYER_REAL_DESCRIPTION = make_real_description(SAWYER_DESCRIPTION, sdk_kind="closed_with_api")(L194) — inherits the sharedhal; whatrobots/sawyer/robot.yamlmirrors.
python/hal/src/openral_hal/panda_mobile.py
In-process digital-twin HAL for the panda_mobile embodiment (Franka 7-DoF arm on a holonomic 3-DoF base). Built by build_hal for the manifest-driven ManifestHALLifecycleNode (issue #191 Phase 3) and by tests; ROS node entrypoint in packages/openral_hal_panda_mobile/.
- const
PANDA_MOBILE_BASE_JOINT_NAMES: list[str]— Base joints[base_x, base_y, base_yaw], derived fromPANDA_MOBILE_DESCRIPTION.base_joints(not hardcoded). (L100) - const
PANDA_MOBILE_JOINT_NAMES: list[str]— Full 11-DoF order: base (3) + arm (7, role-derived) + gripper (1, role-derived) — all from the description. (L116) - const
PANDA_MOBILE_DESCRIPTION: RobotDescription— Canonical RobotDescription, loaded fromrobots/panda_mobile/robot.yamlat module import. Single source of truth for joint metadata +sim_joint_nameoverrides; the arm/base/gripper name constants above derive from it viaJointSpec.role. (L93) class PandaMobileHAL— In-process digital-twin HAL. RoutesBODY_TWIST→ planar Euler integration of (vx, vy, wz); routesJOINT_POSITION→ 7-vec arm targets or 11-vec base+arm+gripper targets. (L132)- (removed: the
base_sim_joint_namesre-export wrapper — callers now importopenral_core.extract_base_sim_joint_namesdirectly.)
python/hal/src/openral_hal/depth_cloud.py
Reusable, robot-agnostic depth-camera → sensor_msgs/PointCloud2 plumbing for deploy-sim HAL nodes (octomap_server source → kernel world-collision check). Pure SensorSpec adapters + the ROS msg builder; the ray-cast synth lives in openral_sim.backends.depth_camera.
- is_depth_sensor(spec) -> bool — True when spec.modality in ("depth", "point_cloud") and it carries pinhole intrinsics (required to back-project). (L41)
- mjcf_camera_name(spec) -> str — Resolves the backing MJCF <camera> name: spec.metadata["mjcf_camera"] if set (the sim camera name can differ from the ROS sensor name), else spec.name. (L50)
- robot_self_body_ids(model, sim_joint_names) -> frozenset[int] — Every MJCF body whose name shares a first-_-token prefix with one of the robot's sim_joint_names (e.g. mobilebase0 / robot0 / gripper0). Passed as synthesize_depth_pointcloud(exclude_body_ids=…) so the depth cloud is self-filtered (the robot is not voxelised into its own world map). (L105)
- depth_synth_kwargs(spec, *, max_range_default, render_size=None) -> dict — Maps a depth SensorSpec to synthesize_depth_pointcloud kwargs (width/height/fx/fy/cx/cy + min_range_m/max_range_m from range_min_m/range_max_m, falling back to max_range_default). When render_size=(width, height) is given (the scene's observation_width/height), the intrinsics are first rescaled via openral_core.scale_intrinsics_to so the ray-cast grid matches the render resolution. (L63)
- resolve_base_body_name(model, *, description=None) -> str | None — Resolve the MJCF body backing the robot's base_frame: when a RobotDescription is given, the first base joint's prefix + _base (mobilebase0_base); then the bare candidates mobilebase0_base / base / robot0_base / base_link — mobilebase0_base tried before robot0_base because in composed robosuite/RoboCasa scenes robot0_base is a placeholder mount at a fixed offset; None if none exist. Backs both the depth/TF base resolution (SimSensorBridge._resolve_depth_base_body) and the viewer free-camera fallback. (L136)
- preferred_viewer_camera_id(model, *, prefer=("agentview","top","frontview","front")) -> int — Pick the named MJCF camera whose vantage the viewer should open from: the first camera whose name contains a prefer substring (a 3rd-person workspace view — robot0_agentview_left, top, agentview), else the first declared camera (e.g. a wrist/eye-in-hand cam), else -1 when the model has no cameras. Scene cameras are authored to frame the action, sidestepping the free orbit's occlusion in cluttered scenes (a base-centred orbit in a RoboCasa kitchen stares at a wall). Consumed by initial_viewer_camera. (L182)
- initial_viewer_camera(*, model, data, description=None) -> tuple[tuple[float,float,float], float, float, float] — Opening free-camera pose (lookat, distance, azimuth_deg, elevation_deg) for the viewer. The viewer always uses mjCAMERA_FREE so the user keeps full mouse control (drag-orbit, scroll-zoom) — this only sets the initial view; a mjCAMERA_FIXED lock would freeze those controls. When preferred_viewer_camera_id finds an authored camera, the eye is placed at that camera's data.cam_xpos with the orbit pivot on the robot base (resolve_base_body_name, else model.stat.center), so the opening view matches the authored vantage yet orbits around the robot; else delegates to base_aligned_free_camera. Reproduces the eye exactly via MuJoCo's eye = lookat − distance·f, f = (cos el cos az, cos el sin az, sin el). (L349)
- apply_robosuite_visual_geomgroups(opt, model) -> bool — For a robosuite/RoboCasa model, set opt.geomgroup to hide collision shells (group 0 — RoboCasa's red kitchen / green robot capsules) and show the textured visual geoms (group 1), so mujoco.viewer renders textures instead of a red collision box. Gated on a robosuite signature (a robot0_/gripper0_/mobilebase0_ body or an agentview/frontview camera) — not geom counts, since dm_control/gym scenes (gym-aloha) put visuals in group 0; returns True when it acted, False (no-op) otherwise. Used by the eval sim run --view viewer. (L225)
- base_aligned_free_camera(*, model, data, base_body_name=None, azimuth_offset_deg=135.0, elevation_deg=-25.0, distance_scale=2.0, max_distance_m=3.5) -> tuple[tuple[float,float,float], float, float, float] — Fallback free-camera framing (lookat_xyz, distance, azimuth_deg, elevation_deg) for camera-less models (single-robot twins): centres on the robot base and offsets the azimuth by the base frame's world yaw so the view aligns to the base's own axes (MuJoCo's world frame is immutable, so the viewer cannot be re-rooted onto base_link). distance is distance_scale × model.stat.extent capped at max_distance_m (a composed scene's whole-model extent would otherwise push the camera tens of metres out). Falls back to model.stat.center with no yaw when base_body_name is None/absent. Shared with the openral sim run --view eval path. (L260)
- camera_optical_tf_to_base(*, model, data, camera_name, base_body_name) -> tuple[tuple[float,float,float], tuple[float,float,float,float]] — Live (translation_xyz, quat_xyzw) of the camera optical frame (REP-103) expressed in the base body, from data.cam_xpos/cam_xmat vs the base body pose, so a node broadcasts base_frame → <camera>_optical_frame. Raises ROSConfigError if camera/body absent. (L407)
- pointcloud2_from_points_xyz(points, *, frame_id, stamp=None) -> PointCloud2 — Packs an (N, 3) float32 array into an unordered (height=1) XYZ-float32 sensor_msgs/PointCloud2 — the layout octomap_server's cloud_in expects (sensor_msgs imported lazily). (L459)
- depth_image_from_grid(depth, *, frame_id, stamp=None) -> Image — Packs an (H, W) float32 metric-depth raster (from synthesize_depth_image) into a 32FC1 sensor_msgs/Image (step=4·W, row-major; 0.0 = no measurement) for nvblox's projective depth integrator (sensor_msgs imported lazily). (L500)
- camera_info_from_intrinsics(*, width, height, fx, fy, cx, cy, frame_id, stamp=None) -> CameraInfo — Builds a pinhole sensor_msgs/CameraInfo for a synthesised depth image — K=[fx,0,cx;0,fy,cy;0,0,1], identity R, P mirroring K (no baseline), zero plumb_bob distortion (MuJoCo ray-cast has none). Callers pass the stride-scaled intrinsics so the model matches the rasterised image. (L537)
python/hal/src/openral_hal/aloha.py
HAL adapter for the Trossen ALOHA bimanual setup (issue #58) + the MuJoCo digital twin.
class AlohaHAL(HALBase)— Real-hardware adapter for the 14-DoF ALOHA over the Interbotix XS SDK. (L332)__init__(*, left_arm_controller='left_arm/arm_controller', right_arm_controller='right_arm/arm_controller', left_gripper_controller='left_arm/gripper_controller', right_gripper_controller='right_arm/gripper_controller', joint_state_topic='/joint_states', estop_topic='/aloha/estop', publish_fn=None, state_fn=None, staleness_limit_s=0.2)(L380)connect() -> None(L411)disconnect() -> None(L428)read_state() -> JointState(L435)send_action(action) -> None— Splits the 14-D action 4-ways across per-arm + per-gripper controllers. (L467)estop() -> None(L531)- private:
_require_connected class AlohaMujocoHAL(MujocoArmHAL)— MuJoCo digital twin for the 14-DoF bimanual ALOHA; thin manifest-driven wrapper aroundMujocoArmHAL(bimanual amendment). All wiring lives inALOHA_DESCRIPTION.sim:gym_aloha:bimanual_viperx_transfer_cubeURI, explicitjoint_qpos_addr/actuator_index(left arm 0-5, left gripper 6, right arm 8-13, right gripper 14 — skipping the negative-finger slots), twoPASSTHROUGHgrippers withmirror_actuator_index(positive finger + negative finger),keyframe_index: 0(seeds the fingers insidectrlrange=[0.021, 0.057]). (L568)__init__(*, mjcf_path=None, settle_steps=1, gravity_enabled=True, staleness_limit_s=0.5)— Forwards toself._init_from_description(ALOHA_DESCRIPTION, …). (L603)_aloha_joint_specs() -> list[JointSpec](L147)_default_publish(topic, msg) -> None(L556)- const
ALOHA_DESCRIPTION = RobotDescription(...)(L191) — sim baseline;sdk_kind="open",hal.sim="openral_hal.aloha:AlohaMujocoHAL"+hal.real="openral_hal.aloha:AlohaHAL". - const
ALOHA_REAL_DESCRIPTION = make_real_description(ALOHA_DESCRIPTION, sdk_kind="closed_with_api")(L303) — inherits the sharedhal; whatrobots/aloha_bimanual/robot.yamlmirrors.
python/hal/src/openral_hal/ur.py
HAL adapters for the Universal Robots UR5e and UR10e arms (sim, MuJoCo).
class UR5eHAL(MujocoArmHAL)— UR5e HAL (MuJoCo-backed). Thin manifest-driven wrapper;__init__forwards toself._init_from_description(UR5e_DESCRIPTION, …). (L302)__init__(*, mjcf_path=None, settle_steps=1, gravity_enabled=True, staleness_limit_s=0.5)(L326)class UR10eHAL(MujocoArmHAL)— UR10e HAL (MuJoCo-backed). Same shape asUR5eHAL. (L344)__init__(*, mjcf_path=None, settle_steps=1, gravity_enabled=True, staleness_limit_s=0.5)(L356)ur5e_with_sensors(catalog_ids=None) -> RobotDescription(L246)ur10e_with_sensors(catalog_ids=None) -> RobotDescription(L272)_ur_joint_specs(velocity_limits, effort_limits) -> list[JointSpec](L119)- const
UR5e_DESCRIPTION = RobotDescription(...)(L157) — sim manifest; all MuJoCo wiring lives inUR5e_DESCRIPTION.sim. - const
UR10e_DESCRIPTION = RobotDescription(...)(L201) — sim manifest; all MuJoCo wiring lives inUR10e_DESCRIPTION.sim.
python/hal/src/openral_hal/ur_real.py
Real-hardware HAL adapters for UR5e / UR10e via ros2_control + ur_robot_driver (URCap / RTDE).
class UR5eRealHAL(_URRealHAL)— Real UR5e viaur_robot_driver. (L147)class UR10eRealHAL(_URRealHAL)— Real UR10e viaur_robot_driver. (L199)class _URRealHAL(RosControlHAL)— Shared real-HW base (controller / topic defaults +deadman_topic). (L88)- const
UR5e_REAL_DESCRIPTION = make_real_description(UR5e_DESCRIPTION, sdk_kind="closed")(L77) — inherits the sharedhal; whatrobots/ur5e/robot.yamlmirrors. - const
UR10e_REAL_DESCRIPTION = make_real_description(UR10e_DESCRIPTION, sdk_kind="closed")(L82) — inherits the sharedhal; whatrobots/ur10e/robot.yamlmirrors.
python/hal/src/openral_hal/so100_follower.py
SO100FollowerHAL — wraps lerobot's SO-100 follower arm USB driver.
class SO100FollowerHAL— HAL adapter wrapping lerobot's SO-100 follower. (L281)__init__(port='/dev/ttyUSB0', *, calibrate_on_connect=False, max_relative_target=None, staleness_limit_s=0.5, robot=None)(L323)connect() -> None— Open USB serial connection. (L365)disconnect() -> None— Close USB, disable motor torque (idempotent). (L475)read_state() -> JointState— Joint state in radians. (L488)send_action(action: Action) -> None— Forward one step to the SO-100 motor bus. (L516)reset_to_pose(pose: list[float]) -> None— Slow linear ramp current → target (speed-capped_RESET_MAX_RAD_S, duration clamped[_RESET_MIN_S, _RESET_MAX_S],_RESET_STEP_HZwaypoints) — the real-arm counterpart of the sim arms' qpos snap; makes the HAL lifecycle node auto-open/openral/<robot>/reset_to_pose, so realdeploy runstarts VLAs from their manifeststarting_pose. (L539)estop() -> None— Disconnect motors then raise. (L602)_require_connected(operation: str),_obs_to_positions(obs)[@staticmethod],_action_to_lerobot(action)_joint_values_to_lerobot(step) -> dict[str, float](module-level) — THE single manifest-order → lerobot{"<joint>.pos": …}unit conversion (rad→deg arm joints,[0,1]→[0,100]gripper); both_action_to_lerobotand thereset_to_poseramp route through it so a calibration/range change can never apply to one actuation path and not the other. (L253)_deg_to_rad(deg) -> float(L248)_rad_to_deg(rad) -> float(L273)- const
SO100_DESCRIPTION = RobotDescription(...)(L103)
python/hal/src/openral_hal/galaxea_a1.py
Real-only Galaxea A1 HAL. OpenRAL stays ROS 2 / Python 3.12; the operator's official ROS 1 Noetic SDK runs out of process behind a literal IPv4-loopback JSON-lines sidecar. No vendor source, binary, or message package is distributed.
class GalaxeaA1HAL(HALBase)— six-axis joint-position + normalized gripper adapter.read_stateandsend_actionuse a cached snapshot/latest target so network I/O stays off the HAL hot path. Commands fail closed on stale state/status, unaccepted motor bits, non-finite values, initial target misalignment, or an excessive feedback-relative target step. Command limits remain exact; a separately tracked 0.01 rad feedback-only endpoint tolerance absorbs encoder zero/quantization at a nominal URDF boundary.estopasks the sidecar to stop its owned ROS 1 stack and always raisesROSEStopRequested.- const
GALAXEA_A1_DESCRIPTION— real-onlyRobotDescription, mirrored byrobots/galaxea_a1/robot.yaml; official A1 URDF joint names/limits, explicit sidecar deadlines, motor masks, 0..104 mm normalized gripper mapping, and the calibrated D455 front / D405 wrist RGB observation contracts. The six joint origins, orientations, and axes are transcribed from the official A1 URDF; collision primitives remain absent until their redistribution and lowering provenance is cleared. tools/galaxea_a1_ros1_sidecar.py— Python-3.8-compatible ROS 1 process that ownsroscore,signal_arm/single_arm_node.launch, and the officialmobiman/jointTracker_demo_nodebinary. The tracker publishes to/openral/arm_joint_command_staged; a sidecar-owned relay is the sole publisher to/arm_joint_command_host. The relay staysLOCKEDuntil the first target, thenARMINGuntil a fresh, valid tracker command is aligned with both that target and measured joint feedback. OnlyACTIVEforwards unchanged tracker commands, and gripper setpoints are gated on the same machine — a gripper command while the relay isLOCKED/ARMINGis refused fail-closed (the gripper bypasses the tracker's staged-hold interpolation, so it must never actuate before alignment). Repeated identical joint and gripper setpoints refresh the command lease without restarting the official tracker. A command lease, alignment timeout, malformed command, stale feedback, motor fault, client disconnect, or e-stop stops the complete owned process group.tools/run_galaxea_a1_sidecar.sh— Docker launcher. Requires an explicit operator-provided image and SDK path; mounts both read-only and claims only the selected serial device. The SDK remains read-only; the official tracker's generated CppAD files go to$XDG_CACHE_HOME/openral/galaxea-a1/x1_robot(or the equivalent path below~/.cache).--check-onlyverifies the local image, required SDK files, cache parent, serial ownership, loopback port, container name, and process lock without opening the serial device or starting a container.
Galaxea A1 hardware bring-up
The first session is observation-only until the HAL graph is healthy. Ensure no other process/container owns the serial device, the arm workspace is clear, and the physical e-stop is reachable.
# One-time: build OpenRAL's vendor-free Noetic runtime image. The official SDK
# is mounted at run time and is never copied into the image.
docker build \
-t openral/galaxea-a1-sidecar:noetic \
docker/galaxea_a1_sidecar
# One-time: build OpenRAL's standard public x86 deploy image (Jazzy/Python 3.12).
just docker-build-x86
# Read-only gate — checks the image, SDK, serial ownership, port, and lock.
tools/run_galaxea_a1_sidecar.sh \
--image openral/galaxea-a1-sidecar:noetic \
--sdk-root /absolute/path/to/A1_SDK \
--serial /dev/a1 \
--check-only
# Terminal 1 — isolated ROS 1 bridge network; only TCP 46011 reaches loopback.
tools/run_galaxea_a1_sidecar.sh \
--image openral/galaxea-a1-sidecar:noetic \
--sdk-root /absolute/path/to/A1_SDK \
--serial /dev/a1
# Terminal 2 — OpenRAL's standard real-hardware path. The OpenRAL container uses
# host networking only for ROS 2 DDS and the sidecar's loopback TCP port; it owns
# no Galaxea serial device and cannot see the ROS 1 master inside the sidecar.
docker run --rm --name openral-galaxea-a1 --network host \
--volume "$(pwd)/robots:/workspace/robots:ro" \
--volume "$(pwd)/scenes:/workspace/scenes:ro" \
--volume "$(pwd)/tests:/workspace/tests:ro" \
openral:x86 \
--config scenes/deploy/galaxea_a1_bench.yaml
# Terminal 3 — observation gate: six named joints update; diagnostics are clean.
docker exec openral-galaxea-a1 bash -lc \
'source /opt/ros/jazzy/setup.bash && source /workspace/install/setup.bash && \
ros2 topic echo /joint_states --once && ros2 topic echo /diagnostics --once'
An optional HAL-level HIL gate can run between Terminal 1 and the full deploy. It opens one sidecar session, validates three fresh finite named-joint samples plus cached motor health, and ends by verifying that downstream e-stop stops the owned ROS 1 stack. Restart Terminal 1 afterwards:
GALAXEA_A1_HIL=1 just hil galaxea_a1
Only after that observation-only run passes, opt into a measured-current-pose
hold. Feedback within the tracked 0.01 rad endpoint tolerance is projected to
the exact command limit; any larger projection fails before publication. The
test also waits for the sidecar relay to report ACTIVE, proving the official
tracker has converged from its compiled task.info initial pose before any
host motor command is forwarded:
GALAXEA_A1_HIL=1 GALAXEA_A1_ALLOW_HOLD=1 just hil galaxea_a1
After the hold passes, a separate lab opt-in moves arm_joint1 by +0.01 rad,
requires it to settle within 0.008 rad (covering the measured 0.007 rad
small-command residual), continuously bounds all six joint excursions, returns
to the measured start, and then performs the same downstream e-stop:
GALAXEA_A1_HIL=1 GALAXEA_A1_ALLOW_NUDGE=1 just hil galaxea_a1
The G2 gripper has its own opt-in. It uses the vendor example's 10 mm step,
mapped through the normalized 0..1 contract over the configured 104 mm
stroke, chooses the direction away from the nearest endpoint, verifies feedback
within the measured 2.5 mm steady-state tolerance, and returns to the measured
opening even when the outbound-leg assertion fails:
GALAXEA_A1_HIL=1 GALAXEA_A1_ALLOW_GRIPPER=1 just hil galaxea_a1
After the HAL-level gates pass, the full-graph HIL runs inside the deploy
container. It captures the current named-joint feedback itself, requires the
C++ kernel and real HAL to be active while the relay is still LOCKED, then
publishes only that measured hold through candidate_action. It verifies the
matching safe_action, exact staged/forwarded targets, zero kernel drops, and
less than one degree of drift. Its finally path publishes /openral/estop
three times and requires the HAL diagnostics to confirm the latch:
docker exec \
--env GALAXEA_A1_DEPLOY_HIL=1 \
--env GALAXEA_A1_ALLOW_HOLD=1 \
openral-galaxea-a1 \
bash -lc 'source /opt/ros/jazzy/setup.bash && \
source /workspace/install/setup.bash && \
pytest -q /workspace/tests/hil/test_galaxea_a1_deploy.py'
After the current-pose full-graph gate passes, the same fixture has a separate
motion opt-in. It moves arm_joint1 by +0.01 rad through
candidate_action -> C++ safety kernel -> safe_action, bounds all six joint
excursions, and returns to the measured start before the downstream e-stop:
docker exec \
--env GALAXEA_A1_DEPLOY_HIL=1 \
--env GALAXEA_A1_ALLOW_HOLD=1 \
--env GALAXEA_A1_ALLOW_NUDGE=1 \
openral-galaxea-a1 \
bash -lc 'source /opt/ros/jazzy/setup.bash && \
source /workspace/install/setup.bash && \
pytest -q /workspace/tests/hil/test_galaxea_a1_deploy.py'
This test intentionally ends the hardware session. Restart both the sidecar and deploy container before any later motion test.
Do not start a policy on the first pass. Stop both commands and investigate if feedback/status becomes stale, a motor code other than the manifest's explicit idle/gripper masks appears, joint order differs, the sidecar exits, or the arm moves before an approved safe action. Motion validation then proceeds with a current-pose hold and a single <=0.01 rad joint increment through OpenRAL's standard candidate-action → C++ kernel → safe-action path, then return-to-start; only afterwards run an A1-specific rSkill.
LingBot-VA rSkill through the complete OpenRAL path
rskills/lingbot-va-galaxea-a1-fruit-placement/rskill.yaml is the first
checkpoint-specific A1 rSkill. The dependency direction is deliberate:
A1 Camera Bridge -> OpenRAL WorldState -> LingBot-VA rSkill
-> A1 Runtime policy gateway (model contract + EEF/cache + IK)
-> OpenRAL candidate_action -> C++ safety kernel -> safe_action
-> GalaxeaA1HAL -> isolated ROS 1 sidecar -> official A1 driver
The A1 Runtime is a public capability provider, not a second controller:
start only its persistent camera owner, LingBot policy server, and OpenRAL
policy gateway. The gateway has no ROS imports or command publisher. Do not
start its LingBot ROS execution bridge or A1 joint runtime while OpenRAL owns
the deployment. The rSkill owns its policy_extras.max_joint_substep_rad replay
setting and reads the independent max_target_step_rad ceiling from the same
RobotDescription used to construct the HAL. Startup rejects a policy bound
that exceeds either the HAL's live target-step ceiling or locked-relay
alignment tolerance. The policy bound is 0.045 rad, below the 0.05 rad
locked-relay alignment threshold; the independent HAL/sidecar live limit is
0.08 rad. The gateway constructs Runtime's IK implementation with the active
OpenRAL RobotDescription's ordered command limits after verifying they are no
wider than Runtime's envelope. Runtime calibration margins therefore cannot
widen the typed OpenRAL, safety-kernel, or official sidecar command envelope.
The gateway emits one bounded target per 30 Hz control tick. When its IK solution is farther than 0.045 rad from fresh feedback, it keeps advancing toward that same solved target on subsequent ticks and only consumes the next model action after the solved target has been dispatched. The FK of the actual dispatched target is written into the KV cache, so the policy state reflects what OpenRAL commanded rather than an unreachable ideal. The official tracker's steady-state error cannot widen the command envelope or bypass the bounded step. The A1 Runtime's 1.70 rad IK-solution validation remains an upstream reachability check, not a motor-command step limit.
# Terminal A — A1 Runtime capability providers only (no ROS command publisher).
cd /absolute/path/to/A1-Research
just cameras start
scripts/apps/lingbot/a1_lingbot_runtime.sh server
uv run galaxea-a1-openral-policy \
--config configs/deployments/lingbot/fruit_placement_eef.toml \
--repo-root .
# Terminal B — official ROS 1 sidecar, as in the bring-up section above.
cd /absolute/path/to/OpenRAL
tools/run_galaxea_a1_sidecar.sh \
--image openral/galaxea-a1-sidecar:noetic \
--sdk-root /absolute/path/to/A1_SDK \
--serial /dev/a1
# Terminal C — the complete OpenRAL real deployment.
cd /absolute/path/to/OpenRAL
uv run --group lingbot openral deploy run \
--config scenes/deploy/galaxea_a1_bench.yaml
Submit the exact trained prompt (for example, put the red mango into the blue
plate) through the dashboard. Before allowing a task motion, first repeat the
observation, hold, joint-nudge, gripper, and full-graph gates above. Stop the
LingBot server afterwards with
scripts/apps/lingbot/a1_lingbot_runtime.sh server-stop.
The A1 opts into hardware-downstream e-stop: /openral/estop stops the
sidecar-owned tracker and driver immediately. The generic
/openral/estop_cleared broadcast cannot re-arm this HAL; restart the lifecycle
and sidecar, re-read motor health, and repeat initial alignment instead.
python/hal/src/openral_hal/h1.py
MuJoCo digital twin for the Unitree H1 humanoid (Menagerie MJCF). Contract validator only — falls without an S0 cerebellum; gravity must be disabled in closed-loop tests (CLAUDE.md §6.2). Unlike the G1 / UR / Franka / SO-100 MJCFs, the H1 menagerie ships motor (torque) actuators, so this HAL runs a software PD position loop every physics step.
class H1MujocoHAL(MujocoArmHAL)— 19-DoF humanoid HAL drivingmujoco_menagerie/unitree_h1/h1.xml. Joint inventory: 5 leg + 5 leg + 1 torso + 4 arm + 4 arm (no wrists). Thin manifest-driven wrapper aroundMujocoArmHAL;__init__forwards toself._init_from_description(H1_DESCRIPTION, …). Inheritsconnect/disconnect/read_state/estop; overrides_apply_arm_targetsto a no-op and_per_step_updateto computetau = kp*(target - q) - kv*dqclamped toctrlrangeso the public action contract stays "position targets in radians". Mirrors howunitree_sdk2wraps motor-level torque control in a position loop on real hardware. (L358)__init__(*, mjcf_path=None, settle_steps=1, gravity_enabled=True, staleness_limit_s=0.5)(L398)_per_step_update(targets) -> None— Recomputes PD torque everymj_step._apply_arm_targets(targets) -> None— No-op (PD loop runs per-step instead)._h1_group(joint_name) -> str— Return the kinematic group token (hip/knee/ankle/torso/shoulder/elbow) forjoint_name. (L209)_h1_parent_child(joint_name) -> tuple[str, str]— Return(parent_link, child_link)for an H1 joint. (L217)_h1_joint_specs() -> list[JointSpec]— Build the 19JointSpecs from the joint-name tuples + the per-joint limit tables. (L253)_h1_pd_gains() -> dict[str, tuple[float, float]]— Per-joint(kp, kv)for the software PD loop (kv = 0.05*kp; kp sized so a 1-rad error roughly saturates each actuator's ctrlrange). (L349)- const
H1_DESCRIPTION = RobotDescription(...)(L276) — sim baseline;sdk_kind="open",hal.sim="openral_hal.h1:H1MujocoHAL"+hal.real=None(sim-only until M2). All MuJoCo wiring (MJCF URI, floating-base joint offsets +7/+6, PD gains) lives inH1_DESCRIPTION.sim. Drift-guarded againstrobots/h1/robot.yamlbytests/unit/test_robot_manifests_match_hal_constants.py.
python/hal/src/openral_hal/flexiv_rizon4.py
MuJoCo digital twin for the Flexiv Rizon 4 — 7-DoF cobot with whole-body force sensitivity (0.1 N). Structurally identical to the UR / Franka sim HALs: position actuators, no gripper, no floating base, no PD-loop overrides — a clean MujocoArmHAL subclass.
class Rizon4MujocoHAL(MujocoArmHAL)— 7-DoF HAL drivingmujoco_menagerie/flexiv_rizon4/flexiv_rizon4.xmlviaMujocoArmHAL. Thin manifest-driven wrapper;__init__forwards toself._init_from_description(RIZON4_DESCRIPTION, …). (L180)__init__(*, mjcf_path=None, settle_steps=1, gravity_enabled=True, staleness_limit_s=0.5)(L210)_rizon4_joint_specs() -> list[JointSpec]— Build the 7JointSpecs from the joint-name tuple + per-joint limit tables. (L111)- const
RIZON4_DESCRIPTION = RobotDescription(...)(L132) — sim baseline;sdk_kind="open",hal.sim="openral_hal.flexiv_rizon4:Rizon4MujocoHAL"+hal.real=None(sim-only). All MuJoCo wiring lives inRIZON4_DESCRIPTION.sim. Drift-guarded againstrobots/rizon4/robot.yamlbytests/unit/test_robot_manifests_match_hal_constants.py.
python/hal/src/openral_hal/openarm.py
MuJoCo digital twin for the Enactic OpenArm v2 bimanual humanoid arm. Fresh HALBase subclass — v2's native <position> actuators with per-class PD baked into the MJCF mean the HAL just writes target → ctrl and steps, no software PD loop needed.
class OpenArmMujocoHAL(MujocoArmHAL)— 16-DoF (7 arm + 1 gripper per side) bimanual HAL drivingenactic/openarm_mujoco/v2/openarm_v20_bimanual.xml; thin manifest-driven wrapper aroundMujocoArmHAL(bimanual amendment). All wiring lives inOPENARM_DESCRIPTION.sim:openarm_v2:bimanualURI (fetched lazily viaensure_openarm_v2_mjcf), explicitjoint_qpos_addrthat skips the passive follower-finger qpos slots (8, 17), twoPASSTHROUGHgrippers (left jaw[0, 0.7854], right jaw[-0.7854, 0]),seed_ctrl_from_qpos: trueso the v2<position>actuators hold the initial pose on the firstmj_step. (L403)__init__(*, mjcf_path=None, settle_steps=1, gravity_enabled=True, staleness_limit_s=0.5)— Forwards toself._init_from_description(OPENARM_DESCRIPTION, …). (L438)_openarm_arm_joint_specs(names, position_limits, side) -> list[JointSpec],_openarm_gripper_joint_spec(name, side, position_limits) -> JointSpec,_openarm_joint_specs() -> list[JointSpec](L167, L190, L206)- const
OPENARM_DESCRIPTION = RobotDescription(...)(L238) — sim baseline (name="openarm_v2", all 16 joints revolute matching v2's hinge gripper).sdk_kind="open",hal.sim="openral_hal.openarm:OpenArmMujocoHAL"+hal.real=None(sim-only). Drift-guarded againstrobots/openarm/robot.yaml.
python/hal/src/openral_hal/anvil_openarm_v2.py
MuJoCo digital twin for the Anvil OpenARM 2.0 — Anvil Robotics' manufactured variant of the standard OpenArm v2 (docs.anvil.bot/introduction/openarm-2.0). Differs from the Enactic v2 twin in exactly two documented joint ranges (J1 ±135 deg; J6 -45..+70 deg radial deviation) plus the wrist support bracket that enables it — all baked into the fetched MJCF, so the HAL stays a thin manifest-driven subclass (ADR-0023).
class AnvilOpenArmV2MujocoHAL(MujocoArmHAL)— 16-DoF (7 arm + 1 gripper per side) bimanual HAL drivingmodels/anvil_openarm_bimanual.xmlfrom the pinnedbensonlee5/anvil-openarm-mujococlone via theopenarm:anvil_v2_bimanualref. All wiring lives inANVIL_OPENARM_V2_DESCRIPTION.sim: joint→qpos map skipping the equality-coupled follower fingers (qpos 8, 17), twoPASSTHROUGHhinge grippers (left jaw[0, 0.7854], right jaw[-0.7854, 0]),seed_ctrl_from_qpos: truefor the native<position>actuators. Structurally identical toOpenArmMujocoHAL— the Anvil-ness is entirely in the asset. (L367)__init__(*, mjcf_path=None, settle_steps=1, gravity_enabled=True, staleness_limit_s=0.5)— Forwards toself._init_from_description(ANVIL_OPENARM_V2_DESCRIPTION, …). (L405)_anvil_arm_joint_specs(names, position_limits, side) -> list[JointSpec],_anvil_gripper_joint_spec(name, side, position_limits) -> JointSpec,_anvil_joint_specs() -> list[JointSpec](L160, L184, L204)- const
ANVIL_OPENARM_V2_DESCRIPTION = RobotDescription(...)(L215) — sim baseline (name="anvil_openarm_v2", all 16 joints revolute, hinge grippers; J1/J6 carry the Anvil ranges on both arms, J2 keeps the v2 mirrored asymmetry; two wrist RGBSensorSpecs rendering the MJCF'scamera_wrist_{left,right}at 640×400).sdk_kind="open",hal.sim="openral_hal.anvil_openarm_v2:AnvilOpenArmV2MujocoHAL"+hal.real=None(sim-only; a wrapper around Anvil's driver stack is a tracked follow-up). Drift-guarded againstrobots/anvil_openarm_v2/robot.yaml.
python/hal/src/openral_hal/_pinned_clone.py
fetch_pinned_clone(repo_url, sha, repo_dir, *, submodule=None, what=…) -> None— THE shared staged-clone + atomic-rename dance for the pinned-SHA asset fetchers (shallow--filter=blob:noneclone into a per-call staging dir, checkout of the pinned SHA, optional single-submodule init,os.renameinto place with the loser of a concurrent race discarded). Extracted from the two OpenArm fetchers, which carried ~30 identical concurrency-critical lines each; a future fix (e.g.EXDEVon cross-filesystem rename) now lands once. RaisesROSConfigErrorwhengitis missing or any git step fails. (L30)
python/hal/src/openral_hal/_anvil_openarm_v2_assets.py
Vendor the Anvil OpenARM 2.0 MJCF from bensonlee5/anvil-openarm-mujoco — no upstream package ships the Anvil variant.
ensure_anvil_openarm_v2_mjcf() -> str— Idempotently clonesbensonlee5/anvil-openarm-mujocoat a pinned SHA into$OPENRAL_CACHE_DIR/anvil_openarm_v2/<sha>/, initialises itsupstream/openarm_mujocomesh submodule (the generated MJCF's meshdir points into it), and returns the bimanual MJCF path. RaisesROSConfigErrorwhengitis missing or the clone / submodule init fails. Mirrors_openarm_v2_assets.ensure_openarm_v2_mjcfplus the submodule step. (L60)- module const
_ANVIL_PINNED_SHA: str(L40) — bump when the generator or the local Anvil spec changes.
python/hal/src/openral_hal/_openarm_v2_assets.py
Vendor the upstream enactic/openarm_mujoco v2 MJCF until robot_descriptions bumps its pin past PR #19.
ensure_openarm_v2_mjcf() -> str— Idempotently clonesenactic/openarm_mujocoat a pinned v2 SHA into$OPENRAL_CACHE_DIR/openarm_v2/<sha>/, returns the bimanual MJCF path. RaisesROSConfigErrorwhengitis missing or the clone fails. Mirrors the pattern used bypython/sim/src/openral_sim/backends/so100_robosuite/_assets.py. (L64)- module const
_OPENARM_V2_PINNED_SHA: str(L47) — bump to track upstream v2 updates.
python/hal/src/openral_hal/g1.py
MuJoCo digital twin for the Unitree G1 humanoid. The default stock-Menagerie path provides joint-contract validation + ADR-0087 kinematic glide; explicit walking_enabled=True selects ADR-0089's pinned MuJoCo Playground ONNX policy and matching gravity-on dynamics.
class G1MujocoHAL(MujocoArmHAL)— 29-DoF humanoid HAL. Default mode drivesmujoco_menagerie/unitree_g1/g1.xml; walking mode swaps in the pinned policy-tuned MJCF + ONNX controller. Floating-base joint remains implicit world state. (L388)__init__(*, mjcf_path=None, settle_steps=1, gravity_enabled=True, staleness_limit_s=0.5, body_twist_dt_s=0.05, walking_enabled=False)(L426)base_pose -> tuple[float, float, float](property) — current glide pin or live walking base pose. (L484)base_twist -> tuple[float, ...](property) — last commanded 6-vec base twist (base_linkframe), zeroed by any non-BODY_TWIST action (matchesPandaMobileHAL). (L492)send_action(action)— routesBODY_TWISTto either the ADR-0087 glide or ADR-0089 walking controller; every other mode defers to the joint path. Non-planar twist components raiseROSConfigError. (L521)idle_step(wall_dt_s=None) -> bool— HOLD-step with the base pinned; walking state resets so a stale velocity command is never replayed. G1 opts out of the bare-arm_step_while_activepath because an active BODY_TWIST must not be replaced by this zero/HOLD behavior. (L568)_per_step_update(targets)/_pin_base()/_pin()— the upright pin:qpos[0:7]= glide pose (roll/pitch clamped to 0),qvel[0:6]= 0, captured lazily from the fresh qpos after connect. Replacing the pin with a balance controller is the designed S0 upgrade seam. (L608)_g1_group(joint_name) -> str— Return the kinematic group token (hip/knee/ankle/waist/shoulder/elbow/wrist) forjoint_name. (L218)_g1_parent_child(joint_name) -> tuple[str, str]— Return(parent_link, child_link)for a G1 joint, following the menagerie URDF convention. (L226)_g1_joint_specs() -> list[JointSpec]— Build the 29JointSpecs from the joint-name tuples and the per-joint limit tables. (L276)- const
G1_DESCRIPTION = RobotDescription(...)(L304) — sim baseline;sdk_kind="open",hal.sim="openral_hal.g1:G1MujocoHAL"+hal.real=None(sim-only until M2). Advertisessupported_control_modes=[joint_position, body_twist],embodiment_tagsincl.mobile_base, and a forwardheadRGB camera (vla_feature_key=observation.images.head, rigged ontotorso_linkvia the ADR-0086 camera rig) so BODY_TWIST nav skills (InternVLA-N1 VLN) match. All MuJoCo wiring (MJCF URI, floating-base joint offsets) lives inG1_DESCRIPTION.sim. Drift-guarded againstrobots/g1/robot.yamlbytests/unit/test_robot_manifests_match_hal_constants.py.
python/hal/src/openral_hal/_g1_walking.py
ADR-0089's private, sim-only walking implementation.
ensure_g1_walking_assets() -> tuple[str, str]— downloads four files from pinned MuJoCo Playground commit43d180a, verifies SHA-256, links the cached Menagerie meshes, flattens the included MJCF, and returns(mjcf_path, policy_path).class G1WalkingController— 50 Hz CPU ONNX inference over 500 Hz MuJoCo physics; builds the upstream 103-D observation, validates the 29-D finite output, scales/clips joint targets, and persists gait phase/action history.
python/hal/src/openral_hal/so100_mujoco.py
MuJoCo digital twin for the SO-100 follower arm (Menagerie MJCF).
class SO100MujocoHAL(MujocoArmHAL)— SO-100 follower MuJoCo HAL, driving themujoco_menagerietrs_so_arm100/so_arm100.xmlwith the same 6-DoF action layout asSO100FollowerHAL. Maps the lerobot-style description joint names to the Menagerie joints (shoulder_pan→Rotation, …,gripper→Jaw) and normalises the revolute Jaw range[-0.174, 1.75]to[0, 1]. Thin manifest-driven wrapper;__init__forwards toself._init_from_description(SO100_DESCRIPTION, …). (L68)__init__(*, mjcf_path=None, settle_steps=1, gravity_enabled=True, staleness_limit_s=0.5)(L108)_read_gripper_normalised() -> float— Override that offsets the closed position from-0.174rad (the base helper assumes closed at qpos == 0).
python/hal/src/openral_hal/so100_sim.py
SO100DigitalTwin — in-process simulator for the SO-100 follower arm.
class SO100DigitalTwinConfig(RobotConfig)— Config for the digital twin. (L59) field:initial_positionsclass SO100DigitalTwin(Robot)— In-process digital twin. (L76)__init__(config)(L101)observation_features() -> dict[str, type]— One float per joint pos. (L116)action_features() -> dict[str, type]— One float per target. (L125)is_connected -> bool[@property] (L134)is_calibrated -> bool[@property] — Always True. (L139)connect(calibrate=True) -> None— Activate (no serial port opened). (L146)calibrate() -> None— No-op. (L154)configure() -> None— No-op. (L158)get_observation() -> RobotObservation— Lerobot-native units. (L162)send_action(action) -> RobotAction— Apply position cmd, update state. (L175)disconnect() -> None— Deactivate (idempotent). (L194)
python/hal/src/openral_hal/ros_control.py
RosControlHAL — ros2_control-backed HAL adapter.
class RosControlHAL—ros2_control-backed HAL adapter. (L72)__init__(description, controller_name, *, joint_state_topic='/joint_states', command_topic=None, publish_fn=None, state_fn=None, staleness_limit_s=0.5)(L101)connect() -> None(L132)disconnect() -> None(L150)read_state() -> JointState(L162)send_action(action) -> None— Publish JointTrajectory. (L199)estop() -> None(L230)- private:
_require_connected,_validate_action _default_publish(topic, msg) -> None— No-op publish when no real ROS 2 node. (L62)
python/hal/src/openral_hal/sim_transport.py
SimTransport — in-memory simulated ros2_control transport.
class SimTransport— In-memory transport simulating a JointTrajectory controller. (L32)__init__(n_joints)(L63)publish(topic, msg) -> None— Record msg, applyjoint_targets. (L73)state() -> dict[str, object]— Current simulated joint state. (L91)call_count -> int[@property] (L107)last_call -> tuple | None[@property] (L112)calls -> list[tuple][@property] (L117)
python/hal/src/openral_hal/lifecycle.py
Generic ROS 2 managed lifecycle node wrapper for every HAL adapter — UR5e / UR10e / Franka / SO-100 / OpenArm / H1 / future HALs all share the same publish / subscribe / heartbeat / OTel-span wiring.
class HALLifecycleNodeBase(LifecycleNode)— Public base class. Owns the standard/joint_states+~/joint_statespublishers, the/openral/safe_action+/openral/estopsubscribers, the 1 HzDiagnosticsHeartbeat, the per-tickhal.read_state+hal.send_actionOTel spans, the estop latch, and the full configure → activate → deactivate → cleanup → shutdown transition wiring. The formerly used~/command(trajectory_msgs/JointTrajectory) subscriber + its_on_commandcallback + the_subscriberfield were removed;_send_action_tracedis now driven only by_on_safe_action. (L372)_create_hal(self) -> HAL— Subclass hook (required): construct and return a HAL instance. Reads ROS-parameter-driven constructor args viaself.get_parameter(...). (L444)_heartbeat_extra_fields(self) -> dict[str, str]— Subclass hook (optional): extra key/values for the/diagnosticspayload (e.g.{"port": "/dev/ttyUSB0"}for SO-100,{"mjcf": "..."}for OpenArm). Default:{}. (L456)on_configure_post_hal(self) -> TransitionCallbackReturn— Subclass hook (optional): robot-specific setup after the HAL connects (e.g. opening a camera renderer on OpenArm). Default:SUCCESS. (L508)on_activate_post_subs(self) -> TransitionCallbackReturn— Subclass hook (optional): robot-specific timers/publishers after the base wires its subs (e.g. the OpenArm camera-render timer). Default:SUCCESS. (L517)on_deactivate_pre_teardown(self) -> None— Subclass hook (optional): stop robot-specific timers before base teardown. Default: no-op. (L525)on_cleanup_pre_disconnect(self) -> None— Subclass hook (optional): tear down robot-specific resources (viewers, renderers) before HAL.disconnect(). Default: no-op. (L532)_publish_joint_state(self) -> None— Timer callback. Wrapsself._hal.read_state()in ahal.read_statespan (identity attrs +producer.record_joint_state) and publishes the standard/joint_states+~/joint_statesmessages; when the HAL exposesread_policy_state, also publishes/openral/policy_state(std_msgs/Float32MultiArray) — only when the underlyingProprioFrameis NEW (one publish per env.step capture, never a latched republish, so the world-state aggregator's dedicatedpolicy_statestaleness window genuinely trips on a wedged simulator). Subclasses may override + callsuper()._publish_joint_state()to extend (OpenArm does this for viewer-sync). (L868)_on_safe_action(self, msg) -> None—/openral/safe_actioncallback. Decodes theopenral_msgs/ActionChunkinto anopenral_core.Actionand forwards through_send_action_traced(action, source="safe_action"). (L963)_send_action_traced(self, action, *, source) -> None— Forwardactiontoself._hal.send_actioninside ahal.send_actionspan. Thesourceattribute disambiguates the origin on the dashboard's Commands card (kept on the span so future subscriber additions can fan in without changing the span shape). (L1015)_on_estop(self, msg) -> None—/openral/estopcallback. Ordered latch → stop → report: sets_estopped(which makes_on_safe_actiondrop commands), calls_invoke_hal_estop, then reports via_emit_estop_telemetryin afinallyso an e-stop is counted even when the vendor stop path raises and even for HALs that opt out of it. Nothing is added ahead of the physical stop._invoke_hal_estop(self) -> None— Callsself._hal.estop()for HALs implementingLifecycleEStopHAL; swallowsROSEStopRequested(expected completion signal) and logs any other failure at fatal — the latch must survive a vendor stop-path failure._emit_estop_telemetry(self) -> None— Emits theopenral.event.estop_requestedspan event (on the active span, else a transienthal.estopspan) and incrementsopenral.hal.estop.countwith ahal.adapterlabel. This is the only producer of either signal. The dashboard ingests OTLP, not ROS topics, so/openral/estopis invisible to it and its Command-bande-stopscounter read 0 no matter how many e-stops fired — a safety indicator that cannot leave zero reads as an affirmative "no e-stops have occurred". This node is the right chokepoint: it is the shared base every robot HAL runs on and it sits on the actuation side, whereas counting at the six/openral/estoppublishers would need six call sites (one of them the C++ kernel) and counting at every subscriber would multiply one e-stop into several. Never raises — telemetry must not disturb the stop path (CLAUDE.md §1.1).make_lifecycle_main(node_name, hal_factory) -> Callable[[], None]— Build amain()entry point for a zero-parameter HAL adapter. Internally constructs a_FactoryHALLifecycleNode(HALLifecycleNodeBase)whose_create_hal()returnshal_factory(). Superseded for the standard arms bymake_lifecycle_main_from_manifest; retained for bespoke nodes. (L244)class ManifestHALLifecycleNode(HALLifecycleNodeBase)— Public generic manifest-driven lifecycle node (promoted from the private_ManifestHALLifecycleNodeunder issue #191). Readsrobot_yaml+hal_mode+ sensor knobs as ROS params and builds its HAL viaopenral_hal.build_hal, so a robot's construction kwargs come from the manifest'shal.parameters.defaults— no bespoke_create_halsubclass. AttachesSimSensorBridge(cameras / depth / scan / viewer) inon_activate_post_subs. Inon_configure_post_hal, reflects on the built HAL and opens/openral/<robot>/reset_to_pose(openral_msgs/srv/ResetToPose) iff it exposesreset_to_pose— generalising the openarm-only service to everyMujocoArmHALsim arm (issue #191 Phase 2); HALs without the method (panda_mobile, scene-attached twins) get no service. In_create_hal, when a scene composition is declared (and not scene-attaching), calls the named composer and threads the composed MJCF in as the HAL'smjcf_path(issue #191 Phase 3b — openarm tabletop); the composition is read from thescene_composition_jsonROS param (the DeployScene's owncomposition) which takes precedence over the robot manifest'sscene_defaults.composition(back-compat fallback) — so the scene owns its arena, the robot manifest describes the robot. Bare-twin camera robots (so100/so101) need no composition: their cameras are spliced by the generic camera rig at HAL connect fromsensors[].sim_placement. Inon_activate_post_subs, when the manifest declares a planar base (base_joints), also attaches aMobileBaseBridge(/odom+odom->base_linkTF +/cmd_vel→BODY_TWIST) — so panda_mobile runs on this node with no subclass (issue #191 Phase 3a). The per-robot lifecycle packages collapse into this node (issue #191 Phases 2-3). A back-compat alias_ManifestHALLifecycleNodeis retained. (L1237)make_lifecycle_main_from_manifest(node_name) -> Callable[[], None]— Build amain()that spins upManifestHALLifecycleNode. The node readsrobot_yaml+hal_mode("sim"|"real") ROS params and constructs its HAL viaopenral_hal.build_hal(description, mode=hal_mode)— one node class serves both modes for every robot. Used by franka / ur5e / ur10e / aloha / g1 / h1 / rizon4 / so100 / so101 (issue #191 Phase 2 migrated so100/so101 off their bespoke node);openral deploy siminjectshal_mode="sim",openral deploy runinjectshal_mode="real". A robot lacking the requested mode raisesROSCapabilityMismatch. (L302)decode_action_chunk(msg) -> Action | None— Inverse ofros_publishing_hal._flatten_action_payload. Decodes theActionChunkwire shape (flat+n_dof+horizon+control_mode) back into a typedopenral_core.Actionwith the per-mode payload field populated (cartesian_delta/gripper/body_twist/joint_*). ReturnsNonefor degenerate chunks (flat=[],n_dof≤0) and for modes the F1/F5 publisher doesn't encode (CARTESIAN_POSE,FOOT_PLACEMENT,DEX_HAND_JOINT). Preservesee_name,frame_id,confidence,tick_index, andcartesian_delta_scale; the scale is metadata for predictive safety only, while the raw delta continues to the HAL/native controller unchanged. (L106)
python/hal/src/openral_hal/sim_bringup.py
Resolve a SimScene or BenchmarkScene YAML path to a live SimRollout. Used by build_hal, which every manifest-driven node (incl. panda_mobile, issue #191 Phase 3) routes through.
build_sim_env_from_yaml(sim_env_yaml: str, *, robot_id_fallback: str | None = None) -> tuple[SimRollout, int | None]— Load aSimSceneorBenchmarkSceneYAML, resolve its scene id inopenral_sim.SCENES, and instantiate the env. Relative paths are resolved by walking parents of the source file (ROS param values are cwd-naïve). Returns(env, seed)— the caller plumbs the seed intoSimAttachedHAL(env_reset_seed=seed). RaisesROSConfigErrorwhen the YAML is not found, the scene id is unregistered, or schema validation fails. Robocasa scenes haveignore_done=Trueinjected so deploy-sim continuous stepping does not trip the episode-done guard; strict-validation native backends (e.g.tabletop_push) are unchanged. (L174)hal_transition_timeout_s(deploy_config: str | None) -> str— Per-transition budget for the HAL'stools/lifecycle_autostart.py, derived from the scene rather than hardcoded insim_e2e.launch.py. Returnsmax(300.0, scene.backend_options.boot_timeout_s + 60.0)as a string; falls back to the 300 s floor for no config, an unreadable path, or an absent/malformed value (reporting a bad scene belongs to the resolver, and raising here would abort the launch before any node starts). Lives besidebuild_sim_env_from_yamlbecause that is the call it bounds: a sidecar backend boots the simulator insideon_configurevia the factory'sconnect(), andisaac_sim(900 s) /behavior(1200 s) /robotwin(600 s) all exceed the old fixed 300 s — and while Isaac Sim 5.1 reachesapp readyin ~13 s on an RTX 4070 Laptop, a sidecar that wedges after that burns the client's wholeboot_timeout_sbeforeconnect()raises (observed live), so the transition's worst case is the declared budget rather than the nominal boot. Today only the fourscenes/deploy/isaac_*.yamlandbehavior_r1pro.yamlclear the floor. (L277)- const
HAL_TRANSITION_TIMEOUT_FLOOR_S = 300.0/HAL_TRANSITION_TIMEOUT_MARGIN_S = 60.0— The floor covers a robocasa-kitchen first boot (MuJoCo + robosuite import,env.reset, cold uv resolve); the margin covers the import + first reset that follow the sidecar handshake. The floor is also the ceiling on how long a wedged HAL stays unreported on scenes that ask for nothing more. (L271)
python/hal/src/openral_hal/sim_attached.py
SimAttachedHAL — generic HAL Protocol adapter that wraps any in-process SimRollout. Shared by panda_mobile, manifest-driven arms, and tests; not import-safe without openral_sim + mujoco.
ActionPacker— Type aliasCallable[..., np.ndarray]. Per-composition translator between an OpenRALActionand the env's flat action vector; the default factory ispack_action_for_env. The optional trailingprevarg (the previous env-frame command, orNone) lets a packer carry an untouched slot — e.g. the gripper while the arm steps — across the two typed Actions one policy step splits into on a non-composite env. Pass a custom instance toSimAttachedHAL.__init__for whole-body humanoid or dexterous-hand action layouts. (L154)normalized_joint_index(model_joint_names: list[str]) -> dict[str, int]— Map MJCF joint names (exact + robosuite-prefix-stripped) to model index. Exact names always win;robot0_joint1→joint1(strip^[a-z]+[0-9]+_) is added only when it neither shadows an exact name nor collides (bimanualrobot0_/robot1_ambiguity → keep explicit). Used bySimAttachedHAL.read_stateso one manifest serves both native MjSpec and robosuite scenes;robot0_never appears in a manifest. (L95)pack_action_for_env(action: Action, description: RobotDescription, env_action_dim: int, prev: np.ndarray | None = None) -> np.ndarray— DefaultActionPacker. TranslatesJOINT_POSITION(arm-only or full base+arm),BODY_TWIST(vx/vy/wz → slots 0-2),CARTESIAN_DELTA(6-vec → arm slots[base_dim:]), andGRIPPER_POSITION(→ last slot) into the env's flat action vector. RaisesROSConfigErrorfor unsupported modes or mismatched row widths. A single VLA policy step on a non-composite env (LIBERO OSC_POSE, SimplerEnv widowx — everydelta_ee_6d_plus_gripperrSkill) splits into a CARTESIAN_DELTA then a GRIPPER_POSITION Action, eachenv.step-ed;prev(threaded fromSimAttachedHAL._last_env_action) carries the last commanded gripper through the arm step and holds the arm on the gripper step, so the arm advances once per policy step with the gripper always commanded — mirroring_pack_with_composite_split. Without it each Action zeroed the other's slots and the arm barely moved. (L172)class SimAttachedHAL— HAL Protocol adapter wrapping an in-processSimRollout. Reads live joint state vianormalized_joint_index+mj_name2id; sends actions viapack_action_for_env(or a caller-suppliedActionPacker) intoenv.step(). Exposesread_images(),mujoco_handles(),sim_time_ns(),base_pose,base_twist,base_pose_6dof()for the ROS lifecycle node's camera publisher, viewer, sim-clock, and odom wiring. (L356)__init__(env: SimRollout, description: RobotDescription, *, action_packer: ActionPacker | None = None, env_reset_seed: int | None = None, env_action_dim: int | None = None, body_twist_dt_s: float = 0.05) -> None(L385)connect() -> None— Reset the env atenv_reset_seed; probeenv_action_dim(via_probe_env_action_dim, which raisesROSConfigErrornaming the backend when noaction_dimis introspectable and no override was given — never a silent fallback); invalidate joint-index cache. Idempotent. (L497)disconnect() -> None— Release env handle (idempotent). (L571)read_state() -> JointState— Walkdescription.joints, resolve each joint vianormalized_joint_index, read liveqpos/qvelfrom MJCF. (L575)send_action(action: Action) -> None— Pack action via composite-split orActionPacker; callenv.step.BODY_TWISTtakes a direct-qpos Euler-integration path on a MuJoCo backend; non-MuJoCo backends route through_apply_body_twist_via_env_step. Stampslast_action_nsso the idle stepper yields. Deploy sim ignores success/terminal metadata and never resets the environment after startup; a backend that rejects a post-terminal command surfaces a typed error._stage_action_group(action, group_step) -> None— For backends exposingaction_group_size+step_action_group, stage each safety-approved slot byAction.tick_indexand commit exactly one simulator step only when the complete tick is present. Rejected/missing slots never actuate (atomicity); transitioning to a newer tick with an incomplete group raisesROSRuntimeError, aligned with the producer's applied-tick barrier. On commit, latches the group's BODY_TWIST slot into_last_body_twist.read_policy_state() -> list[float] | None— Cached simulator-native checkpoint proprioception (e.g. BEHAVIOR's official 61-D R1Pro vector) for/openral/policy_state; only an explicitobs["policy_state"], never inferred from joint state.idle_step() -> bool— Sim-only free-running stepper (2026-06-04 amendment). Advances the wrappedSimRolloutone tick withnp.zeros(env_action_dim)(HOLD) so cameras keep rendering when no skill is executing — without it an idle deploy-sim scene freezes and the perception bus sees a dead scene. ReturnsFalse(suppressed) when not connected, estop-latched,env_action_dim is None, or no live MuJoCo handles; else steps and returnsTrue. Mirrorssend_action's deferred-reset branch and_last_obsre-cache; does NOT touch_last_env_action/_last_body_twist. Defined ONLY onSimAttachedHAL— real HALs never define it; this method-only exclusion (not "zero is harmless") is the primary real-hardware guard, since a zero vector is a HOLD in sim but "drive to 0 rad" on a real position arm. (L932) Refuses to interleave a HOLD inside a mid-flight atomic action group, but discards a pending group loudly after 5 s without a new slot so a skill that dies mid-tick cannot freeze scene physics/cameras forever.read_images() -> dict[str, Any]— Return latest rendered camera frames keyed by camera name from the cached_last_obs. (L1554)read_depth_clouds() -> dict[str, NDArray]— Per-depth-sensor(N,3)base_linkpoint clouds from_last_obs["depth_points"](a non-MuJoCo backend, e.g. the Isaac scene, deprojects viaCamera.get_pointcloudso the HAL never re-derives geometry);{}when the backend renders no depth.SimSensorBridgepublishes them asPointCloud2for octomap.read_scan() -> NDArray | None— The 2-D LaserScan range fan (base_link,angle_min=-π→+π) from_last_obs["scan"]when a non-MuJoCo backend ray-casts a lidar (the Isaac scene);Nonewhen it renders no lidar.SimSensorBridge._compute_scan_rangesreads it for/scan.mujoco_handles() -> tuple[Any, Any] | None— Forward the env's(model, data)MJCF handles. (L1429)last_committed_tick -> int[@property] — Most recent complete atomic action group committed to the simulator.sim_time_ns() -> int | None— Cross-reconnect-monotonic elapsed sim time in ns.connectfolds elapsed time into an offset before an explicit lifecycle reset rewinds the backend clock; deploy actions and terminal signals never reset it.clock_authority() -> ClockAuthority— Return the timestamp authority this HAL contributes to the graph:ClockAuthority.simulation(<backend>, timestep_s=body_twist_dt_s)whensim_time_ns()is live, otherwiseClockAuthority.host_wall()so launch keeps the graph on the host-wall authority.estop() -> None— Latch e-stop; subsequentsend_actioncalls are dropped. (L1421)base_pose -> tuple[float, float, float][@property] — Current(x, y, yaw): from MJCF qpos on a MuJoCo backend, else fromobs["base_pose"]the SimRollout surfaces (Isaac kinematic base);(0,0,0)when the backend reports neither. Feeds the/odompublisher. (L1618)base_twist -> tuple[float, float, float, float, float, float][@property] — Last commanded body twist(vx, vy, vz, wx, wy, wz). (L1671)_apply_body_twist_via_env_step(row: list[float]) -> None— Non-MuJoCoBODY_TWIST: validate the planar twist, latch it for/odom, pack(vx, vy, wz)into the FINAL three env-action slots (the manifest scene's[arm…, gripper, base-twist]layout), zero the arm/gripper so a pure base move holds the arm, and_step_and_cache.base_pose_6dof() -> tuple[...] | None— Full 6-DoF(xyz, quat_xyzw)from robocasaraw_proprio; falls back toNonefor non-robocasa backends. (L1681)last_action_ns -> int[@property] — Monotonic ns of the last real action throughsend_action;0until the first one. The idle stepper reads it (viashould_idle_step) to yield to an active skill. (L1544)
python/hal/src/openral_hal/sim_sensor_bridge.py
Shared sim-sensor + viewer bridge for scene-attached HAL lifecycle nodes. Republishes RGB camera frames and a live MuJoCo viewer for any manifest-driven node, and runs the sim-only idle stepper. Phase 2 adds /scan + depth PointCloud2. Depth comes from the MuJoCo ray-cast (_publish_depth_clouds) OR, for a non-MuJoCo backend that surfaces ready base_link clouds in obs (the Isaac scene), _publish_depth_clouds_from_obs — which wraps hal.read_depth_clouds() into a base_link PointCloud2 (no ray-cast, no per-camera optical TF); _setup_depth creates the publishers when either source is present. rclpy imported lazily.
should_idle_step(now_ns: int, last_action_ns: int, idle_hold_ns: int, *, step_while_active=False) -> bool— Pure predicate (no rclpy) for the sim-only wall-time stepper. Scene-attached environments returnTrueonly after the idle-hold window; bareMujocoArmHALpassesstep_while_active=Trueso its current control target keeps integrating during active skills and/clock+ camera timers stay live. Used bySimSensorBridge._idle_step_tick; unit-testable in isolation. (L48)constant_scan_no_hit_ranges(*, n_beams: int, max_range_m: float) -> list[float]— Pure (no rclpy) synthetic/scanfan: every beam clamped tomax_range_m("no hit everywhere"), the honest reading for an in-process digital twin with no scene to ray-cast. Used bySimSensorBridge._compute_scan_ranges's no-handles branch; moved out of the panda_mobile node in issue #191 Phase 3. (sim_sensor_bridge.py)class SimSensorBridge— Wire and tear down sim-sensor publishers and the MuJoCo viewer on a HAL lifecycle node. Streams are gated on the robot manifest + HAL capability. Owns/scanfor both paths since issue #191 Phase 3: live MJCF ray-cast whenhal.mujoco_handles()is bound (SimAttachedHAL), aconstant_scan_no_hit_rangesfan for the bare digital twin (the node no longer publishes its own scan). (L147)__init__(node: Any, hal: Any, description: RobotDescription, *, viewer_enabled: bool = True, camera_rate_hz: float = 10.0, viewer_sync_rate_hz: float = 30.0, scan_rate_hz: float = 10.0, scan_n_beams: int = 360, scan_max_range_m: float = 12.0, scan_min_range_m: float = 0.05, depth_rate_hz: float = 10.0, depth_max_range_m: float = 5.0, depth_pixel_stride: int = 4, idle_hold_ms: float = 200.0, on_step: Any = None) -> None—on_step: optional zero-arg callback invoked after each successfulidle_step(the node refreshes the proprio snapshot through it, so odom/joint_state stay fresh while idle). (L164)setup() -> None— Activate all streams the manifest + HAL support: RGB camera publishers on/openral/cameras/<n>/image+ a per-frameCameraInfocompanion on/openral/cameras/<n>/camera_info(pinhole intrinsics derived from the MJCFcam_fovy:fy=(h/2)/tan(fovy/2),fx=fy, centre principal point, stamped with the manifestframe_id— what cuVSLAM builds its rig from and nvblox frames mono depth against; gated onhasattr(hal, "read_images")+ RGBSensorSpec; the RGB topics are advertised lazily by_advertise_cameraon each camera's first real frame, not here — see that entry), the sim-only idle stepper (gated oncallable(getattr(hal, "idle_step", None))+ live MuJoCo handles), viewer (mujoco.viewer.launch_passivewithshow_left_ui=False, show_right_ui=Falseso only the sim renders;_aim_viewer_camerathen sets the opening free-camera pose viainitial_viewer_camera— eye at a 3rd-person scene camera, orbit pivot on the base, base-aligned default for camera-less twins — leaving the cameramjCAMERA_FREEso the user can orbit/zoom; GL/DISPLAY failure → warn + continue). Idempotent per activate. (L299)_advertise_camera(name: str) -> Any— Create (once) and return theImagepublisher for one camera, plus itscamera_infocompanion. Called from_publish_imagesthe first time the backend actually hands up a frame forname, so the advertised topic set is exactly what this scene renders — never the manifest superset. A manifest may legitimately declare a camera the current scene cannot supply (panda_mobile's syntheticheadnav cam needs a mobile base +OPENRAL_ROBOCASA_HEAD_CAM=1, whichdeploysets only when a capability-matched rSkill consumes it, so a manipulation-only palette declares the sensor and no backend renders it); advertising eagerly left a permanently silent/openral/cameras/head/imagethat a subscriber cannot distinguish from a slow stream and so waits on forever. A declared camera that never yields a frame gets one warning naming the available obs keys and no topic. (L410)teardown() -> None— Cancel timers (incl. the idle-step timer), destroy publishers, close viewer. Called fromon_deactivate/on_cleanup. (L308)class MobileBaseBridge— Generic planar-mobile-base ROS wiring (sibling ofSimSensorBridge): owns/odom, theodom->base_linkTF, and the/cmd_vel→BODY_TWIST bridge (out-of-scope: bypasses the safety supervisor; Nav2'svelocity_smoothercaps velocity). Frame ids come fromRobotDescription.{odom_frame,base_frame}; the HAL must exposebase_pose(base_pose_6dof()/base_twistused when present).ManifestHALLifecycleNodeattaches it inon_activate_post_subsiff the manifest declaresbase_joints— so a mobile robot needs no node subclass (issue #191 Phase 3, replaced the bespoke panda_mobile node). (mobile_base_bridge.py)__init__(node, hal, description, *, odom_rate_hz: float = 20.0, cmd_vel_topic: str = "/cmd_vel", proprio: Any = None) -> None—proprio: when set (sim-attached HALs), odom is published from the node's dedicated thread viapublish_from_snapshot, reading the snapshot not the simulator;None(real HALs) keeps the legacy odom timer.setup() -> None— Create the/odompublisher + TF broadcaster +/cmd_velsubscription; the odom timer is created only whenproprio is None(sim HALs publish odom off the node's thread).publish_from_snapshot() -> None— Dedicated-thread entry: publish one/odom+ TF sample from the proprio snapshot (never the simulator). Thin alias over_publish_odom(which branches onproprio).teardown() -> None— Cancel the timer (if any) + destroy the publisher/subscription. Idempotent.
python/hal/src/openral_hal/proprio_snapshot.py
Decouples the control-critical publishers (odom / joint_state / TF) from the single executor thread that runs env.step + render + raycast. The sim-attached HAL node captures a frame after each step (on the executor thread, where reading the sim is safe) and a dedicated publisher thread re-emits it at ~30 Hz, so odom stays fresh (~28 Hz live, vs ~1.8 Hz starved) without ever touching MjData/GL off the executor thread (a MultiThreadedExecutor was rejected — MuJoCo's GL context is thread-affine).
class ProprioFrame— Frozen dataclass: one coherent proprio sample (state: JointState,base_pose: (x,y,yaw),base_pose_6dof: ((x,y,z),(qx,qy,qz,qw)) | None,base_twist: tuple[float, ...],sim_time_ns: int | None— sim time carried for the /clock publisher,policy_state: tuple[float, ...] | None— simulator-native checkpoint state vector). Plain immutable data only — no live simulator handles — so it is safe to publish from a different thread than the one that stepped the sim. (proprio_snapshot.py)class ProprioSnapshot— Lock-guarded holder for the latestProprioFrame. One writer (the executor thread, after each step) callsset; readers (the publisher thread) calllatest; the immutable frame is swapped under the lock so a reader never sees a torn frame and never reaches the HAL. HAL-agnostic — the node does the capture. (proprio_snapshot.py)set(frame: ProprioFrame) -> None— Atomically publishframeas the latest sample (executor thread only). (proprio_snapshot.py)latest() -> ProprioFrame | None— Return the most recent frame, orNonebefore the first capture. (proprio_snapshot.py)